1131 lines · 35.2 KB · TypeScript
RawDownload
import Database from "better-sqlite3";import { describe, expect, it } from "@effect/vitest";import { drizzle } from "drizzle-orm/better-sqlite3";import { Effect } from "effect";import { fumadb } from "@executor-js/fumadb";import {  createDrizzleRuntimeSchemaFromTables,  createDrizzleRuntimeSchemaSqlFromTables,  drizzleAdapter,} from "@executor-js/fumadb/adapters/drizzle";import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";import { withQueryContext, type AbstractQuery } from "@executor-js/fumadb/query";import { column, idColumn, schema, table } from "@executor-js/fumadb/schema"; interface TenantPolicyContext {  readonly allowedTenantIds: ReadonlySet<string>;  readonly deniedTables: ReadonlySet<string>;  readonly marker: string;  readonly observed: string[];  readonly allowedRegionId?: bigint;} const observe = (context: TenantPolicyContext, event: string) => {  context.observed.push(`${context.marker}:${event}`);}; const assertTenantAllowed = (tableName: string, context: TenantPolicyContext, tenantId: string) => {  observe(context, `${tableName}:assert`);  if (!context.allowedTenantIds.has(tenantId)) {    // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: FumaDB table policy callbacks reject writes by throwing    throw new Error(`tenant ${tenantId} is not allowed for ${tableName}`);  }}; const isReadDenied = (tableName: string, context: TenantPolicyContext) => {  observe(context, `${tableName}:read`);  return context.deniedTables.has(tableName);}; const authors = table("policy_authors", {  id: idColumn("id", "varchar(255)"),  tenantId: column("tenant_id", "varchar(255)"),  name: column("name", "string"),}).policy<TenantPolicyContext>({  name: "tenant.authors",  onRead: ({ builder, context }) => {    if (isReadDenied("authors", context)) return false;    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onCreate: ({ values, context }) => assertTenantAllowed("authors", context, values.tenantId),  onUpdate: ({ builder, set, context }) => {    observe(context, "authors:update");    if (set.tenantId !== undefined) assertTenantAllowed("authors", context, set.tenantId);    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onDelete: ({ builder, context }) => {    observe(context, "authors:delete");    return builder("tenantId", "in", [...context.allowedTenantIds]);  },}); const posts = table("policy_posts", {  id: idColumn("id", "varchar(255)"),  tenantId: column("tenant_id", "varchar(255)"),  authorId: column("author_id", "varchar(255)"),  title: column("title", "string"),}).policy<TenantPolicyContext>({  name: "tenant.posts",  onRead: ({ builder, context }) => {    if (isReadDenied("posts", context)) return false;    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onCreate: ({ values, context }) => assertTenantAllowed("posts", context, values.tenantId),  onUpdate: ({ builder, set, context }) => {    observe(context, "posts:update");    if (set.tenantId !== undefined) assertTenantAllowed("posts", context, set.tenantId);    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onDelete: ({ builder, context }) => {    observe(context, "posts:delete");    return builder("tenantId", "in", [...context.allowedTenantIds]);  },}); const comments = table("policy_comments", {  id: idColumn("id", "varchar(255)"),  tenantId: column("tenant_id", "varchar(255)"),  postId: column("post_id", "varchar(255)"),  body: column("body", "string"),}).policy<TenantPolicyContext>({  name: "tenant.comments",  onRead: ({ builder, context }) => {    if (isReadDenied("comments", context)) return false;    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onCreate: ({ values, context }) => assertTenantAllowed("comments", context, values.tenantId),  onUpdate: ({ builder, set, context }) => {    observe(context, "comments:update");    if (set.tenantId !== undefined) assertTenantAllowed("comments", context, set.tenantId);    return builder("tenantId", "in", [...context.allowedTenantIds]);  },  onDelete: ({ builder, context }) => {    observe(context, "comments:delete");    return builder("tenantId", "in", [...context.allowedTenantIds]);  },}); // Policy predicate carries a bigint value; string-keyed grouping of these// predicates (e.g. JSON.stringify) throws on bigint.const quotas = table("policy_quotas", {  id: idColumn("id", "varchar(255)"),  region: column("region", "bigint"),  label: column("label", "string"),}).policy<TenantPolicyContext>({  name: "tenant.quotas",  onUpdate: ({ builder, context }) => builder("region", "=", context.allowedRegionId ?? BigInt(-1)),}); // The update policy predicate depends on the row itself, so rows with// different `shard` values compile to different predicates and split one// upsertMany call into separate predicate groups.const shards = table("policy_shards", {  id: idColumn("id", "varchar(255)"),  shard: column("shard", "string"),  authorId: column("author_id", "varchar(255)"),}).policy<TenantPolicyContext>({  name: "tenant.shards",  onUpdate: ({ builder, set }) => builder("shard", "=", set.shard ?? ""),}); // Ten columns, so a row-count-sized batch would bind ten parameters per row.const wideRows = table("policy_wide_rows", {  id: idColumn("id", "varchar(255)"),  c1: column("c1", "string"),  c2: column("c2", "string"),  c3: column("c3", "string"),  c4: column("c4", "string"),  c5: column("c5", "string"),  c6: column("c6", "string"),  c7: column("c7", "string"),  c8: column("c8", "string"),  c9: column("c9", "string"),}); // The physical column name needs identifier quoting; the bulk upsert conflict// SET clause references it as `excluded.<name>` and must quote it.const quotedNames = table("policy_quoted_names", {  id: idColumn("id", "varchar(255)"),  displayName: column("display-name", "string"),}); const v1 = schema({  version: "1.0.0",  tables: {    authors,    posts,    comments,    quotas,    shards,    wideRows,    quotedNames,  },  relations: {    authors: ({ many }) => ({      posts: many("posts"),      shards: many("shards"),    }),    shards: ({ one }) => ({      author: one("authors", ["authorId", "id"]).foreignKey(),    }),    posts: ({ one, many }) => ({      author: one("authors", ["authorId", "id"]).foreignKey(),      comments: many("comments"),    }),    comments: ({ one }) => ({      post: one("posts", ["postId", "id"]).foreignKey(),    }),  },}); const tablePolicyDB = fumadb({  namespace: "table_policy_test",  schemas: [v1],}); type TablePolicyQuery = AbstractQuery<typeof v1>; const makeContext = (  allowedTenantIds: readonly string[],  marker: string,  deniedTables: readonly string[] = [],): TenantPolicyContext => ({  allowedTenantIds: new Set(allowedTenantIds),  deniedTables: new Set(deniedTables),  marker,  observed: [],}); const makeHarness = async (options?: {  readonly nativeBatch?: boolean;  readonly maxBoundParameters?: number;}) => {  const sqlite = new Database(":memory:");  sqlite.pragma("foreign_keys = ON");  const runtimeSchema = createDrizzleRuntimeSchemaFromTables({    tables: v1.tables,    namespace: "table_policy_test",    version: "1.0.0",    provider: "sqlite",  });  const statements: { readonly sql: string; readonly paramCount: number }[] = [];  const drizzleDb = drizzle(sqlite, {    schema: runtimeSchema,    logger: {      logQuery: (query: string, params: unknown[]) => {        statements.push({ sql: query, paramCount: params.length });      },    },  });  let batchCalls = 0;   if (options?.nativeBatch) {    Object.assign(drizzleDb, {      batch: async (queries: readonly { run: () => unknown }[]) => {        batchCalls += 1;        return sqlite.transaction(() => queries.map((query) => query.run()))();      },    });  }   for (const statement of createDrizzleRuntimeSchemaSqlFromTables({    tables: v1.tables,    namespace: "table_policy_test",    version: "1.0.0",    provider: "sqlite",  })) {    sqlite.exec(statement);  }   const client = tablePolicyDB.client(    drizzleAdapter({      db: drizzleDb,      provider: "sqlite",      interactiveTransactions: options?.nativeBatch ? false : undefined,      maxBoundParameters: options?.maxBoundParameters,    }),  );   return {    orm: client.orm("1.0.0"),    getBatchCalls: () => batchCalls,    getStatements: (): readonly { readonly sql: string; readonly paramCount: number }[] =>      statements,    close: async () => {      sqlite.close();    },  };}; const useHarness = <A>(run: (orm: TablePolicyQuery) => Promise<A>) =>  Effect.acquireUseRelease(    Effect.promise(() => makeHarness()),    ({ orm }) => Effect.promise(() => run(orm)),    ({ close }) => Effect.promise(close),  ); const useStatementHarness = <A>(  run: (harness: {    readonly orm: TablePolicyQuery;    readonly getStatements: () => readonly { readonly sql: string; readonly paramCount: number }[];  }) => Promise<A>,) =>  Effect.acquireUseRelease(    Effect.promise(() => makeHarness()),    (harness) => Effect.promise(() => run(harness)),    ({ close }) => Effect.promise(close),  ); const useBudgetHarness = <A>(  maxBoundParameters: number,  run: (orm: TablePolicyQuery) => Promise<A>,) =>  Effect.acquireUseRelease(    Effect.promise(() => makeHarness({ maxBoundParameters })),    ({ orm }) => Effect.promise(() => run(orm)),    ({ close }) => Effect.promise(close),  ); const useNativeBatchHarness = <A>(  run: (harness: {    readonly orm: TablePolicyQuery;    readonly getBatchCalls: () => number;  }) => Promise<A>,) =>  Effect.acquireUseRelease(    Effect.promise(() => makeHarness({ nativeBatch: true, maxBoundParameters: 8 })),    (harness) => Effect.promise(() => run(harness)),    ({ close }) => Effect.promise(close),  ); const seedTenants = async (orm: TablePolicyQuery) => {  const seed = withQueryContext(orm, makeContext(["tenant-a", "tenant-b"], "seed"));   await seed.createMany("authors", [    {      id: "author-a",      tenantId: "tenant-a",      name: "Ada",    },    {      id: "author-b",      tenantId: "tenant-b",      name: "Bert",    },  ]);   await seed.createMany("posts", [    {      id: "post-a-1",      tenantId: "tenant-a",      authorId: "author-a",      title: "A One",    },    {      id: "post-a-2",      tenantId: "tenant-a",      authorId: "author-a",      title: "A Two",    },    {      id: "post-b-1",      tenantId: "tenant-b",      authorId: "author-b",      title: "B One",    },  ]);   await seed.createMany("comments", [    {      id: "comment-a-1",      tenantId: "tenant-a",      postId: "post-a-1",      body: "A comment",    },    {      id: "comment-b-1",      tenantId: "tenant-b",      postId: "post-b-1",      body: "B comment",    },  ]);}; describe("FumaDB table policies", () => {  it.effect(    "filters reads, joins, counts, updates, deletes, and upserts through public query APIs",    () =>      useHarness(async (orm) => {        await seedTenants(orm);        const tenantAContext = makeContext(["tenant-a"], "tenant-a");        const tenantA = withQueryContext(orm, tenantAContext);        const allTenants = withQueryContext(orm, makeContext(["tenant-a", "tenant-b"], "all"));         await expect(tenantA.count("posts")).resolves.toBe(2);         await expect(          tenantA.findMany("authors", {            orderBy: ["id", "asc"],            join: (builder) =>              builder.posts({                orderBy: ["id", "asc"],                join: (builder) => builder.comments({ orderBy: ["id", "asc"] }),              }),          }),        ).resolves.toEqual([          {            id: "author-a",            tenantId: "tenant-a",            name: "Ada",            posts: [              {                id: "post-a-1",                tenantId: "tenant-a",                authorId: "author-a",                title: "A One",                comments: [                  {                    id: "comment-a-1",                    tenantId: "tenant-a",                    postId: "post-a-1",                    body: "A comment",                  },                ],              },              {                id: "post-a-2",                tenantId: "tenant-a",                authorId: "author-a",                title: "A Two",                comments: [],              },            ],          },        ]);         await expect(          tenantA.findMany("posts", {            orderBy: ["id", "asc"],            join: (builder) =>              builder.author({                select: ["id", "tenantId"],              }),          }),        ).resolves.toEqual([          {            id: "post-a-1",            tenantId: "tenant-a",            authorId: "author-a",            title: "A One",            author: {              id: "author-a",              tenantId: "tenant-a",            },          },          {            id: "post-a-2",            tenantId: "tenant-a",            authorId: "author-a",            title: "A Two",            author: {              id: "author-a",              tenantId: "tenant-a",            },          },        ]);         await tenantA.updateMany("posts", {          set: {            title: "tenant-a-updated",          },        });        await expect(          allTenants.findMany("posts", {            select: ["id", "title"],            orderBy: ["id", "asc"],          }),        ).resolves.toEqual([          {            id: "post-a-1",            title: "tenant-a-updated",          },          {            id: "post-a-2",            title: "tenant-a-updated",          },          {            id: "post-b-1",            title: "B One",          },        ]);         await tenantA.deleteMany("comments", {});        await expect(          allTenants.findMany("comments", {            select: ["id", "tenantId"],            orderBy: ["id", "asc"],          }),        ).resolves.toEqual([          {            id: "comment-b-1",            tenantId: "tenant-b",          },        ]);         await tenantA.upsert("posts", {          where: (builder) => builder("id", "=", "post-a-2"),          update: {            title: "tenant-a-upserted",          },          create: {            id: "post-a-created-if-missing",            tenantId: "tenant-a",            authorId: "author-a",            title: "not used",          },        });        await tenantA.upsert("posts", {          where: (builder) => builder("id", "=", "post-a-3"),          update: {            title: "not used",          },          create: {            id: "post-a-3",            tenantId: "tenant-a",            authorId: "author-a",            title: "A Three",          },        });        await tenantA.upsertMany("posts", {          target: ["id"],

Lines 1–500 of 1131.