@pattern-stack/codegen 0.7.8 → 0.8.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.
Files changed (26) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/runtime/base-classes/activity-entity-repository.js +98 -17
  3. package/dist/runtime/base-classes/activity-entity-repository.js.map +1 -1
  4. package/dist/runtime/base-classes/base-repository.d.ts +47 -3
  5. package/dist/runtime/base-classes/base-repository.js +98 -17
  6. package/dist/runtime/base-classes/base-repository.js.map +1 -1
  7. package/dist/runtime/base-classes/index.d.ts +1 -0
  8. package/dist/runtime/base-classes/index.js +137 -28
  9. package/dist/runtime/base-classes/index.js.map +1 -1
  10. package/dist/runtime/base-classes/junction-sync-repository.js +102 -21
  11. package/dist/runtime/base-classes/junction-sync-repository.js.map +1 -1
  12. package/dist/runtime/base-classes/knowledge-entity-repository.js +98 -17
  13. package/dist/runtime/base-classes/knowledge-entity-repository.js.map +1 -1
  14. package/dist/runtime/base-classes/metadata-entity-repository.js +101 -20
  15. package/dist/runtime/base-classes/metadata-entity-repository.js.map +1 -1
  16. package/dist/runtime/base-classes/synced-entity-repository.js +103 -22
  17. package/dist/runtime/base-classes/synced-entity-repository.js.map +1 -1
  18. package/dist/runtime/base-classes/tenant-context.d.ts +79 -0
  19. package/dist/runtime/base-classes/tenant-context.js +46 -0
  20. package/dist/runtime/base-classes/tenant-context.js.map +1 -0
  21. package/dist/src/cli/index.js +2 -0
  22. package/dist/src/cli/index.js.map +1 -1
  23. package/package.json +1 -1
  24. package/runtime/base-classes/base-repository.ts +96 -20
  25. package/runtime/base-classes/index.ts +13 -0
  26. package/runtime/base-classes/tenant-context.ts +175 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,10 +9,15 @@
9
9
  *
10
10
  * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.
11
11
  */
12
- import { eq, inArray, isNull, sql } from 'drizzle-orm';
12
+ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
13
13
  import type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';
14
14
  import type { SQL } from 'drizzle-orm';
15
15
  import type { DrizzleClient, DrizzleTx } from '../types/drizzle';
16
+ import {
17
+ requireRequester,
18
+ tryGetRequester,
19
+ type RequesterScope,
20
+ } from './tenant-context';
16
21
 
17
22
  // ============================================================================
18
23
  // Interfaces
@@ -59,6 +64,21 @@ export abstract class BaseRepository<TEntity> {
59
64
  userTracking: false,
60
65
  };
61
66
 
67
+ /**
68
+ * Ambient tenant-scope enforcement for `userTracking` repos (see
69
+ * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
70
+ *
71
+ * - `'lenient'` (default): when no ambient requester context is active,
72
+ * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
73
+ * ambient scoping is additive. Scoping kicks in automatically once a
74
+ * boundary installs `withRequester(...)`.
75
+ * - `'strict'`: a missing ambient context throws (`requireRequester`),
76
+ * making a forgotten boundary fail loud instead of silently returning
77
+ * cross-tenant rows. Recommended for new multi-tenant consumers — override
78
+ * in a concrete repo or a family base class.
79
+ */
80
+ protected readonly scopeEnforcement: 'lenient' | 'strict' = 'lenient';
81
+
62
82
  protected readonly db: DrizzleClient;
63
83
 
64
84
  constructor(db: DrizzleClient) {
@@ -85,9 +105,7 @@ export abstract class BaseRepository<TEntity> {
85
105
  * Returns null if not found (or soft-deleted when softDelete=true).
86
106
  */
87
107
  async findById(id: string): Promise<TEntity | null> {
88
- const rows = await this.baseQuery()
89
- .where(eq(this.table['id'], id))
90
- .limit(1);
108
+ const rows = await this.baseQuery(eq(this.table['id'], id)).limit(1);
91
109
  return (rows[0] as TEntity) ?? null;
92
110
  }
93
111
 
@@ -97,7 +115,7 @@ export abstract class BaseRepository<TEntity> {
97
115
  */
98
116
  async findByIds(ids: string[]): Promise<TEntity[]> {
99
117
  if (ids.length === 0) return [];
100
- const rows = await this.baseQuery().where(inArray(this.table['id'], ids));
118
+ const rows = await this.baseQuery(inArray(this.table['id'], ids));
101
119
  return rows as TEntity[];
102
120
  }
103
121
 
@@ -105,11 +123,8 @@ export abstract class BaseRepository<TEntity> {
105
123
  * List entities with optional filtering, pagination, and ordering.
106
124
  */
107
125
  async list(options?: ListOptions): Promise<TEntity[]> {
108
- let query = this.baseQuery();
126
+ let query = this.baseQuery(options?.where);
109
127
 
110
- if (options?.where) {
111
- query = query.where(options.where) as typeof query;
112
- }
113
128
  if (options?.orderBy) {
114
129
  query = query.orderBy(options.orderBy as SQL) as typeof query;
115
130
  }
@@ -137,6 +152,10 @@ export abstract class BaseRepository<TEntity> {
137
152
  if (this.behaviors.softDelete) {
138
153
  conditions.push(isNull(this.table['deletedAt']));
139
154
  }
155
+ const scope = this.scopePredicate();
156
+ if (scope) {
157
+ conditions.push(scope);
158
+ }
140
159
  if (where) {
141
160
  conditions.push(where);
142
161
  }
@@ -144,8 +163,6 @@ export abstract class BaseRepository<TEntity> {
144
163
  if (conditions.length === 1) {
145
164
  query = query.where(conditions[0]) as typeof query;
146
165
  } else if (conditions.length > 1) {
147
- // Combine with AND by building the condition inline
148
- const { and } = await import('drizzle-orm');
149
166
  query = query.where(and(...conditions)) as typeof query;
150
167
  }
151
168
 
@@ -186,7 +203,7 @@ export abstract class BaseRepository<TEntity> {
186
203
  const rows = await this.runner(tx)
187
204
  .update(this.table)
188
205
  .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
189
- .where(eq(this.table['id'], id))
206
+ .where(this.scopeAnd(eq(this.table['id'], id)))
190
207
  .returning();
191
208
  return rows[0] as TEntity;
192
209
  }
@@ -202,11 +219,11 @@ export abstract class BaseRepository<TEntity> {
202
219
  await runner
203
220
  .update(this.table)
204
221
  .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
205
- .where(eq(this.table['id'], id));
222
+ .where(this.scopeAnd(eq(this.table['id'], id)));
206
223
  } else {
207
224
  await runner
208
225
  .delete(this.table)
209
- .where(eq(this.table['id'], id));
226
+ .where(this.scopeAnd(eq(this.table['id'], id)));
210
227
  }
211
228
  }
212
229
 
@@ -224,15 +241,74 @@ export abstract class BaseRepository<TEntity> {
224
241
  // ============================================================================
225
242
 
226
243
  /**
227
- * Base SELECT query that automatically excludes soft-deleted rows
228
- * when softDelete behavior is enabled.
244
+ * Base SELECT query that automatically applies the ambient guards —
245
+ * soft-delete exclusion (when `softDelete`) and tenant scope (when
246
+ * `userTracking` + an active requester context) — combined with an optional
247
+ * caller `extra` predicate into a SINGLE `WHERE`.
248
+ *
249
+ * Pass the leaf predicate as `extra` rather than chaining a second
250
+ * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
251
+ * `.where()` on a `$dynamic()` query, so a chained call would silently drop
252
+ * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
229
253
  */
230
- protected baseQuery() {
254
+ protected baseQuery(extra?: SQL) {
231
255
  const query = this.db.select().from(this.table).$dynamic();
232
- if (this.behaviors.softDelete) {
233
- return query.where(isNull(this.table['deletedAt']));
256
+ const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });
257
+ return where ? query.where(where) : query;
258
+ }
259
+
260
+ /**
261
+ * Build the ambient tenant-scope predicate for this repo's table.
262
+ *
263
+ * Returns `undefined` (no scoping) when:
264
+ * - `behaviors.userTracking` is false (repo is not user-owned), or
265
+ * - no ambient requester context is active AND `scopeEnforcement` is
266
+ * `'lenient'` (the default — preserves pre-scoping behavior).
267
+ *
268
+ * When a requester context is active, scopes by `user_id` per the ambient
269
+ * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
270
+ * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
271
+ * → no filter. See `tenant-context.ts` for the boundary-install contract.
272
+ */
273
+ protected scopePredicate(): SQL | undefined {
274
+ if (!this.behaviors.userTracking) return undefined;
275
+ const ctx =
276
+ this.scopeEnforcement === 'strict'
277
+ ? requireRequester()
278
+ : tryGetRequester();
279
+ if (!ctx) return undefined;
280
+ const scope: RequesterScope = ctx.scope ?? 'user';
281
+ switch (scope) {
282
+ case 'superuser':
283
+ return undefined;
284
+ case 'org':
285
+ return ctx.orgUserIds && ctx.orgUserIds.length > 0
286
+ ? inArray(this.table['userId'], ctx.orgUserIds as string[])
287
+ : sql`false`;
288
+ case 'user':
289
+ default:
290
+ return eq(this.table['userId'], ctx.userId);
234
291
  }
235
- return query;
292
+ }
293
+
294
+ /**
295
+ * Combine the ambient scope predicate (and, optionally, the soft-delete
296
+ * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
297
+ * when nothing applies. Used by read + by-id write paths so a single
298
+ * `.where(...)` carries every guard.
299
+ */
300
+ protected scopeAnd(
301
+ extra?: SQL,
302
+ opts?: { softDelete?: boolean },
303
+ ): SQL | undefined {
304
+ const conditions: SQL[] = [];
305
+ if (opts?.softDelete) conditions.push(isNull(this.table['deletedAt']));
306
+ const scope = this.scopePredicate();
307
+ if (scope) conditions.push(scope);
308
+ if (extra) conditions.push(extra);
309
+ if (conditions.length === 0) return undefined;
310
+ if (conditions.length === 1) return conditions[0];
311
+ return and(...conditions);
236
312
  }
237
313
 
238
314
  /**
@@ -4,6 +4,19 @@
4
4
  export { BaseRepository } from './base-repository';
5
5
  export type { BehaviorConfig, ListOptions } from './base-repository';
6
6
 
7
+ // Ambient tenant scope (AsyncLocalStorage) — read by BaseRepository.scopePredicate,
8
+ // set at request/worker boundaries via withRequester/withUserScope/etc.
9
+ export {
10
+ withRequester,
11
+ requireRequester,
12
+ tryGetRequester,
13
+ requireRequesterScope,
14
+ withUserScope,
15
+ withOrgScope,
16
+ withSuperuserScope,
17
+ } from './tenant-context';
18
+ export type { RequesterContext, RequesterScope } from './tenant-context';
19
+
7
20
  export { BaseService } from './base-service';
8
21
  export type { IBaseRepository } from './base-service';
9
22
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Ambient requester context — AsyncLocalStorage-backed tenant scope.
3
+ *
4
+ * The alternative to threading `userId`/`organizationId` through every
5
+ * repository/service signature. Set ONCE at each boundary the generated app
6
+ * owns, read implicitly inside `BaseRepository` (see `scopePredicate`).
7
+ *
8
+ * ## Where to set it (boundaries)
9
+ *
10
+ * - HTTP / tRPC handlers — from the authenticated `ctx.user`
11
+ * - OAuth callback controllers — from the authenticated session
12
+ * - Queue/worker `process()` — from the job's owning user after the
13
+ * job's record is loaded
14
+ *
15
+ * Each boundary wraps the rest of the request in `withRequester({ userId,
16
+ * organizationId }, () => ...)`. The context propagates through every `await`
17
+ * to all downstream repo/service calls without being passed explicitly.
18
+ *
19
+ * ## Where to read it
20
+ *
21
+ * - `BaseRepository.scopePredicate()` reads it (via `tryGetRequester` in
22
+ * lenient mode, `requireRequester` in strict mode) and filters every read
23
+ * by the ambient scope when the repo declares `userTracking: true`.
24
+ *
25
+ * ## Why AsyncLocalStorage over an explicit parameter
26
+ *
27
+ * Threading `userId` (and later `organizationId`) through dozens of method
28
+ * signatures is pure parameter pollution. Ambient context also lets a repo
29
+ * make the "I forgot to scope" mistake impossible at runtime: in strict mode
30
+ * `requireRequester()` throws when no context is active, surfacing a missing
31
+ * boundary call loudly rather than silently leaking cross-tenant data.
32
+ *
33
+ * ## Not-found semantics
34
+ *
35
+ * When a row exists but belongs to a different requester, scoped reads return
36
+ * `null`/`[]` — identical to "truly doesn't exist". No existence oracle;
37
+ * callers throw NotFound uniformly. Standard security practice.
38
+ *
39
+ * ## Testing
40
+ *
41
+ * Tests that exercise scoped repos must wrap the call in `withRequester(...)`.
42
+ * In strict mode an unwrapped call hitting `requireRequester()` throws — by
43
+ * design. In lenient mode (the default) an unwrapped call is simply unscoped.
44
+ */
45
+ import { AsyncLocalStorage } from 'node:async_hooks';
46
+
47
+ /**
48
+ * Data-visibility scope. The auth layer decides which scope a request is
49
+ * allowed to claim; the repo trusts whatever the ambient context says.
50
+ *
51
+ * - `'user'`: filter every read by `user_id = ctx.userId`. Default.
52
+ * - `'org'`: filter every read by membership in the requester's org, resolved
53
+ * via `user_id IN (ctx.orgUserIds)` rather than via a per-entity
54
+ * `organization_id` column. Works for every user-owned table and keeps repos
55
+ * single-table — the org member list is pre-resolved at the boundary.
56
+ * - `'superuser'`: no scope filter. Engineering / internal-tools only.
57
+ *
58
+ * AUTHORIZATION (who is allowed to claim each scope) lives in boundary
59
+ * middleware, not in the repo. The repo trusts the ambient context — same
60
+ * trust model as a threaded `userId`.
61
+ */
62
+ export type RequesterScope = 'user' | 'org' | 'superuser';
63
+
64
+ export interface RequesterContext {
65
+ /**
66
+ * The user making the request. Always present — even in `'org'` and
67
+ * `'superuser'` scopes it is the audit-trail "who actually did this".
68
+ */
69
+ readonly userId: string;
70
+ /**
71
+ * The organization the requester belongs to. Required when
72
+ * `scope === 'org'`; may be null for `'user'` (users with no org) and for
73
+ * `'superuser'` (cross-org reads).
74
+ */
75
+ readonly organizationId: string | null;
76
+ /**
77
+ * Data-visibility scope. Defaults to `'user'` when omitted.
78
+ */
79
+ readonly scope?: RequesterScope;
80
+ /**
81
+ * For `scope === 'org'`: the list of user IDs in the requester's org,
82
+ * pre-resolved by the boundary middleware that established the `'org'`
83
+ * scope (one `SELECT users.id WHERE organization_id = X` at the trust
84
+ * boundary). Repos use this as a literal `IN (...)` filter — they never
85
+ * JOIN to `users` themselves. Required when `scope === 'org'`.
86
+ */
87
+ readonly orgUserIds?: readonly string[];
88
+ }
89
+
90
+ const als = new AsyncLocalStorage<RequesterContext>();
91
+
92
+ /**
93
+ * Set the ambient requester context for the duration of `fn`. The context
94
+ * propagates through `await` boundaries to all downstream calls. Nesting is
95
+ * fine — an inner `withRequester` overrides the outer for its callback.
96
+ */
97
+ export function withRequester<T>(
98
+ ctx: RequesterContext,
99
+ fn: () => Promise<T>,
100
+ ): Promise<T> {
101
+ return als.run(ctx, fn);
102
+ }
103
+
104
+ /**
105
+ * Read the ambient requester context. Throws if no context is active — by
106
+ * design. Used by repos in strict scope-enforcement mode; an unwrapped call
107
+ * site is a missing boundary.
108
+ */
109
+ export function requireRequester(): RequesterContext {
110
+ const ctx = als.getStore();
111
+ if (!ctx) {
112
+ throw new Error(
113
+ 'No requester context active. Wrap the entry point in ' +
114
+ 'withRequester({ userId, organizationId }, fn). See tenant-context.ts.',
115
+ );
116
+ }
117
+ return ctx;
118
+ }
119
+
120
+ /**
121
+ * Read the ambient requester context without throwing. Returns `undefined`
122
+ * when no context is active. Used by repos in lenient scope-enforcement mode
123
+ * (the default) and by code paths that legitimately run outside a request.
124
+ */
125
+ export function tryGetRequester(): RequesterContext | undefined {
126
+ return als.getStore();
127
+ }
128
+
129
+ /**
130
+ * Resolve the effective scope for the ambient context, defaulting to `'user'`.
131
+ */
132
+ export function requireRequesterScope(): RequesterScope {
133
+ return requireRequester().scope ?? 'user';
134
+ }
135
+
136
+ /**
137
+ * Convenience helpers for setting scope explicitly. All three preserve
138
+ * `userId` in the context (audit trail) regardless of scope.
139
+ *
140
+ * - `withUserScope`: regular end-user requests. Most call sites.
141
+ * - `withOrgScope`: admin / org-shared resource access. The caller MUST verify
142
+ * the requester's role permits `'org'` before calling — the helper does not
143
+ * enforce authorization. `orgUserIds` is pre-resolved at the boundary.
144
+ * - `withSuperuserScope`: engineering scripts / internal tools. `organizationId`
145
+ * is null (cross-org is the point). Same authorization caveat applies.
146
+ */
147
+ export function withUserScope<T>(
148
+ userId: string,
149
+ organizationId: string | null,
150
+ fn: () => Promise<T>,
151
+ ): Promise<T> {
152
+ return withRequester({ userId, organizationId, scope: 'user' }, fn);
153
+ }
154
+
155
+ export function withOrgScope<T>(
156
+ userId: string,
157
+ organizationId: string,
158
+ orgUserIds: readonly string[],
159
+ fn: () => Promise<T>,
160
+ ): Promise<T> {
161
+ return withRequester(
162
+ { userId, organizationId, scope: 'org', orgUserIds },
163
+ fn,
164
+ );
165
+ }
166
+
167
+ export function withSuperuserScope<T>(
168
+ userId: string,
169
+ fn: () => Promise<T>,
170
+ ): Promise<T> {
171
+ return withRequester(
172
+ { userId, organizationId: null, scope: 'superuser' },
173
+ fn,
174
+ );
175
+ }