brick.ts

Comments Plugin Pattern

Attach discussion threads to any parent resource using a canonical comments schema.

llms.txt

This guide demonstrates the canonical Brick-TS comments plugin pattern, attaching threaded discussions to any parent resource with target foreign-key validation.

Canonical Schema

The comments pattern uses a single, unified database schema across all services:

src/extensions/comments/schema.ts
import { sqliteTable, text } from "@brickkit/core";

export const commentsTable = sqliteTable("comments", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  targetId: text("target_id").notNull(),
  body: text("body").notNull(),
});

Schema

These columns match the Drizzle tables in the example. Defaults marked as application defaults run in Drizzle, rather than in SQL.

Table Name: comments

FieldTypeDescription
idtextRow identifier. PK Default: crypto.randomUUID() (application)
target_idtextParent record identifier, validated by the application.
bodytextText content.

Table Name: pages

FieldTypeDescription
idtextRow identifier. PK Default: crypto.randomUUID() (application)
titletextDisplay title.

Plugin Implementation

Implement the comments factory using defineResourcePlugin and @brickkit/crud:

src/extensions/comments/index.ts
import { ActionExecutionError, defineResourcePlugin, eq } from "@brickkit/core";
import { crud } from "@brickkit/crud";
import { commentsTable } from "./schema";

export { commentsTable } from "./schema";

/** Resource plugin that attaches comments to any host resource. */
export function comments(options: { table: typeof commentsTable }) {
  return defineResourcePlugin({
    name: "example/comments",
    setup({ resource, service }) {
      const comment = service
        .resource({
          name: `${resource.name}Comment`,
          table: options.table,
          id: options.table.id,
        })
        .use(
          crud({
            // Anyone may read and post comments; editing and deleting
            // existing comments requires an authenticated user.
            access: {
              authorize: ({ operation, ctx }) => {
                if (operation === "update" || operation === "delete") {
                  return !!ctx.user;
                }

                return true;
              },
            },
            fields: {
              id: { read: true, filter: ["eq"] },
              targetId: { read: true, create: true, filter: ["eq"] },
              body: {
                read: true,
                create: true,
                update: true,
                filter: ["contains"],
              },
            },
            hooks: {
              beforeCreate: async ({ data, ctx }) => {
                data.body = String(data.body).trim();
                if (!data.body) {
                  throw new ActionExecutionError(
                    "BAD_REQUEST",
                    "Comment body cannot be blank",
                    400,
                  );
                }

                // Verify parent resource exists in the database
                const parents = await ctx.db
                  .select({ id: resource.id })
                  .from(resource.table)
                  .where(eq(resource.id, data.targetId))
                  .limit(1);

                if (!parents.length) {
                  throw new ActionExecutionError(
                    "NOT_FOUND",
                    "Comment target not found",
                    404,
                  );
                }
              },
              beforeUpdate: ({ data }) => {
                if (data.body !== undefined) {
                  data.body = String(data.body).trim();
                  if (!data.body) {
                    throw new ActionExecutionError(
                      "BAD_REQUEST",
                      "Comment body cannot be blank",
                      400,
                    );
                  }
                }
              },
            },
          }),
        );

      return { comments: comment };
    },
  });
}

Attaching to Resources

Attach the comments plugin to any host resource with .use():

src/services/pages.ts
import {
  defineDatabase,
  defineService,
  sqliteTable,
  text,
} from "@brickkit/core";
import { crud } from "@brickkit/crud";
import { comments, commentsTable } from "../extensions/comments";

export const pagesTable = sqliteTable("pages", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  title: text("title").notNull(),
});

const database = defineDatabase({ tables: { pagesTable, commentsTable } });
export const pagesService = defineService("pages", { database });

// Attach comments plugin to the page resource:
export const page = pagesService
  .resource({ name: "page", table: pagesTable, id: pagesTable.id })
  .use(
    crud({
      access: "public",
      fields: { id: { read: true }, title: { read: true, create: true } },
    }),
  )
  .use(comments({ table: commentsTable }));

This automatically mounts the standard CRUD REST endpoints for the comment resource (under the service prefix, e.g. /api):

  • POST /api/pageComment (create)
  • POST /api/pageComment/query (list)
  • POST /api/pageComment/count and POST /api/pageComment/exists
  • GET /api/pageComment/:id (public), PATCH /api/pageComment/:id and DELETE /api/pageComment/:id (authenticated)

The recipe has no per-comment ownership, so any authenticated user can edit or delete any comment. If comments should be editable only by their author, add an owner column to the table and return an ownership scope from the access policy, alongside authorize.