@zanzojs/drizzle 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,91 +1,116 @@
1
1
  # @zanzojs/drizzle
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@zanzojs/drizzle.svg?style=flat-square)](https://www.npmjs.com/package/@zanzojs/drizzle)
4
- [![Drizzle ORM](https://img.shields.io/badge/Drizzle-ORM-green.svg?style=flat-square)](https://orm.drizzle.team/)
4
+ [![Drizzle ORM](https://img.shields.io/badge/Drizzle-ORM-green.svg?style=flat-square)](https://orm.drizzle.team)
5
5
 
6
- The official Drizzle ORM adapter for ZanzoJS.
6
+ The official Drizzle ORM adapter for ZanzoJS.
7
7
 
8
- Translating complex relationship hierarchies into SQL `JOIN`s is historically messy and slow. This adapter implements the "Zanzibar Tuple Pattern", which translates your Zanzo authorization rules into safe, parameterized `EXISTS` subqueries targeting a single, universal table.
8
+ ## When to use this package
9
9
 
10
- ## Installation
10
+ `@zanzojs/drizzle` serves two distinct purposes:
11
11
 
12
- This package requires `@zanzojs/core` and `drizzle-orm` as peer dependencies.
12
+ **1. Write-time tuple materialization** (`materializeDerivedTuples` / `removeDerivedTuples`)
13
+ When you grant or revoke access via nested permission paths (e.g. `folder.admin`), you must pre-materialize the derived tuples in the database. This is what makes read-time evaluation fast.
13
14
 
14
- ```bash
15
- pnpm add @zanzojs/core @zanzojs/drizzle drizzle-orm
15
+ **2. SQL-filtered queries for large datasets**
16
+ When you need to fetch a filtered list of resources (e.g. "all documents this user can read") and the dataset is too large to load entirely into memory, the adapter generates parameterized `EXISTS` subqueries that push the permission filter directly to the database.
17
+
18
+ > [!TIP]
19
+ > **Performance Optimized**: As of v0.3.0, the adapter automatically groups multiple permission paths into a single `EXISTS` subquery using an `IN` clause, providing significant performance gains for complex schemas.
20
+
21
+ ```typescript
22
+ // Without adapter — loads everything into memory and filters (inefficient for large datasets)
23
+ const allDocs = await db.select().from(documents);
24
+ const myDocs = allDocs.filter(d => snapshot['Document:' + d.id]?.includes('read'));
25
+
26
+ // With adapter — filter goes directly to SQL (efficient at any scale)
27
+ const myDocs = await db.select().from(documents)
28
+ .where(withPermissions('User:alice', 'read', 'Document', documents.id));
16
29
  ```
17
30
 
18
- ## Setup Guide
31
+ > **Note:** For the frontend snapshot flow, you don't need this adapter. The snapshot is generated by loading the user's tuples with `engine.load()` and calling `createZanzoSnapshot()`. The adapter is for backend queries that need SQL-level filtering.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pnpm add @zanzojs/core@latest @zanzojs/drizzle@latest drizzle-orm
37
+ ```
19
38
 
20
- ### 1. The Universal Tuple Table
39
+ ## Setup
21
40
 
22
- Instead of spreading permission foreign keys across all your tables, you create a single table to hold all application relationships. This table structure is mandatory for the adapter to work.
41
+ ### 1. Create the Universal Tuple Table
23
42
 
43
+ All relationships live in a single table. This is the Zanzibar pattern.
24
44
  ```typescript
25
- import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
45
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
26
46
 
27
47
  export const zanzoTuples = sqliteTable('zanzo_tuples', {
28
- object: text('object').notNull(), // e.g., "Project:123"
29
- relation: text('relation').notNull(), // e.g., "owner"
30
- subject: text('subject').notNull(), // e.g., "User:999"
48
+ id: integer('id').primaryKey({ autoIncrement: true }),
49
+ object: text('object').notNull(), // e.g. "Document:doc1"
50
+ relation: text('relation').notNull(), // e.g. "owner"
51
+ subject: text('subject').notNull(), // e.g. "User:alice"
31
52
  });
32
53
  ```
33
54
 
34
- ### 2. Creating the Adapter
35
-
36
- You initialize the adapter by feeding it your core `ZanzoEngine` instance and your Drizzle tuple table reference.
37
-
55
+ ### 2. Create the adapter
38
56
  ```typescript
39
57
  import { createZanzoAdapter } from '@zanzojs/drizzle';
40
- import { engine } from './zanzo.config';
58
+ import { engine } from './zanzo.config';
41
59
  import { zanzoTuples } from './schema';
42
60
 
43
61
  export const withPermissions = createZanzoAdapter(engine, zanzoTuples);
44
62
  ```
45
63
 
46
- ### 3. Query Pushdown (Read Operations)
64
+ ### 3. Query pushdown
65
+ ```typescript
66
+ async function getReadableDocuments(userId: string) {
67
+ return await db.select()
68
+ .from(documents)
69
+ .where(withPermissions(`User:${userId}`, 'read', 'Document', documents.id));
70
+ }
71
+ ```
72
+
73
+ ## Configuration Options
47
74
 
48
- Now, whenever you want to fetch a list of entities (like `Projects`) but only return the ones the user is allowed to read, you use the adapter to generate the `WHERE` clause dynamically.
75
+ `createZanzoAdapter` accepts an optional `options` object:
49
76
 
50
77
  ```typescript
51
- import { db, projects } from './db';
52
-
53
- async function getReadableProjects(userId: string) {
54
- // Generate the AST SQL fragment
55
- const accessFilter = withPermissions(
56
- `User:${userId}`,
57
- 'read',
58
- 'Project',
59
- projects.id // The column to match against the tuple's object ID
60
- );
61
-
62
- // Apply it to your standard Drizzle query
63
- return await db.select().from(projects).where(accessFilter);
64
- }
78
+ export const withPermissions = createZanzoAdapter(engine, zanzoTuples, {
79
+ dialect: 'postgres', // 'postgres' (default), 'mysql', or 'sqlite'
80
+ debug: false, // Set to true to log generated SQL and AST
81
+ warnOnNestedConditions: true // Warns if materialized tuples are missing
82
+ });
65
83
  ```
66
84
 
67
- ### Write Operations (Important!)
85
+ ### 1. SQL Injection Prevention
86
+ As of v0.3.0, the adapter avoids `sql.raw` for all resource identifiers. All inputs are handled via Drizzle's secure parameter binding.
87
+
88
+ ### 2. Dialect Support
89
+ The adapter is dialect-aware. If you use SQLite, specify `{ dialect: 'sqlite' }` to use the `||` operator for string concatenation instead of `CONCAT`.
68
90
 
69
- The SQL adapter prioritizes extreme read performance. As a trade-off, it relies on strict string matching for nested definitions (e.g. `workspace.org.admin`).
91
+ ### 3. AST Caching
92
+ The adapter includes internal caching of structural permission trees (ASTs), minimizing CPU overhead for repeated queries on the same resource types.
70
93
 
71
- To make this work, **you must use `@zanzojs/core`'s `expandTuples()` function when writing to the database.** If you skip `expandTuples()` during mutations, deep permission paths will not resolve correctly during Drizzle queries.
94
+ ## Write Operations
72
95
 
96
+ ### Granting access with materializeDerivedTuples
97
+
98
+ When assigning a role that involves nested permission paths, use `materializeDerivedTuples` to materialize all derived tuples atomically.
73
99
  ```typescript
74
- import { expandTuples } from '@zanzojs/core';
100
+ import { materializeDerivedTuples } from '@zanzojs/core';
75
101
 
76
- async function grantAccess(userId: string, projectId: string) {
102
+ async function grantAccess(userId: string, relation: string, objectId: string) {
77
103
  const baseTuple = {
78
104
  subject: `User:${userId}`,
79
- relation: 'owner',
80
- object: `Project:${projectId}`,
105
+ relation,
106
+ object: objectId,
81
107
  };
82
108
 
83
- const derived = await expandTuples({
109
+ const derived = await materializeDerivedTuples({
84
110
  schema: engine.getSchema(),
85
111
  newTuple: baseTuple,
86
112
  fetchChildren: async (parentObject, relation) => {
87
- // Return child object IDs linked to parentObject via relation
88
- const rows = await db.select({ subject: zanzoTuples.subject })
113
+ const rows = await db.select({ object: zanzoTuples.object })
89
114
  .from(zanzoTuples)
90
115
  .where(and(
91
116
  eq(zanzoTuples.subject, parentObject),
@@ -95,11 +120,48 @@ async function grantAccess(userId: string, projectId: string) {
95
120
  },
96
121
  });
97
122
 
98
- // Insert base tuple + all derived tuples atomically
99
123
  await db.insert(zanzoTuples).values([baseTuple, ...derived]);
100
124
  }
101
125
  ```
102
126
 
103
- ## Documentation
127
+ ### Revoking access with removeDerivedTuples
104
128
 
105
- For full architecture details, refer to the [ZanzoJS Monorepo](https://github.com/GonzaloJeria/zanzo).
129
+ `removeDerivedTuples` is the symmetric inverse of `materializeDerivedTuples`. It identifies all derived tuples to delete.
130
+ ```typescript
131
+ import { removeDerivedTuples } from '@zanzojs/core';
132
+
133
+ async function revokeAccess(userId: string, relation: string, objectId: string) {
134
+ const baseTuple = {
135
+ subject: `User:${userId}`,
136
+ relation,
137
+ object: objectId,
138
+ };
139
+
140
+ const derived = await removeDerivedTuples({
141
+ schema: engine.getSchema(),
142
+ revokedTuple: baseTuple,
143
+ fetchChildren: async (parentObject, relation) => {
144
+ const rows = await db.select({ object: zanzoTuples.object })
145
+ .from(zanzoTuples)
146
+ .where(and(
147
+ eq(zanzoTuples.subject, parentObject),
148
+ eq(zanzoTuples.relation, relation),
149
+ ));
150
+ return rows.map(r => r.object);
151
+ },
152
+ });
153
+
154
+ for (const tuple of [baseTuple, ...derived]) {
155
+ await db.delete(zanzoTuples).where(and(
156
+ eq(zanzoTuples.subject, tuple.subject),
157
+ eq(zanzoTuples.relation, tuple.relation),
158
+ eq(zanzoTuples.object, tuple.object),
159
+ ));
160
+ }
161
+ }
162
+ ```
163
+
164
+ > **materializeDerivedTuples and removeDerivedTuples are symmetric.** If `materializeDerivedTuples` derived a tuple, `removeDerivedTuples` will identify it for deletion. This guarantees no orphaned tuples.
165
+
166
+ ## Documentation
167
+ For full architecture details, see the [ZanzoJS Monorepo](https://github.com/GonzaloJeria/zanzo).
package/dist/index.cjs CHANGED
@@ -28,35 +28,63 @@ var import_core = require("@zanzojs/core");
28
28
  function createZanzoAdapter(engine, tupleTable, options) {
29
29
  const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
30
30
  const shouldWarn = options?.warnOnNestedConditions ?? isDev;
31
+ const isDebug = options?.debug ?? false;
32
+ const dialect = options?.dialect ?? "postgres";
33
+ const astCache = /* @__PURE__ */ new Map();
31
34
  return function withPermissions(actor, action, resourceType, resourceIdColumn) {
32
- const ast = engine.buildDatabaseQuery(actor, action, resourceType);
35
+ const cacheKey = `${action}:${resourceType}`;
36
+ let ast = astCache.get(cacheKey);
37
+ if (ast === void 0) {
38
+ ast = engine.buildDatabaseQuery(actor, action, resourceType);
39
+ astCache.set(cacheKey, ast);
40
+ } else if (ast) {
41
+ }
42
+ if (isDebug) {
43
+ console.debug(`[Zanzo Debug] Action: ${action}, Resource: ${resourceType}`);
44
+ console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));
45
+ }
33
46
  if (ast && ast.conditions.length > 100) {
34
- throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);
47
+ throw new import_core.ZanzoError(import_core.ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);
35
48
  }
36
- if (!ast) {
49
+ if (!ast || ast.conditions.length === 0) {
37
50
  return import_drizzle_orm.sql`1 = 0`;
38
51
  }
39
- const parseCondition = (cond) => {
40
- const objectString = import_drizzle_orm.sql`${resourceType} || '${import_drizzle_orm.sql.raw(import_core.ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;
52
+ const objectString = dialect === "sqlite" ? import_drizzle_orm.sql`${resourceType} || ${import_core.ENTITY_REF_SEPARATOR} || ${resourceIdColumn}` : import_drizzle_orm.sql`CONCAT(${resourceType}, ${import_core.ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;
53
+ const relationsBySubject = /* @__PURE__ */ new Map();
54
+ for (const cond of ast.conditions) {
55
+ const fullRelation = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR) : cond.relation;
41
56
  if (cond.type === "nested" && shouldWarn) {
42
- console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
57
+ console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
43
58
  }
44
- const relationString = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR) : cond.relation;
45
- return import_drizzle_orm.sql`EXISTS (
46
- SELECT 1 FROM ${tupleTable}
47
- WHERE ${tupleTable.object} = ${objectString}
48
- AND ${tupleTable.relation} = ${relationString}
49
- AND ${tupleTable.subject} = ${cond.targetSubject}
50
- )`;
51
- };
52
- const parsedConditions = ast.conditions.map(parseCondition).filter((c) => c !== void 0);
53
- if (parsedConditions.length === 0) {
54
- return import_drizzle_orm.sql`1 = 0`;
59
+ const targetSubject = cond.targetSubject === actor ? actor : cond.targetSubject;
60
+ let relations = relationsBySubject.get(targetSubject);
61
+ if (!relations) {
62
+ relations = /* @__PURE__ */ new Set();
63
+ relationsBySubject.set(targetSubject, relations);
64
+ }
65
+ relations.add(fullRelation);
66
+ }
67
+ const sqlConditions = [];
68
+ for (const [subject, relations] of relationsBySubject.entries()) {
69
+ const relationArray = Array.from(relations);
70
+ const condition = relationArray.length === 1 ? import_drizzle_orm.sql`EXISTS (
71
+ SELECT 1 FROM ${tupleTable}
72
+ WHERE ${tupleTable.object} = ${objectString}
73
+ AND ${tupleTable.relation} = ${relationArray[0]}
74
+ AND ${tupleTable.subject} = ${subject}
75
+ )` : import_drizzle_orm.sql`EXISTS (
76
+ SELECT 1 FROM ${tupleTable}
77
+ WHERE ${tupleTable.object} = ${objectString}
78
+ AND ${tupleTable.relation} IN (${import_drizzle_orm.sql.join(relationArray.map((r) => import_drizzle_orm.sql`${r}`), import_drizzle_orm.sql`, `)})
79
+ AND ${tupleTable.subject} = ${subject}
80
+ )`;
81
+ sqlConditions.push(condition);
55
82
  }
56
- if (ast.operator === "AND") {
57
- return (0, import_drizzle_orm.and)(...parsedConditions);
83
+ const finalFilter = ast.operator === "AND" ? (0, import_drizzle_orm.and)(...sqlConditions) : (0, import_drizzle_orm.or)(...sqlConditions);
84
+ if (isDebug) {
85
+ console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);
58
86
  }
59
- return (0, import_drizzle_orm.or)(...parsedConditions);
87
+ return finalFilter;
60
88
  };
61
89
  }
62
90
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use expandTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @param actor The Subject identifier validating access (e.g \"User:1\")\n * @param action The protected action (e.g \"read\")\n * @param resourceType The target Domain scope (e.g \"Invoice\")\n * @param resourceIdColumn The specific Drizzle column representing the object's ID in the business table (e.g `invoices.id`)\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Evaluate the underlying pure logical AST\n const ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion:\n // If a badly designed ReBAC schema generates a monstrous combinatorial AST tree, \n // it could exceed max SQL text limits resulting in DB crashes. Abort safely.\n if (ast && ast.conditions.length > 100) {\n throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);\n }\n\n if (!ast) {\n // Access totally denied\n return sql`1 = 0`; \n }\n\n const parseCondition = (cond: Condition): SQL<unknown> | undefined => {\n \n // In the Zanzibar Pattern, ALL conditions (direct or nested) ultimately result\n // in looking up pre-computed or dynamically queried tuples.\n // E.g for a direct target: SELECT 1 FROM tuples WHERE object = TYPE:ID AND relation = X AND subject = TARGET\n \n const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;\n\n // In Zanzibar, nested conditions (e.g. org.admin) are evaluated using the \"Tuple Expansion\" pattern.\n // This means the user has asynchronously written materialized tuples into the database.\n // Therefore, both direct and nested queries are resolved identically via O(1) EXISTS lookups.\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n const relationString = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n return sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationString} \n AND ${tupleTable.subject} = ${cond.targetSubject}\n )`;\n };\n\n const parsedConditions = ast.conditions\n .map(parseCondition)\n .filter((c): c is SQL<unknown> => c !== undefined);\n\n if (parsedConditions.length === 0) {\n return sql`1 = 0`;\n }\n\n if (ast.operator === 'AND') {\n return and(...parsedConditions) as SQL<unknown>;\n }\n\n return or(...parsedConditions) as SQL<unknown>;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA6C;AAE7C,kBAA8D;AA+BvD,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AAUtD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,MAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AAK/E,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,MAAM,oMAAoM;AAAA,IACtN;AAEA,QAAI,CAAC,KAAK;AAER,aAAO;AAAA,IACT;AAEA,UAAM,iBAAiB,CAAC,SAA8C;AAMpE,YAAM,eAAe,yBAAM,YAAY,QAAQ,uBAAI,IAAI,gCAAoB,CAAC,QAAQ,gBAAgB;AAKpG,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,CAAC,0LAA0L;AAAA,MAC7T;AAEA,YAAM,iBAAiB,KAAK,SAAS,WACjC,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,IACtE,KAAK;AAET,aAAO;AAAA,wBACW,UAAU;AAAA,gBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,gBACnC,WAAW,QAAQ,MAAM,cAAc;AAAA,gBACvC,WAAW,OAAO,MAAM,KAAK,aAAa;AAAA;AAAA,IAEtD;AAEA,UAAM,mBAAmB,IAAI,WAC1B,IAAI,cAAc,EAClB,OAAO,CAAC,MAAyB,MAAM,MAAS;AAEnD,QAAI,iBAAiB,WAAW,GAAG;AAChC,aAAO;AAAA,IACV;AAEA,QAAI,IAAI,aAAa,OAAO;AAC1B,iBAAO,wBAAI,GAAG,gBAAgB;AAAA,IAChC;AAEA,eAAO,uBAAG,GAAG,gBAAgB;AAAA,EAC/B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use materializeDerivedTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n\n /**\n * If true, logs the generated AST and SQL conditions to the console for debugging purposes.\n * @default false\n */\n debug?: boolean;\n\n /**\n * The database dialect. Used to optimize string concatenation.\n * If not provided, it defaults to 'postgres' which uses standard CONCAT.\n * @default 'postgres'\n */\n dialect?: 'mysql' | 'postgres' | 'sqlite';\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @remarks\n * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been \n * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input \n * directly without routing through the engine first may bypass validation and produce \n * unexpected query behavior.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n const isDebug = options?.debug ?? false;\n const dialect = options?.dialect ?? 'postgres';\n\n // Performance Optimization: Cache structural ASTs per action+resourceType\n // The actor is NOT part of the key as it varies per request, but the logical \n // permission tree (AST) for a given action on a resource type is static.\n const astCache = new Map<string, QueryAST | null>();\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @remarks\n * This function assumes all identifiers have been validated by the ZanzoEngine.\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Check Cache first\n const cacheKey = `${action as string}:${resourceType as string}`;\n let ast = astCache.get(cacheKey);\n\n if (ast === undefined) {\n // Evaluate the underlying pure logical AST\n // We use a dummy actor for the initial build to get the structural conditions,\n // then we'll replace the targetSubject with the real actor if needed.\n // Actually, buildDatabaseQuery uses the actor in the conditions.\n ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n astCache.set(cacheKey, ast);\n } else if (ast) {\n // If we have a cached AST, we must update the targetSubject for the current actor\n // Since we are rebuilding the SQL conditions anyway, we can just use the actor \n // from the function arguments.\n }\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Action: ${action as string}, Resource: ${resourceType as string}`);\n console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));\n }\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion\n if (ast && ast.conditions.length > 100) {\n throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);\n }\n\n if (!ast || ast.conditions.length === 0) {\n return sql`1 = 0`; \n }\n\n // Dialect-agnostic/Secure concatenation for the object identifier: \"ResourceType:ID\"\n // We avoid sql.raw to prevent injection.\n const objectString = dialect === 'sqlite'\n ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}`\n : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;\n\n // OPTIMIZATION: In Zanzibar, most permissions share the same subject and object target.\n // We group all conditions that share the same targetSubject into a single EXISTS subquery using IN.\n const relationsBySubject = new Map<string, Set<string>>();\n\n for (const cond of ast.conditions) {\n // Logic for building the full relation name (e.g. \"workspace.viewer\")\n const fullRelation = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n // Use the actual actor from arguments to ensure correctness even with cached AST\n const targetSubject = cond.targetSubject === actor ? actor : cond.targetSubject;\n\n let relations = relationsBySubject.get(targetSubject);\n if (!relations) {\n relations = new Set();\n relationsBySubject.set(targetSubject, relations);\n }\n relations.add(fullRelation);\n }\n\n const sqlConditions: SQL<unknown>[] = [];\n\n for (const [subject, relations] of relationsBySubject.entries()) {\n const relationArray = Array.from(relations);\n \n const condition = relationArray.length === 1\n ? sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationArray[0]} \n AND ${tupleTable.subject} = ${subject}\n )`\n : sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} IN (${sql.join(relationArray.map(r => sql`${r}`), sql`, `)}) \n AND ${tupleTable.subject} = ${subject}\n )`;\n \n sqlConditions.push(condition);\n }\n\n const finalFilter = (ast.operator === 'AND' ? and(...sqlConditions) : or(...sqlConditions)) as SQL<unknown>;\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);\n }\n\n return finalFilter;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA6C;AAE7C,kBAA0F;AAkDnF,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AACtD,QAAM,UAAU,SAAS,SAAS;AAClC,QAAM,UAAU,SAAS,WAAW;AAKpC,QAAM,WAAW,oBAAI,IAA6B;AAQlD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,WAAW,GAAG,MAAgB,IAAI,YAAsB;AAC9D,QAAI,MAAM,SAAS,IAAI,QAAQ;AAE/B,QAAI,QAAQ,QAAW;AAKrB,YAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AACzE,eAAS,IAAI,UAAU,GAAG;AAAA,IAC5B,WAAW,KAAK;AAAA,IAIhB;AAEA,QAAI,SAAS;AACX,cAAQ,MAAM,yBAAyB,MAAgB,eAAe,YAAsB,EAAE;AAC9F,cAAQ,MAAM,gCAAgC,KAAK,UAAU,KAAK,MAAM,CAAC,CAAC;AAAA,IAC5E;AAGA,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,uBAAW,2BAAe,cAAc,2GAA2G;AAAA,IAC/J;AAEA,QAAI,CAAC,OAAO,IAAI,WAAW,WAAW,GAAG;AACvC,aAAO;AAAA,IACT;AAIA,UAAM,eAAe,YAAY,WAC7B,yBAAM,YAAY,OAAO,gCAAoB,OAAO,gBAAgB,KACpE,gCAAa,YAAY,KAAK,gCAAoB,KAAK,gBAAgB;AAI3E,UAAM,qBAAqB,oBAAI,IAAyB;AAExD,eAAW,QAAQ,IAAI,YAAY;AAEjC,YAAM,eAAe,KAAK,SAAS,WAC/B,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,IACtE,KAAK;AAET,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,YAAY,sMAAsM;AAAA,MAC9Q;AAGA,YAAM,gBAAgB,KAAK,kBAAkB,QAAQ,QAAQ,KAAK;AAElE,UAAI,YAAY,mBAAmB,IAAI,aAAa;AACpD,UAAI,CAAC,WAAW;AACd,oBAAY,oBAAI,IAAI;AACpB,2BAAmB,IAAI,eAAe,SAAS;AAAA,MACjD;AACA,gBAAU,IAAI,YAAY;AAAA,IAC5B;AAEA,UAAM,gBAAgC,CAAC;AAEvC,eAAW,CAAC,SAAS,SAAS,KAAK,mBAAmB,QAAQ,GAAG;AAC/D,YAAM,gBAAgB,MAAM,KAAK,SAAS;AAE1C,YAAM,YAAY,cAAc,WAAW,IACvC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,MAAM,cAAc,CAAC,CAAC;AAAA,oBACzC,WAAW,OAAO,MAAM,OAAO;AAAA,eAEzC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,QAAQ,uBAAI,KAAK,cAAc,IAAI,OAAK,yBAAM,CAAC,EAAE,GAAG,0BAAO,CAAC;AAAA,oBAC/E,WAAW,OAAO,MAAM,OAAO;AAAA;AAG7C,oBAAc,KAAK,SAAS;AAAA,IAC9B;AAEA,UAAM,cAAe,IAAI,aAAa,YAAQ,wBAAI,GAAG,aAAa,QAAI,uBAAG,GAAG,aAAa;AAEzF,QAAI,SAAS;AACX,cAAQ,MAAM,2CAA2C;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AACF;","names":[]}
package/dist/index.d.cts CHANGED
@@ -13,16 +13,33 @@ interface ZanzoTupleTable {
13
13
  interface ZanzoAdapterOptions {
14
14
  /**
15
15
  * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,
16
- * reminding you to use expandTuples() when writing this relationship.
16
+ * reminding you to use materializeDerivedTuples() when writing this relationship.
17
17
  *
18
18
  * @default true in NODE_ENV=development, false in production
19
19
  */
20
20
  warnOnNestedConditions?: boolean;
21
+ /**
22
+ * If true, logs the generated AST and SQL conditions to the console for debugging purposes.
23
+ * @default false
24
+ */
25
+ debug?: boolean;
26
+ /**
27
+ * The database dialect. Used to optimize string concatenation.
28
+ * If not provided, it defaults to 'postgres' which uses standard CONCAT.
29
+ * @default 'postgres'
30
+ */
31
+ dialect?: 'mysql' | 'postgres' | 'sqlite';
21
32
  }
22
33
  /**
23
34
  * Creates a "Zero-Config" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.
24
35
  * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.
25
36
  *
37
+ * @remarks
38
+ * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been
39
+ * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input
40
+ * directly without routing through the engine first may bypass validation and produce
41
+ * unexpected query behavior.
42
+ *
26
43
  * @param engine The initialized ZanzoEngine instance
27
44
  * @param tupleTable The central Drizzle Table where all Relation Tuples are stored
28
45
  * @param options Optional configuration for the adapter
package/dist/index.d.ts CHANGED
@@ -13,16 +13,33 @@ interface ZanzoTupleTable {
13
13
  interface ZanzoAdapterOptions {
14
14
  /**
15
15
  * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,
16
- * reminding you to use expandTuples() when writing this relationship.
16
+ * reminding you to use materializeDerivedTuples() when writing this relationship.
17
17
  *
18
18
  * @default true in NODE_ENV=development, false in production
19
19
  */
20
20
  warnOnNestedConditions?: boolean;
21
+ /**
22
+ * If true, logs the generated AST and SQL conditions to the console for debugging purposes.
23
+ * @default false
24
+ */
25
+ debug?: boolean;
26
+ /**
27
+ * The database dialect. Used to optimize string concatenation.
28
+ * If not provided, it defaults to 'postgres' which uses standard CONCAT.
29
+ * @default 'postgres'
30
+ */
31
+ dialect?: 'mysql' | 'postgres' | 'sqlite';
21
32
  }
22
33
  /**
23
34
  * Creates a "Zero-Config" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.
24
35
  * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.
25
36
  *
37
+ * @remarks
38
+ * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been
39
+ * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input
40
+ * directly without routing through the engine first may bypass validation and produce
41
+ * unexpected query behavior.
42
+ *
26
43
  * @param engine The initialized ZanzoEngine instance
27
44
  * @param tupleTable The central Drizzle Table where all Relation Tuples are stored
28
45
  * @param options Optional configuration for the adapter
package/dist/index.js CHANGED
@@ -1,38 +1,66 @@
1
1
  // src/index.ts
2
2
  import { or, and, sql } from "drizzle-orm";
3
- import { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from "@zanzojs/core";
3
+ import { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from "@zanzojs/core";
4
4
  function createZanzoAdapter(engine, tupleTable, options) {
5
5
  const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
6
6
  const shouldWarn = options?.warnOnNestedConditions ?? isDev;
7
+ const isDebug = options?.debug ?? false;
8
+ const dialect = options?.dialect ?? "postgres";
9
+ const astCache = /* @__PURE__ */ new Map();
7
10
  return function withPermissions(actor, action, resourceType, resourceIdColumn) {
8
- const ast = engine.buildDatabaseQuery(actor, action, resourceType);
11
+ const cacheKey = `${action}:${resourceType}`;
12
+ let ast = astCache.get(cacheKey);
13
+ if (ast === void 0) {
14
+ ast = engine.buildDatabaseQuery(actor, action, resourceType);
15
+ astCache.set(cacheKey, ast);
16
+ } else if (ast) {
17
+ }
18
+ if (isDebug) {
19
+ console.debug(`[Zanzo Debug] Action: ${action}, Resource: ${resourceType}`);
20
+ console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));
21
+ }
9
22
  if (ast && ast.conditions.length > 100) {
10
- throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);
23
+ throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);
11
24
  }
12
- if (!ast) {
25
+ if (!ast || ast.conditions.length === 0) {
13
26
  return sql`1 = 0`;
14
27
  }
15
- const parseCondition = (cond) => {
16
- const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;
28
+ const objectString = dialect === "sqlite" ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}` : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;
29
+ const relationsBySubject = /* @__PURE__ */ new Map();
30
+ for (const cond of ast.conditions) {
31
+ const fullRelation = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) : cond.relation;
17
32
  if (cond.type === "nested" && shouldWarn) {
18
- console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
33
+ console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
19
34
  }
20
- const relationString = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) : cond.relation;
21
- return sql`EXISTS (
22
- SELECT 1 FROM ${tupleTable}
23
- WHERE ${tupleTable.object} = ${objectString}
24
- AND ${tupleTable.relation} = ${relationString}
25
- AND ${tupleTable.subject} = ${cond.targetSubject}
26
- )`;
27
- };
28
- const parsedConditions = ast.conditions.map(parseCondition).filter((c) => c !== void 0);
29
- if (parsedConditions.length === 0) {
30
- return sql`1 = 0`;
35
+ const targetSubject = cond.targetSubject === actor ? actor : cond.targetSubject;
36
+ let relations = relationsBySubject.get(targetSubject);
37
+ if (!relations) {
38
+ relations = /* @__PURE__ */ new Set();
39
+ relationsBySubject.set(targetSubject, relations);
40
+ }
41
+ relations.add(fullRelation);
42
+ }
43
+ const sqlConditions = [];
44
+ for (const [subject, relations] of relationsBySubject.entries()) {
45
+ const relationArray = Array.from(relations);
46
+ const condition = relationArray.length === 1 ? sql`EXISTS (
47
+ SELECT 1 FROM ${tupleTable}
48
+ WHERE ${tupleTable.object} = ${objectString}
49
+ AND ${tupleTable.relation} = ${relationArray[0]}
50
+ AND ${tupleTable.subject} = ${subject}
51
+ )` : sql`EXISTS (
52
+ SELECT 1 FROM ${tupleTable}
53
+ WHERE ${tupleTable.object} = ${objectString}
54
+ AND ${tupleTable.relation} IN (${sql.join(relationArray.map((r) => sql`${r}`), sql`, `)})
55
+ AND ${tupleTable.subject} = ${subject}
56
+ )`;
57
+ sqlConditions.push(condition);
31
58
  }
32
- if (ast.operator === "AND") {
33
- return and(...parsedConditions);
59
+ const finalFilter = ast.operator === "AND" ? and(...sqlConditions) : or(...sqlConditions);
60
+ if (isDebug) {
61
+ console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);
34
62
  }
35
- return or(...parsedConditions);
63
+ return finalFilter;
36
64
  };
37
65
  }
38
66
  export {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use expandTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @param actor The Subject identifier validating access (e.g \"User:1\")\n * @param action The protected action (e.g \"read\")\n * @param resourceType The target Domain scope (e.g \"Invoice\")\n * @param resourceIdColumn The specific Drizzle column representing the object's ID in the business table (e.g `invoices.id`)\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Evaluate the underlying pure logical AST\n const ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion:\n // If a badly designed ReBAC schema generates a monstrous combinatorial AST tree, \n // it could exceed max SQL text limits resulting in DB crashes. Abort safely.\n if (ast && ast.conditions.length > 100) {\n throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);\n }\n\n if (!ast) {\n // Access totally denied\n return sql`1 = 0`; \n }\n\n const parseCondition = (cond: Condition): SQL<unknown> | undefined => {\n \n // In the Zanzibar Pattern, ALL conditions (direct or nested) ultimately result\n // in looking up pre-computed or dynamically queried tuples.\n // E.g for a direct target: SELECT 1 FROM tuples WHERE object = TYPE:ID AND relation = X AND subject = TARGET\n \n const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;\n\n // In Zanzibar, nested conditions (e.g. org.admin) are evaluated using the \"Tuple Expansion\" pattern.\n // This means the user has asynchronously written materialized tuples into the database.\n // Therefore, both direct and nested queries are resolved identically via O(1) EXISTS lookups.\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n const relationString = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n return sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationString} \n AND ${tupleTable.subject} = ${cond.targetSubject}\n )`;\n };\n\n const parsedConditions = ast.conditions\n .map(parseCondition)\n .filter((c): c is SQL<unknown> => c !== undefined);\n\n if (parsedConditions.length === 0) {\n return sql`1 = 0`;\n }\n\n if (ast.operator === 'AND') {\n return and(...parsedConditions) as SQL<unknown>;\n }\n\n return or(...parsedConditions) as SQL<unknown>;\n };\n}\n"],"mappings":";AAAA,SAAS,IAAI,KAAU,WAAsB;AAE7C,SAAS,sBAAsB,+BAA+B;AA+BvD,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AAUtD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,MAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AAK/E,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,MAAM,oMAAoM;AAAA,IACtN;AAEA,QAAI,CAAC,KAAK;AAER,aAAO;AAAA,IACT;AAEA,UAAM,iBAAiB,CAAC,SAA8C;AAMpE,YAAM,eAAe,MAAM,YAAY,QAAQ,IAAI,IAAI,oBAAoB,CAAC,QAAQ,gBAAgB;AAKpG,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,CAAC,0LAA0L;AAAA,MAC7T;AAEA,YAAM,iBAAiB,KAAK,SAAS,WACjC,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,IACtE,KAAK;AAET,aAAO;AAAA,wBACW,UAAU;AAAA,gBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,gBACnC,WAAW,QAAQ,MAAM,cAAc;AAAA,gBACvC,WAAW,OAAO,MAAM,KAAK,aAAa;AAAA;AAAA,IAEtD;AAEA,UAAM,mBAAmB,IAAI,WAC1B,IAAI,cAAc,EAClB,OAAO,CAAC,MAAyB,MAAM,MAAS;AAEnD,QAAI,iBAAiB,WAAW,GAAG;AAChC,aAAO;AAAA,IACV;AAEA,QAAI,IAAI,aAAa,OAAO;AAC1B,aAAO,IAAI,GAAG,gBAAgB;AAAA,IAChC;AAEA,WAAO,GAAG,GAAG,gBAAgB;AAAA,EAC/B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use materializeDerivedTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n\n /**\n * If true, logs the generated AST and SQL conditions to the console for debugging purposes.\n * @default false\n */\n debug?: boolean;\n\n /**\n * The database dialect. Used to optimize string concatenation.\n * If not provided, it defaults to 'postgres' which uses standard CONCAT.\n * @default 'postgres'\n */\n dialect?: 'mysql' | 'postgres' | 'sqlite';\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @remarks\n * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been \n * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input \n * directly without routing through the engine first may bypass validation and produce \n * unexpected query behavior.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n const isDebug = options?.debug ?? false;\n const dialect = options?.dialect ?? 'postgres';\n\n // Performance Optimization: Cache structural ASTs per action+resourceType\n // The actor is NOT part of the key as it varies per request, but the logical \n // permission tree (AST) for a given action on a resource type is static.\n const astCache = new Map<string, QueryAST | null>();\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @remarks\n * This function assumes all identifiers have been validated by the ZanzoEngine.\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Check Cache first\n const cacheKey = `${action as string}:${resourceType as string}`;\n let ast = astCache.get(cacheKey);\n\n if (ast === undefined) {\n // Evaluate the underlying pure logical AST\n // We use a dummy actor for the initial build to get the structural conditions,\n // then we'll replace the targetSubject with the real actor if needed.\n // Actually, buildDatabaseQuery uses the actor in the conditions.\n ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n astCache.set(cacheKey, ast);\n } else if (ast) {\n // If we have a cached AST, we must update the targetSubject for the current actor\n // Since we are rebuilding the SQL conditions anyway, we can just use the actor \n // from the function arguments.\n }\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Action: ${action as string}, Resource: ${resourceType as string}`);\n console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));\n }\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion\n if (ast && ast.conditions.length > 100) {\n throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);\n }\n\n if (!ast || ast.conditions.length === 0) {\n return sql`1 = 0`; \n }\n\n // Dialect-agnostic/Secure concatenation for the object identifier: \"ResourceType:ID\"\n // We avoid sql.raw to prevent injection.\n const objectString = dialect === 'sqlite'\n ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}`\n : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;\n\n // OPTIMIZATION: In Zanzibar, most permissions share the same subject and object target.\n // We group all conditions that share the same targetSubject into a single EXISTS subquery using IN.\n const relationsBySubject = new Map<string, Set<string>>();\n\n for (const cond of ast.conditions) {\n // Logic for building the full relation name (e.g. \"workspace.viewer\")\n const fullRelation = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n // Use the actual actor from arguments to ensure correctness even with cached AST\n const targetSubject = cond.targetSubject === actor ? actor : cond.targetSubject;\n\n let relations = relationsBySubject.get(targetSubject);\n if (!relations) {\n relations = new Set();\n relationsBySubject.set(targetSubject, relations);\n }\n relations.add(fullRelation);\n }\n\n const sqlConditions: SQL<unknown>[] = [];\n\n for (const [subject, relations] of relationsBySubject.entries()) {\n const relationArray = Array.from(relations);\n \n const condition = relationArray.length === 1\n ? sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationArray[0]} \n AND ${tupleTable.subject} = ${subject}\n )`\n : sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} IN (${sql.join(relationArray.map(r => sql`${r}`), sql`, `)}) \n AND ${tupleTable.subject} = ${subject}\n )`;\n \n sqlConditions.push(condition);\n }\n\n const finalFilter = (ast.operator === 'AND' ? and(...sqlConditions) : or(...sqlConditions)) as SQL<unknown>;\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);\n }\n\n return finalFilter;\n };\n}\n"],"mappings":";AAAA,SAAS,IAAI,KAAU,WAAsB;AAE7C,SAAS,sBAAsB,yBAAyB,YAAY,sBAAsB;AAkDnF,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AACtD,QAAM,UAAU,SAAS,SAAS;AAClC,QAAM,UAAU,SAAS,WAAW;AAKpC,QAAM,WAAW,oBAAI,IAA6B;AAQlD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,WAAW,GAAG,MAAgB,IAAI,YAAsB;AAC9D,QAAI,MAAM,SAAS,IAAI,QAAQ;AAE/B,QAAI,QAAQ,QAAW;AAKrB,YAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AACzE,eAAS,IAAI,UAAU,GAAG;AAAA,IAC5B,WAAW,KAAK;AAAA,IAIhB;AAEA,QAAI,SAAS;AACX,cAAQ,MAAM,yBAAyB,MAAgB,eAAe,YAAsB,EAAE;AAC9F,cAAQ,MAAM,gCAAgC,KAAK,UAAU,KAAK,MAAM,CAAC,CAAC;AAAA,IAC5E;AAGA,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,WAAW,eAAe,cAAc,2GAA2G;AAAA,IAC/J;AAEA,QAAI,CAAC,OAAO,IAAI,WAAW,WAAW,GAAG;AACvC,aAAO;AAAA,IACT;AAIA,UAAM,eAAe,YAAY,WAC7B,MAAM,YAAY,OAAO,oBAAoB,OAAO,gBAAgB,KACpE,aAAa,YAAY,KAAK,oBAAoB,KAAK,gBAAgB;AAI3E,UAAM,qBAAqB,oBAAI,IAAyB;AAExD,eAAW,QAAQ,IAAI,YAAY;AAEjC,YAAM,eAAe,KAAK,SAAS,WAC/B,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,IACtE,KAAK;AAET,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,YAAY,sMAAsM;AAAA,MAC9Q;AAGA,YAAM,gBAAgB,KAAK,kBAAkB,QAAQ,QAAQ,KAAK;AAElE,UAAI,YAAY,mBAAmB,IAAI,aAAa;AACpD,UAAI,CAAC,WAAW;AACd,oBAAY,oBAAI,IAAI;AACpB,2BAAmB,IAAI,eAAe,SAAS;AAAA,MACjD;AACA,gBAAU,IAAI,YAAY;AAAA,IAC5B;AAEA,UAAM,gBAAgC,CAAC;AAEvC,eAAW,CAAC,SAAS,SAAS,KAAK,mBAAmB,QAAQ,GAAG;AAC/D,YAAM,gBAAgB,MAAM,KAAK,SAAS;AAE1C,YAAM,YAAY,cAAc,WAAW,IACvC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,MAAM,cAAc,CAAC,CAAC;AAAA,oBACzC,WAAW,OAAO,MAAM,OAAO;AAAA,eAEzC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,QAAQ,IAAI,KAAK,cAAc,IAAI,OAAK,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC;AAAA,oBAC/E,WAAW,OAAO,MAAM,OAAO;AAAA;AAG7C,oBAAc,KAAK,SAAS;AAAA,IAC9B;AAEA,UAAM,cAAe,IAAI,aAAa,QAAQ,IAAI,GAAG,aAAa,IAAI,GAAG,GAAG,aAAa;AAEzF,QAAI,SAAS;AACX,cAAQ,MAAM,2CAA2C;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zanzojs/drizzle",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Drizzle ORM adapter for Zanzo ReBAC. Zero-config Zanzibar Tuple Pattern with parameterized SQL.",
5
5
  "keywords": [
6
6
  "@zanzojs/core",
@@ -21,9 +21,9 @@
21
21
  "type": "module",
22
22
  "exports": {
23
23
  ".": {
24
+ "types": "./dist/index.d.ts",
24
25
  "import": "./dist/index.js",
25
- "require": "./dist/index.cjs",
26
- "types": "./dist/index.d.ts"
26
+ "require": "./dist/index.cjs"
27
27
  }
28
28
  },
29
29
  "main": "./dist/index.cjs",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "peerDependencies": {
41
- "@zanzojs/core": "^0.1.0-beta.0",
41
+ "@zanzojs/core": "^0.3.0",
42
42
  "drizzle-orm": ">=0.29.0"
43
43
  },
44
44
  "devDependencies": {
@@ -46,7 +46,7 @@
46
46
  "typescript": "^5.7.2",
47
47
  "tsup": "latest",
48
48
  "vitest": "latest",
49
- "@zanzojs/core": "0.1.0"
49
+ "@zanzojs/core": "0.3.0"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsup",