@pattern-stack/codegen 0.7.8 → 0.8.1

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 (40) hide show
  1. package/CHANGELOG.md +95 -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/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
  22. package/dist/runtime/subsystems/auth/index.d.ts +2 -0
  23. package/dist/runtime/subsystems/auth/index.js +55 -0
  24. package/dist/runtime/subsystems/auth/index.js.map +1 -1
  25. package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
  26. package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
  27. package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
  28. package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
  29. package/dist/runtime/subsystems/index.d.ts +1 -0
  30. package/dist/runtime/subsystems/index.js +4 -0
  31. package/dist/runtime/subsystems/index.js.map +1 -1
  32. package/dist/src/cli/index.js +15 -1
  33. package/dist/src/cli/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/runtime/base-classes/base-repository.ts +96 -20
  36. package/runtime/base-classes/index.ts +13 -0
  37. package/runtime/base-classes/tenant-context.ts +175 -0
  38. package/runtime/subsystems/auth/index.ts +8 -0
  39. package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
  40. package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.1] — 2026-05-25
8
+
9
+ Closes the loop on ambient tenant scoping (0.8.0): adds the **RequesterContext
10
+ boundary** that turns an authenticated request into ambient scope, so scoping
11
+ actually engages over HTTP — including Swagger's "Authorize" bearer flow. See
12
+ ADR-0002 (`ai-docs/adrs/0002-requester-context-boundary.md`).
13
+
14
+ ### Added
15
+
16
+ - **`feat(auth)` — `RequesterContextMiddleware` + `installRequesterContext`.** New
17
+ `runtime/subsystems/auth/middleware/requester-context.ts`. The Express-style
18
+ middleware resolves the requester via the consumer's `IUserContext` and runs the
19
+ rest of the request inside `withRequester(...)`, so every downstream repository
20
+ read/write is auto-scoped (ADR-0001) with no threaded `userId`. ALS-correct
21
+ (middleware, not interceptor). `installRequesterContext(app)` is the one-liner
22
+ for `main.ts`: resolves `AUTH_USER_CONTEXT` from the root container
23
+ (`app.get(token, { strict: false })`), no-ops with a warning when unbound.
24
+ Exported from the auth barrel. Verified over real HTTP — two concurrent requests
25
+ with different bearer tokens each observe their own scope; an unauthenticated
26
+ request observes none (`requester-context.http.spec.ts`).
27
+ - **`feat(auth)` — optional `IUserContext.resolveRequester(req)`.** Supplies the
28
+ full `org`/`superuser` `RequesterContext` (org member list resolved at the
29
+ boundary). Backward compatible: when absent, the boundary derives plain `'user'`
30
+ scope from `getCurrentUserId`.
31
+
32
+ ### Changed
33
+
34
+ - **`feat(scaffold)` — generated `main.ts` persists Swagger auth.**
35
+ `SwaggerModule.setup(...)` now passes `{ swaggerOptions: { persistAuthorization:
36
+ true } }`, so the "Authorize" bearer token survives reloads and keeps flowing as
37
+ the `Authorization` header the boundary reads. The generated `main.ts` also
38
+ carries a commented `installRequesterContext(app)` hint (not a static import — so
39
+ scaffolds without the auth subsystem still compile).
40
+
41
+ ### Notes
42
+
43
+ - Wiring is opt-in: add `installRequesterContext(app)` to your bootstrap after
44
+ `NestFactory.create`. Auto-patching it in at `subsystem install auth` time (like
45
+ the Swagger block) is a deferred follow-up (ADR-0002). A tRPC-side boundary and
46
+ junction-repo scoping remain deferred.
47
+
48
+ ## [0.8.0] — 2026-05-25
49
+
50
+ Adds **ambient tenant scoping** to `BaseRepository`: user-owned repos filter every
51
+ read/write by the requester automatically, instead of relying on hand-threaded
52
+ `userId` parameters. Ports the proven `dealbrain` `RequesterContext` pattern into
53
+ the codegen substrate. See ADR-0001 (`ai-docs/adrs/0001-ambient-tenant-scoping.md`).
54
+
55
+ ### Added
56
+
57
+ - **`feat(runtime)` — `tenant-context.ts` ambient scope primitive.** New
58
+ `runtime/base-classes/tenant-context.ts`: an `AsyncLocalStorage`-backed
59
+ `RequesterContext { userId, organizationId, scope?, orgUserIds? }` with
60
+ `withRequester(ctx, fn)` (set at a boundary), `requireRequester()` /
61
+ `tryGetRequester()` (read inside repos), and `withUserScope` / `withOrgScope` /
62
+ `withSuperuserScope` helpers. Scope model (`'user' | 'org' | 'superuser'`)
63
+ copied verbatim from `dealbrain` `packages/integrations/src/framework`.
64
+ Vendored into consumers via `init-scaffold` and exported from the base-classes
65
+ barrel.
66
+ - **`feat(runtime)` — `BaseRepository.scopePredicate()` + `scopeAnd()`.** When a
67
+ repo declares `behaviors.userTracking` (i.e. the entity has the `user_tracking`
68
+ behavior) and an ambient `RequesterContext` is active, `findById`, `findByIds`,
69
+ `list`, `count`, `update`, and `delete` automatically filter by `user_id`:
70
+ `= ctx.userId` (`user`), `IN ctx.orgUserIds` (`org`; empty ⇒ matches nothing),
71
+ or unfiltered (`superuser`). **No new per-entity config knob** — it rides the
72
+ existing (previously dormant) `userTracking` flag. No template changes.
73
+ - **Unit coverage** — `base-repository.spec.ts` gains a scoping suite that renders
74
+ real Drizzle SQL (via `QueryBuilder`) to assert the emitted `WHERE` per scope,
75
+ gating (off when `userTracking` false / no context), strict-mode throw, and the
76
+ combined soft-delete + scope + leaf predicate.
77
+
78
+ ### Changed
79
+
80
+ - **`scopeEnforcement` (lenient default).** With no ambient context active, a
81
+ `userTracking` repo is **not** scoped — adopting ambient scoping is additive,
82
+ and isolation engages only once a boundary installs `withRequester(...)`. Set
83
+ `protected readonly scopeEnforcement = 'strict'` on a repo or family base to
84
+ make a missing boundary throw (fail-loud). Validated against
85
+ `dealbrain-integrations` (no `userTracking` repos today): typecheck + 737 tests
86
+ green, behavior unchanged.
87
+
88
+ ### Fixed
89
+
90
+ - **`fix(runtime)` — soft-delete guard no longer dropped on `findById` /
91
+ `list({where})` / bespoke query methods.** Drizzle's `.where()` *overrides* a
92
+ prior `.where()` on a `$dynamic()` query, so the soft-delete `isNull` filter
93
+ that `baseQuery()` added was being silently discarded whenever a leaf method
94
+ chained its own `.where()` — only no-arg `list()` and `count()` actually
95
+ excluded soft-deleted rows. `baseQuery(extra?)` now folds soft-delete + scope +
96
+ the leaf predicate into a single AND-joined `WHERE`.
97
+ - **Migration:** on `soft_delete` entities, `findById(id)` and `list({ where })`
98
+ now correctly **exclude** soft-deleted rows (previously returned them). Code
99
+ that relied on the old behavior to read a soft-deleted row must query
100
+ `deletedAt` explicitly.
101
+
7
102
  ## [0.7.8] — 2026-05-25
8
103
 
9
104
  Fixes a NestJS DI-resolution bug in cross-entity module wiring. A generated service that injects a sibling entity's **repository** — junction `.list()` composition (CGP-60), EAV value→definition resolution (`eav_value_table`) — failed at runtime because the sibling module exported only its **service**, never its repository (ADR-002). The code typechecked (`tsc` can't see DI wiring), so it shipped and only surfaced on a consumer's first `NestFactory` boot (dealbrain-integrations).
@@ -2,7 +2,25 @@
2
2
  import { eq as eq2, between, desc } from "drizzle-orm";
3
3
 
4
4
  // runtime/base-classes/base-repository.ts
5
- import { eq, inArray, isNull, sql } from "drizzle-orm";
5
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
6
+
7
+ // runtime/base-classes/tenant-context.ts
8
+ import { AsyncLocalStorage } from "async_hooks";
9
+ var als = new AsyncLocalStorage();
10
+ function requireRequester() {
11
+ const ctx = als.getStore();
12
+ if (!ctx) {
13
+ throw new Error(
14
+ "No requester context active. Wrap the entry point in withRequester({ userId, organizationId }, fn). See tenant-context.ts."
15
+ );
16
+ }
17
+ return ctx;
18
+ }
19
+ function tryGetRequester() {
20
+ return als.getStore();
21
+ }
22
+
23
+ // runtime/base-classes/base-repository.ts
6
24
  var BaseRepository = class {
7
25
  // eslint-disable-line @typescript-eslint/no-explicit-any
8
26
  /**
@@ -14,6 +32,20 @@ var BaseRepository = class {
14
32
  softDelete: false,
15
33
  userTracking: false
16
34
  };
35
+ /**
36
+ * Ambient tenant-scope enforcement for `userTracking` repos (see
37
+ * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
38
+ *
39
+ * - `'lenient'` (default): when no ambient requester context is active,
40
+ * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
41
+ * ambient scoping is additive. Scoping kicks in automatically once a
42
+ * boundary installs `withRequester(...)`.
43
+ * - `'strict'`: a missing ambient context throws (`requireRequester`),
44
+ * making a forgotten boundary fail loud instead of silently returning
45
+ * cross-tenant rows. Recommended for new multi-tenant consumers — override
46
+ * in a concrete repo or a family base class.
47
+ */
48
+ scopeEnforcement = "lenient";
17
49
  db;
18
50
  constructor(db) {
19
51
  this.db = db;
@@ -36,7 +68,7 @@ var BaseRepository = class {
36
68
  * Returns null if not found (or soft-deleted when softDelete=true).
37
69
  */
38
70
  async findById(id) {
39
- const rows = await this.baseQuery().where(eq(this.table["id"], id)).limit(1);
71
+ const rows = await this.baseQuery(eq(this.table["id"], id)).limit(1);
40
72
  return rows[0] ?? null;
41
73
  }
42
74
  /**
@@ -45,17 +77,14 @@ var BaseRepository = class {
45
77
  */
46
78
  async findByIds(ids) {
47
79
  if (ids.length === 0) return [];
48
- const rows = await this.baseQuery().where(inArray(this.table["id"], ids));
80
+ const rows = await this.baseQuery(inArray(this.table["id"], ids));
49
81
  return rows;
50
82
  }
51
83
  /**
52
84
  * List entities with optional filtering, pagination, and ordering.
53
85
  */
54
86
  async list(options) {
55
- let query = this.baseQuery();
56
- if (options?.where) {
57
- query = query.where(options.where);
58
- }
87
+ let query = this.baseQuery(options?.where);
59
88
  if (options?.orderBy) {
60
89
  query = query.orderBy(options.orderBy);
61
90
  }
@@ -78,13 +107,16 @@ var BaseRepository = class {
78
107
  if (this.behaviors.softDelete) {
79
108
  conditions.push(isNull(this.table["deletedAt"]));
80
109
  }
110
+ const scope = this.scopePredicate();
111
+ if (scope) {
112
+ conditions.push(scope);
113
+ }
81
114
  if (where) {
82
115
  conditions.push(where);
83
116
  }
84
117
  if (conditions.length === 1) {
85
118
  query = query.where(conditions[0]);
86
119
  } else if (conditions.length > 1) {
87
- const { and } = await import("drizzle-orm");
88
120
  query = query.where(and(...conditions));
89
121
  }
90
122
  const rows = await query;
@@ -114,7 +146,7 @@ var BaseRepository = class {
114
146
  */
115
147
  async update(id, input, tx) {
116
148
  const data = this.withTimestamps(input, "update");
117
- const rows = await this.runner(tx).update(this.table).set(data).where(eq(this.table["id"], id)).returning();
149
+ const rows = await this.runner(tx).update(this.table).set(data).where(this.scopeAnd(eq(this.table["id"], id))).returning();
118
150
  return rows[0];
119
151
  }
120
152
  /**
@@ -125,9 +157,9 @@ var BaseRepository = class {
125
157
  async delete(id, tx) {
126
158
  const runner = this.runner(tx);
127
159
  if (this.behaviors.softDelete) {
128
- await runner.update(this.table).set({ deletedAt: /* @__PURE__ */ new Date() }).where(eq(this.table["id"], id));
160
+ await runner.update(this.table).set({ deletedAt: /* @__PURE__ */ new Date() }).where(this.scopeAnd(eq(this.table["id"], id)));
129
161
  } else {
130
- await runner.delete(this.table).where(eq(this.table["id"], id));
162
+ await runner.delete(this.table).where(this.scopeAnd(eq(this.table["id"], id)));
131
163
  }
132
164
  }
133
165
  /**
@@ -142,15 +174,64 @@ var BaseRepository = class {
142
174
  // Protected Helpers
143
175
  // ============================================================================
144
176
  /**
145
- * Base SELECT query that automatically excludes soft-deleted rows
146
- * when softDelete behavior is enabled.
177
+ * Base SELECT query that automatically applies the ambient guards —
178
+ * soft-delete exclusion (when `softDelete`) and tenant scope (when
179
+ * `userTracking` + an active requester context) — combined with an optional
180
+ * caller `extra` predicate into a SINGLE `WHERE`.
181
+ *
182
+ * Pass the leaf predicate as `extra` rather than chaining a second
183
+ * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
184
+ * `.where()` on a `$dynamic()` query, so a chained call would silently drop
185
+ * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
147
186
  */
148
- baseQuery() {
187
+ baseQuery(extra) {
149
188
  const query = this.db.select().from(this.table).$dynamic();
150
- if (this.behaviors.softDelete) {
151
- return query.where(isNull(this.table["deletedAt"]));
189
+ const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });
190
+ return where ? query.where(where) : query;
191
+ }
192
+ /**
193
+ * Build the ambient tenant-scope predicate for this repo's table.
194
+ *
195
+ * Returns `undefined` (no scoping) when:
196
+ * - `behaviors.userTracking` is false (repo is not user-owned), or
197
+ * - no ambient requester context is active AND `scopeEnforcement` is
198
+ * `'lenient'` (the default — preserves pre-scoping behavior).
199
+ *
200
+ * When a requester context is active, scopes by `user_id` per the ambient
201
+ * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
202
+ * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
203
+ * → no filter. See `tenant-context.ts` for the boundary-install contract.
204
+ */
205
+ scopePredicate() {
206
+ if (!this.behaviors.userTracking) return void 0;
207
+ const ctx = this.scopeEnforcement === "strict" ? requireRequester() : tryGetRequester();
208
+ if (!ctx) return void 0;
209
+ const scope = ctx.scope ?? "user";
210
+ switch (scope) {
211
+ case "superuser":
212
+ return void 0;
213
+ case "org":
214
+ return ctx.orgUserIds && ctx.orgUserIds.length > 0 ? inArray(this.table["userId"], ctx.orgUserIds) : sql`false`;
215
+ case "user":
216
+ default:
217
+ return eq(this.table["userId"], ctx.userId);
152
218
  }
153
- return query;
219
+ }
220
+ /**
221
+ * Combine the ambient scope predicate (and, optionally, the soft-delete
222
+ * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
223
+ * when nothing applies. Used by read + by-id write paths so a single
224
+ * `.where(...)` carries every guard.
225
+ */
226
+ scopeAnd(extra, opts) {
227
+ const conditions = [];
228
+ if (opts?.softDelete) conditions.push(isNull(this.table["deletedAt"]));
229
+ const scope = this.scopePredicate();
230
+ if (scope) conditions.push(scope);
231
+ if (extra) conditions.push(extra);
232
+ if (conditions.length === 0) return void 0;
233
+ if (conditions.length === 1) return conditions[0];
234
+ return and(...conditions);
154
235
  }
155
236
  /**
156
237
  * Merge timestamp fields into an input object.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../runtime/base-classes/activity-entity-repository.ts","../../../runtime/base-classes/base-repository.ts"],"sourcesContent":["/**\n * ActivityEntityRepository<TEntity>\n *\n * Family-specific base for activity entities (emails, calls, meetings, notes).\n * Adds date-range queries, user/opportunity scoping, and recency ordering.\n *\n * Concrete repos extend this and declare their table + behaviors.\n */\nimport { eq, between, desc } from 'drizzle-orm';\nimport { BaseRepository } from './base-repository';\n\nexport abstract class ActivityEntityRepository<TEntity> extends BaseRepository<TEntity> {\n /**\n * Find activities within a date range (inclusive).\n */\n async findByDateRange(start: Date, end: Date): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(between(this.table['occurredAt'], start, end));\n return rows as TEntity[];\n }\n\n /**\n * Find all activities for a specific user.\n */\n async findByUserId(userId: string): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['userId'], userId));\n return rows as TEntity[];\n }\n\n /**\n * Find all activities for a specific opportunity.\n */\n async findByOpportunityId(opportunityId: string): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['opportunityId'], opportunityId));\n return rows as TEntity[];\n }\n\n /**\n * Find the most recent activities for an opportunity, ordered by occurredAt desc.\n */\n async findRecentByOpportunityId(opportunityId: string, limit = 10): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['opportunityId'], opportunityId))\n .orderBy(desc(this.table['occurredAt']))\n .limit(limit);\n return rows as TEntity[];\n }\n}\n","/**\n * BaseRepository<TEntity>\n *\n * Abstract base class providing standard CRUD operations via Drizzle ORM.\n * Every generated repository extends this class.\n *\n * Family-specific bases (CrmEntityRepository, etc.) extend this in v0.1\n * without any changes to BaseRepository.\n *\n * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.\n */\nimport { eq, inArray, isNull, sql } from 'drizzle-orm';\nimport type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';\nimport type { SQL } from 'drizzle-orm';\nimport type { DrizzleClient, DrizzleTx } from '../types/drizzle';\n\n// ============================================================================\n// Interfaces\n// ============================================================================\n\n/**\n * Behavior flags for the repository. Controls automatic timestamp injection\n * and soft-delete filtering.\n */\nexport interface BehaviorConfig {\n timestamps: boolean;\n softDelete: boolean;\n userTracking: boolean;\n}\n\n/**\n * Options for the list() method.\n */\nexport interface ListOptions {\n where?: SQL;\n limit?: number;\n offset?: number;\n orderBy?: PgColumn | SQL;\n}\n\n// ============================================================================\n// BaseRepository\n// ============================================================================\n\nexport abstract class BaseRepository<TEntity> {\n /**\n * The Drizzle table schema for this entity.\n * Concrete repositories declare this as a class property.\n */\n protected abstract readonly table: PgTableWithColumns<any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n\n /**\n * Behavior flags controlling automatic behavior injection.\n * Override in concrete repositories to enable behaviors.\n */\n protected readonly behaviors: BehaviorConfig = {\n timestamps: false,\n softDelete: false,\n userTracking: false,\n };\n\n protected readonly db: DrizzleClient;\n\n constructor(db: DrizzleClient) {\n this.db = db;\n }\n\n /**\n * Pick the runner for a write: the caller-supplied transaction handle\n * if present, otherwise the repository's own client. Keeps the `tx`\n * parameter purely additive — callers without a transaction call as\n * before. Used by the write methods below + consumer overrides (e.g.\n * the generated `upsertCurrentValues` on EAV value tables).\n */\n protected runner(tx?: DrizzleTx): DrizzleClient {\n return tx ?? this.db;\n }\n\n // ============================================================================\n // Read Operations\n // ============================================================================\n\n /**\n * Find a single entity by its primary key.\n * Returns null if not found (or soft-deleted when softDelete=true).\n */\n async findById(id: string): Promise<TEntity | null> {\n const rows = await this.baseQuery()\n .where(eq(this.table['id'], id))\n .limit(1);\n return (rows[0] as TEntity) ?? null;\n }\n\n /**\n * Find multiple entities by their primary keys.\n * Returns empty array immediately for empty input (avoids DB errors).\n */\n async findByIds(ids: string[]): Promise<TEntity[]> {\n if (ids.length === 0) return [];\n const rows = await this.baseQuery().where(inArray(this.table['id'], ids));\n return rows as TEntity[];\n }\n\n /**\n * List entities with optional filtering, pagination, and ordering.\n */\n async list(options?: ListOptions): Promise<TEntity[]> {\n let query = this.baseQuery();\n\n if (options?.where) {\n query = query.where(options.where) as typeof query;\n }\n if (options?.orderBy) {\n query = query.orderBy(options.orderBy as SQL) as typeof query;\n }\n if (options?.limit !== undefined) {\n query = query.limit(options.limit) as typeof query;\n }\n if (options?.offset !== undefined) {\n query = query.offset(options.offset) as typeof query;\n }\n\n const rows = await query;\n return rows as TEntity[];\n }\n\n /**\n * Count entities matching an optional WHERE clause.\n * Soft-deleted rows are always excluded when softDelete=true.\n */\n async count(where?: SQL): Promise<number> {\n let query = this.db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(this.table);\n\n const conditions: SQL[] = [];\n if (this.behaviors.softDelete) {\n conditions.push(isNull(this.table['deletedAt']));\n }\n if (where) {\n conditions.push(where);\n }\n\n if (conditions.length === 1) {\n query = query.where(conditions[0]) as typeof query;\n } else if (conditions.length > 1) {\n // Combine with AND by building the condition inline\n const { and } = await import('drizzle-orm');\n query = query.where(and(...conditions)) as typeof query;\n }\n\n const rows = await query;\n return rows[0]?.count ?? 0;\n }\n\n /**\n * Check whether an entity with the given id exists.\n */\n async exists(id: string): Promise<boolean> {\n const result = await this.findById(id);\n return result !== null;\n }\n\n // ============================================================================\n // Write Operations\n // ============================================================================\n\n /**\n * Insert a new entity. Timestamps are auto-injected when timestamps=true.\n */\n async create(input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'create');\n const rows = await this.runner(tx)\n .insert(this.table)\n .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Update an existing entity by id. updatedAt is auto-injected when timestamps=true.\n * Returns the updated entity.\n */\n async update(id: string, input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'update');\n const rows = await this.runner(tx)\n .update(this.table)\n .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(eq(this.table['id'], id))\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Delete an entity by id.\n * - softDelete=true: sets deletedAt to current timestamp\n * - softDelete=false: hard-deletes the row\n */\n async delete(id: string, tx?: DrizzleTx): Promise<void> {\n const runner = this.runner(tx);\n if (this.behaviors.softDelete) {\n await runner\n .update(this.table)\n .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(eq(this.table['id'], id));\n } else {\n await runner\n .delete(this.table)\n .where(eq(this.table['id'], id));\n }\n }\n\n /**\n * Insert or update multiple entities.\n * Default naive implementation — family repositories override with\n * proper conflict-target upsert (e.g., CrmEntityRepository).\n */\n async upsertMany(inputs: Array<Partial<TEntity>>, tx?: DrizzleTx): Promise<TEntity[]> {\n return Promise.all(inputs.map((input) => this.create(input, tx)));\n }\n\n // ============================================================================\n // Protected Helpers\n // ============================================================================\n\n /**\n * Base SELECT query that automatically excludes soft-deleted rows\n * when softDelete behavior is enabled.\n */\n protected baseQuery() {\n const query = this.db.select().from(this.table).$dynamic();\n if (this.behaviors.softDelete) {\n return query.where(isNull(this.table['deletedAt']));\n }\n return query;\n }\n\n /**\n * Merge timestamp fields into an input object.\n * - mode='create': adds createdAt and updatedAt\n * - mode='update': adds updatedAt only\n *\n * No-op when timestamps behavior is disabled.\n */\n protected withTimestamps(\n input: Record<string, unknown>,\n mode: 'create' | 'update',\n ): Record<string, unknown> {\n if (!this.behaviors.timestamps) return input;\n const now = new Date();\n if (mode === 'create') {\n return { ...input, createdAt: now, updatedAt: now };\n }\n return { ...input, updatedAt: now };\n }\n\n /**\n * Build a WHERE clause fragment that restricts results to rows whose\n * parent (identified by a belongs_to FK) is not soft-deleted.\n *\n * Use this in custom repository methods when you need \"rows reachable\n * from an active parent\". The default findAll / findById behavior is\n * NOT changed by this helper — opt in explicitly where needed.\n *\n * ADR-021 — Soft-delete cascade: Option A (filter at query time).\n * `on_delete` FK rules do not fire for soft-deletes; use this helper\n * instead of expecting cascade semantics on the DB level.\n *\n * Example:\n * async listActiveMessages(): Promise<Message[]> {\n * return this.list({\n * where: this.activeParentFilter(conversations, this.table['conversationId']),\n * });\n * }\n *\n * @param parentTable The Drizzle table object for the parent entity.\n * @param parentFkColumn The FK column on this (child) table that references parent.id.\n */\n protected activeParentFilter(\n parentTable: PgTableWithColumns<any>, // eslint-disable-line @typescript-eslint/no-explicit-any\n parentFkColumn: PgColumn,\n ): SQL {\n return sql`EXISTS (\n SELECT 1 FROM ${parentTable} p\n WHERE p.id = ${parentFkColumn}\n AND p.deleted_at IS NULL\n )`;\n }\n}\n"],"mappings":"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","names":["eq","eq"]}
1
+ {"version":3,"sources":["../../../runtime/base-classes/activity-entity-repository.ts","../../../runtime/base-classes/base-repository.ts","../../../runtime/base-classes/tenant-context.ts"],"sourcesContent":["/**\n * ActivityEntityRepository<TEntity>\n *\n * Family-specific base for activity entities (emails, calls, meetings, notes).\n * Adds date-range queries, user/opportunity scoping, and recency ordering.\n *\n * Concrete repos extend this and declare their table + behaviors.\n */\nimport { eq, between, desc } from 'drizzle-orm';\nimport { BaseRepository } from './base-repository';\n\nexport abstract class ActivityEntityRepository<TEntity> extends BaseRepository<TEntity> {\n /**\n * Find activities within a date range (inclusive).\n */\n async findByDateRange(start: Date, end: Date): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(between(this.table['occurredAt'], start, end));\n return rows as TEntity[];\n }\n\n /**\n * Find all activities for a specific user.\n */\n async findByUserId(userId: string): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['userId'], userId));\n return rows as TEntity[];\n }\n\n /**\n * Find all activities for a specific opportunity.\n */\n async findByOpportunityId(opportunityId: string): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['opportunityId'], opportunityId));\n return rows as TEntity[];\n }\n\n /**\n * Find the most recent activities for an opportunity, ordered by occurredAt desc.\n */\n async findRecentByOpportunityId(opportunityId: string, limit = 10): Promise<TEntity[]> {\n const rows = await this.baseQuery()\n .where(eq(this.table['opportunityId'], opportunityId))\n .orderBy(desc(this.table['occurredAt']))\n .limit(limit);\n return rows as TEntity[];\n }\n}\n","/**\n * BaseRepository<TEntity>\n *\n * Abstract base class providing standard CRUD operations via Drizzle ORM.\n * Every generated repository extends this class.\n *\n * Family-specific bases (CrmEntityRepository, etc.) extend this in v0.1\n * without any changes to BaseRepository.\n *\n * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.\n */\nimport { and, eq, inArray, isNull, sql } from 'drizzle-orm';\nimport type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';\nimport type { SQL } from 'drizzle-orm';\nimport type { DrizzleClient, DrizzleTx } from '../types/drizzle';\nimport {\n requireRequester,\n tryGetRequester,\n type RequesterScope,\n} from './tenant-context';\n\n// ============================================================================\n// Interfaces\n// ============================================================================\n\n/**\n * Behavior flags for the repository. Controls automatic timestamp injection\n * and soft-delete filtering.\n */\nexport interface BehaviorConfig {\n timestamps: boolean;\n softDelete: boolean;\n userTracking: boolean;\n}\n\n/**\n * Options for the list() method.\n */\nexport interface ListOptions {\n where?: SQL;\n limit?: number;\n offset?: number;\n orderBy?: PgColumn | SQL;\n}\n\n// ============================================================================\n// BaseRepository\n// ============================================================================\n\nexport abstract class BaseRepository<TEntity> {\n /**\n * The Drizzle table schema for this entity.\n * Concrete repositories declare this as a class property.\n */\n protected abstract readonly table: PgTableWithColumns<any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n\n /**\n * Behavior flags controlling automatic behavior injection.\n * Override in concrete repositories to enable behaviors.\n */\n protected readonly behaviors: BehaviorConfig = {\n timestamps: false,\n softDelete: false,\n userTracking: false,\n };\n\n /**\n * Ambient tenant-scope enforcement for `userTracking` repos (see\n * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.\n *\n * - `'lenient'` (default): when no ambient requester context is active,\n * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting\n * ambient scoping is additive. Scoping kicks in automatically once a\n * boundary installs `withRequester(...)`.\n * - `'strict'`: a missing ambient context throws (`requireRequester`),\n * making a forgotten boundary fail loud instead of silently returning\n * cross-tenant rows. Recommended for new multi-tenant consumers — override\n * in a concrete repo or a family base class.\n */\n protected readonly scopeEnforcement: 'lenient' | 'strict' = 'lenient';\n\n protected readonly db: DrizzleClient;\n\n constructor(db: DrizzleClient) {\n this.db = db;\n }\n\n /**\n * Pick the runner for a write: the caller-supplied transaction handle\n * if present, otherwise the repository's own client. Keeps the `tx`\n * parameter purely additive — callers without a transaction call as\n * before. Used by the write methods below + consumer overrides (e.g.\n * the generated `upsertCurrentValues` on EAV value tables).\n */\n protected runner(tx?: DrizzleTx): DrizzleClient {\n return tx ?? this.db;\n }\n\n // ============================================================================\n // Read Operations\n // ============================================================================\n\n /**\n * Find a single entity by its primary key.\n * Returns null if not found (or soft-deleted when softDelete=true).\n */\n async findById(id: string): Promise<TEntity | null> {\n const rows = await this.baseQuery(eq(this.table['id'], id)).limit(1);\n return (rows[0] as TEntity) ?? null;\n }\n\n /**\n * Find multiple entities by their primary keys.\n * Returns empty array immediately for empty input (avoids DB errors).\n */\n async findByIds(ids: string[]): Promise<TEntity[]> {\n if (ids.length === 0) return [];\n const rows = await this.baseQuery(inArray(this.table['id'], ids));\n return rows as TEntity[];\n }\n\n /**\n * List entities with optional filtering, pagination, and ordering.\n */\n async list(options?: ListOptions): Promise<TEntity[]> {\n let query = this.baseQuery(options?.where);\n\n if (options?.orderBy) {\n query = query.orderBy(options.orderBy as SQL) as typeof query;\n }\n if (options?.limit !== undefined) {\n query = query.limit(options.limit) as typeof query;\n }\n if (options?.offset !== undefined) {\n query = query.offset(options.offset) as typeof query;\n }\n\n const rows = await query;\n return rows as TEntity[];\n }\n\n /**\n * Count entities matching an optional WHERE clause.\n * Soft-deleted rows are always excluded when softDelete=true.\n */\n async count(where?: SQL): Promise<number> {\n let query = this.db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(this.table);\n\n const conditions: SQL[] = [];\n if (this.behaviors.softDelete) {\n conditions.push(isNull(this.table['deletedAt']));\n }\n const scope = this.scopePredicate();\n if (scope) {\n conditions.push(scope);\n }\n if (where) {\n conditions.push(where);\n }\n\n if (conditions.length === 1) {\n query = query.where(conditions[0]) as typeof query;\n } else if (conditions.length > 1) {\n query = query.where(and(...conditions)) as typeof query;\n }\n\n const rows = await query;\n return rows[0]?.count ?? 0;\n }\n\n /**\n * Check whether an entity with the given id exists.\n */\n async exists(id: string): Promise<boolean> {\n const result = await this.findById(id);\n return result !== null;\n }\n\n // ============================================================================\n // Write Operations\n // ============================================================================\n\n /**\n * Insert a new entity. Timestamps are auto-injected when timestamps=true.\n */\n async create(input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'create');\n const rows = await this.runner(tx)\n .insert(this.table)\n .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Update an existing entity by id. updatedAt is auto-injected when timestamps=true.\n * Returns the updated entity.\n */\n async update(id: string, input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'update');\n const rows = await this.runner(tx)\n .update(this.table)\n .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(this.scopeAnd(eq(this.table['id'], id)))\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Delete an entity by id.\n * - softDelete=true: sets deletedAt to current timestamp\n * - softDelete=false: hard-deletes the row\n */\n async delete(id: string, tx?: DrizzleTx): Promise<void> {\n const runner = this.runner(tx);\n if (this.behaviors.softDelete) {\n await runner\n .update(this.table)\n .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(this.scopeAnd(eq(this.table['id'], id)));\n } else {\n await runner\n .delete(this.table)\n .where(this.scopeAnd(eq(this.table['id'], id)));\n }\n }\n\n /**\n * Insert or update multiple entities.\n * Default naive implementation — family repositories override with\n * proper conflict-target upsert (e.g., CrmEntityRepository).\n */\n async upsertMany(inputs: Array<Partial<TEntity>>, tx?: DrizzleTx): Promise<TEntity[]> {\n return Promise.all(inputs.map((input) => this.create(input, tx)));\n }\n\n // ============================================================================\n // Protected Helpers\n // ============================================================================\n\n /**\n * Base SELECT query that automatically applies the ambient guards —\n * soft-delete exclusion (when `softDelete`) and tenant scope (when\n * `userTracking` + an active requester context) — combined with an optional\n * caller `extra` predicate into a SINGLE `WHERE`.\n *\n * Pass the leaf predicate as `extra` rather than chaining a second\n * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior\n * `.where()` on a `$dynamic()` query, so a chained call would silently drop\n * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.\n */\n protected baseQuery(extra?: SQL) {\n const query = this.db.select().from(this.table).$dynamic();\n const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });\n return where ? query.where(where) : query;\n }\n\n /**\n * Build the ambient tenant-scope predicate for this repo's table.\n *\n * Returns `undefined` (no scoping) when:\n * - `behaviors.userTracking` is false (repo is not user-owned), or\n * - no ambient requester context is active AND `scopeEnforcement` is\n * `'lenient'` (the default — preserves pre-scoping behavior).\n *\n * When a requester context is active, scopes by `user_id` per the ambient\n * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN\n * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`\n * → no filter. See `tenant-context.ts` for the boundary-install contract.\n */\n protected scopePredicate(): SQL | undefined {\n if (!this.behaviors.userTracking) return undefined;\n const ctx =\n this.scopeEnforcement === 'strict'\n ? requireRequester()\n : tryGetRequester();\n if (!ctx) return undefined;\n const scope: RequesterScope = ctx.scope ?? 'user';\n switch (scope) {\n case 'superuser':\n return undefined;\n case 'org':\n return ctx.orgUserIds && ctx.orgUserIds.length > 0\n ? inArray(this.table['userId'], ctx.orgUserIds as string[])\n : sql`false`;\n case 'user':\n default:\n return eq(this.table['userId'], ctx.userId);\n }\n }\n\n /**\n * Combine the ambient scope predicate (and, optionally, the soft-delete\n * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`\n * when nothing applies. Used by read + by-id write paths so a single\n * `.where(...)` carries every guard.\n */\n protected scopeAnd(\n extra?: SQL,\n opts?: { softDelete?: boolean },\n ): SQL | undefined {\n const conditions: SQL[] = [];\n if (opts?.softDelete) conditions.push(isNull(this.table['deletedAt']));\n const scope = this.scopePredicate();\n if (scope) conditions.push(scope);\n if (extra) conditions.push(extra);\n if (conditions.length === 0) return undefined;\n if (conditions.length === 1) return conditions[0];\n return and(...conditions);\n }\n\n /**\n * Merge timestamp fields into an input object.\n * - mode='create': adds createdAt and updatedAt\n * - mode='update': adds updatedAt only\n *\n * No-op when timestamps behavior is disabled.\n */\n protected withTimestamps(\n input: Record<string, unknown>,\n mode: 'create' | 'update',\n ): Record<string, unknown> {\n if (!this.behaviors.timestamps) return input;\n const now = new Date();\n if (mode === 'create') {\n return { ...input, createdAt: now, updatedAt: now };\n }\n return { ...input, updatedAt: now };\n }\n\n /**\n * Build a WHERE clause fragment that restricts results to rows whose\n * parent (identified by a belongs_to FK) is not soft-deleted.\n *\n * Use this in custom repository methods when you need \"rows reachable\n * from an active parent\". The default findAll / findById behavior is\n * NOT changed by this helper — opt in explicitly where needed.\n *\n * ADR-021 — Soft-delete cascade: Option A (filter at query time).\n * `on_delete` FK rules do not fire for soft-deletes; use this helper\n * instead of expecting cascade semantics on the DB level.\n *\n * Example:\n * async listActiveMessages(): Promise<Message[]> {\n * return this.list({\n * where: this.activeParentFilter(conversations, this.table['conversationId']),\n * });\n * }\n *\n * @param parentTable The Drizzle table object for the parent entity.\n * @param parentFkColumn The FK column on this (child) table that references parent.id.\n */\n protected activeParentFilter(\n parentTable: PgTableWithColumns<any>, // eslint-disable-line @typescript-eslint/no-explicit-any\n parentFkColumn: PgColumn,\n ): SQL {\n return sql`EXISTS (\n SELECT 1 FROM ${parentTable} p\n WHERE p.id = ${parentFkColumn}\n AND p.deleted_at IS NULL\n )`;\n }\n}\n","/**\n * Ambient requester context — AsyncLocalStorage-backed tenant scope.\n *\n * The alternative to threading `userId`/`organizationId` through every\n * repository/service signature. Set ONCE at each boundary the generated app\n * owns, read implicitly inside `BaseRepository` (see `scopePredicate`).\n *\n * ## Where to set it (boundaries)\n *\n * - HTTP / tRPC handlers — from the authenticated `ctx.user`\n * - OAuth callback controllers — from the authenticated session\n * - Queue/worker `process()` — from the job's owning user after the\n * job's record is loaded\n *\n * Each boundary wraps the rest of the request in `withRequester({ userId,\n * organizationId }, () => ...)`. The context propagates through every `await`\n * to all downstream repo/service calls without being passed explicitly.\n *\n * ## Where to read it\n *\n * - `BaseRepository.scopePredicate()` reads it (via `tryGetRequester` in\n * lenient mode, `requireRequester` in strict mode) and filters every read\n * by the ambient scope when the repo declares `userTracking: true`.\n *\n * ## Why AsyncLocalStorage over an explicit parameter\n *\n * Threading `userId` (and later `organizationId`) through dozens of method\n * signatures is pure parameter pollution. Ambient context also lets a repo\n * make the \"I forgot to scope\" mistake impossible at runtime: in strict mode\n * `requireRequester()` throws when no context is active, surfacing a missing\n * boundary call loudly rather than silently leaking cross-tenant data.\n *\n * ## Not-found semantics\n *\n * When a row exists but belongs to a different requester, scoped reads return\n * `null`/`[]` — identical to \"truly doesn't exist\". No existence oracle;\n * callers throw NotFound uniformly. Standard security practice.\n *\n * ## Testing\n *\n * Tests that exercise scoped repos must wrap the call in `withRequester(...)`.\n * In strict mode an unwrapped call hitting `requireRequester()` throws — by\n * design. In lenient mode (the default) an unwrapped call is simply unscoped.\n */\nimport { AsyncLocalStorage } from 'node:async_hooks';\n\n/**\n * Data-visibility scope. The auth layer decides which scope a request is\n * allowed to claim; the repo trusts whatever the ambient context says.\n *\n * - `'user'`: filter every read by `user_id = ctx.userId`. Default.\n * - `'org'`: filter every read by membership in the requester's org, resolved\n * via `user_id IN (ctx.orgUserIds)` rather than via a per-entity\n * `organization_id` column. Works for every user-owned table and keeps repos\n * single-table — the org member list is pre-resolved at the boundary.\n * - `'superuser'`: no scope filter. Engineering / internal-tools only.\n *\n * AUTHORIZATION (who is allowed to claim each scope) lives in boundary\n * middleware, not in the repo. The repo trusts the ambient context — same\n * trust model as a threaded `userId`.\n */\nexport type RequesterScope = 'user' | 'org' | 'superuser';\n\nexport interface RequesterContext {\n /**\n * The user making the request. Always present — even in `'org'` and\n * `'superuser'` scopes it is the audit-trail \"who actually did this\".\n */\n readonly userId: string;\n /**\n * The organization the requester belongs to. Required when\n * `scope === 'org'`; may be null for `'user'` (users with no org) and for\n * `'superuser'` (cross-org reads).\n */\n readonly organizationId: string | null;\n /**\n * Data-visibility scope. Defaults to `'user'` when omitted.\n */\n readonly scope?: RequesterScope;\n /**\n * For `scope === 'org'`: the list of user IDs in the requester's org,\n * pre-resolved by the boundary middleware that established the `'org'`\n * scope (one `SELECT users.id WHERE organization_id = X` at the trust\n * boundary). Repos use this as a literal `IN (...)` filter — they never\n * JOIN to `users` themselves. Required when `scope === 'org'`.\n */\n readonly orgUserIds?: readonly string[];\n}\n\nconst als = new AsyncLocalStorage<RequesterContext>();\n\n/**\n * Set the ambient requester context for the duration of `fn`. The context\n * propagates through `await` boundaries to all downstream calls. Nesting is\n * fine — an inner `withRequester` overrides the outer for its callback.\n */\nexport function withRequester<T>(\n ctx: RequesterContext,\n fn: () => Promise<T>,\n): Promise<T> {\n return als.run(ctx, fn);\n}\n\n/**\n * Read the ambient requester context. Throws if no context is active — by\n * design. Used by repos in strict scope-enforcement mode; an unwrapped call\n * site is a missing boundary.\n */\nexport function requireRequester(): RequesterContext {\n const ctx = als.getStore();\n if (!ctx) {\n throw new Error(\n 'No requester context active. Wrap the entry point in ' +\n 'withRequester({ userId, organizationId }, fn). See tenant-context.ts.',\n );\n }\n return ctx;\n}\n\n/**\n * Read the ambient requester context without throwing. Returns `undefined`\n * when no context is active. Used by repos in lenient scope-enforcement mode\n * (the default) and by code paths that legitimately run outside a request.\n */\nexport function tryGetRequester(): RequesterContext | undefined {\n return als.getStore();\n}\n\n/**\n * Resolve the effective scope for the ambient context, defaulting to `'user'`.\n */\nexport function requireRequesterScope(): RequesterScope {\n return requireRequester().scope ?? 'user';\n}\n\n/**\n * Convenience helpers for setting scope explicitly. All three preserve\n * `userId` in the context (audit trail) regardless of scope.\n *\n * - `withUserScope`: regular end-user requests. Most call sites.\n * - `withOrgScope`: admin / org-shared resource access. The caller MUST verify\n * the requester's role permits `'org'` before calling — the helper does not\n * enforce authorization. `orgUserIds` is pre-resolved at the boundary.\n * - `withSuperuserScope`: engineering scripts / internal tools. `organizationId`\n * is null (cross-org is the point). Same authorization caveat applies.\n */\nexport function withUserScope<T>(\n userId: string,\n organizationId: string | null,\n fn: () => Promise<T>,\n): Promise<T> {\n return withRequester({ userId, organizationId, scope: 'user' }, fn);\n}\n\nexport function withOrgScope<T>(\n userId: string,\n organizationId: string,\n orgUserIds: readonly string[],\n fn: () => Promise<T>,\n): Promise<T> {\n return withRequester(\n { userId, organizationId, scope: 'org', orgUserIds },\n fn,\n );\n}\n\nexport function withSuperuserScope<T>(\n userId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n return withRequester(\n { userId, organizationId: null, scope: 'superuser' },\n fn,\n );\n}\n"],"mappings":"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","names":["eq","eq"]}
@@ -33,6 +33,20 @@ declare abstract class BaseRepository<TEntity> {
33
33
  * Override in concrete repositories to enable behaviors.
34
34
  */
35
35
  protected readonly behaviors: BehaviorConfig;
36
+ /**
37
+ * Ambient tenant-scope enforcement for `userTracking` repos (see
38
+ * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
39
+ *
40
+ * - `'lenient'` (default): when no ambient requester context is active,
41
+ * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
42
+ * ambient scoping is additive. Scoping kicks in automatically once a
43
+ * boundary installs `withRequester(...)`.
44
+ * - `'strict'`: a missing ambient context throws (`requireRequester`),
45
+ * making a forgotten boundary fail loud instead of silently returning
46
+ * cross-tenant rows. Recommended for new multi-tenant consumers — override
47
+ * in a concrete repo or a family base class.
48
+ */
49
+ protected readonly scopeEnforcement: 'lenient' | 'strict';
36
50
  protected readonly db: DrizzleClient;
37
51
  constructor(db: DrizzleClient);
38
52
  /**
@@ -88,12 +102,42 @@ declare abstract class BaseRepository<TEntity> {
88
102
  */
89
103
  upsertMany(inputs: Array<Partial<TEntity>>, tx?: DrizzleTx): Promise<TEntity[]>;
90
104
  /**
91
- * Base SELECT query that automatically excludes soft-deleted rows
92
- * when softDelete behavior is enabled.
105
+ * Base SELECT query that automatically applies the ambient guards —
106
+ * soft-delete exclusion (when `softDelete`) and tenant scope (when
107
+ * `userTracking` + an active requester context) — combined with an optional
108
+ * caller `extra` predicate into a SINGLE `WHERE`.
109
+ *
110
+ * Pass the leaf predicate as `extra` rather than chaining a second
111
+ * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
112
+ * `.where()` on a `$dynamic()` query, so a chained call would silently drop
113
+ * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
93
114
  */
94
- protected baseQuery(): drizzle_orm_pg_core.PgSelectBase<any, any, "single", {} | Record<any, "not-null">, true, never, {
115
+ protected baseQuery(extra?: SQL): drizzle_orm_pg_core.PgSelectBase<any, any, "single", {} | Record<any, "not-null">, true, never, {
95
116
  [x: string]: any;
96
117
  }[], any>;
118
+ /**
119
+ * Build the ambient tenant-scope predicate for this repo's table.
120
+ *
121
+ * Returns `undefined` (no scoping) when:
122
+ * - `behaviors.userTracking` is false (repo is not user-owned), or
123
+ * - no ambient requester context is active AND `scopeEnforcement` is
124
+ * `'lenient'` (the default — preserves pre-scoping behavior).
125
+ *
126
+ * When a requester context is active, scopes by `user_id` per the ambient
127
+ * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
128
+ * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
129
+ * → no filter. See `tenant-context.ts` for the boundary-install contract.
130
+ */
131
+ protected scopePredicate(): SQL | undefined;
132
+ /**
133
+ * Combine the ambient scope predicate (and, optionally, the soft-delete
134
+ * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
135
+ * when nothing applies. Used by read + by-id write paths so a single
136
+ * `.where(...)` carries every guard.
137
+ */
138
+ protected scopeAnd(extra?: SQL, opts?: {
139
+ softDelete?: boolean;
140
+ }): SQL | undefined;
97
141
  /**
98
142
  * Merge timestamp fields into an input object.
99
143
  * - mode='create': adds createdAt and updatedAt
@@ -1,5 +1,23 @@
1
1
  // runtime/base-classes/base-repository.ts
2
- import { eq, inArray, isNull, sql } from "drizzle-orm";
2
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
3
+
4
+ // runtime/base-classes/tenant-context.ts
5
+ import { AsyncLocalStorage } from "async_hooks";
6
+ var als = new AsyncLocalStorage();
7
+ function requireRequester() {
8
+ const ctx = als.getStore();
9
+ if (!ctx) {
10
+ throw new Error(
11
+ "No requester context active. Wrap the entry point in withRequester({ userId, organizationId }, fn). See tenant-context.ts."
12
+ );
13
+ }
14
+ return ctx;
15
+ }
16
+ function tryGetRequester() {
17
+ return als.getStore();
18
+ }
19
+
20
+ // runtime/base-classes/base-repository.ts
3
21
  var BaseRepository = class {
4
22
  // eslint-disable-line @typescript-eslint/no-explicit-any
5
23
  /**
@@ -11,6 +29,20 @@ var BaseRepository = class {
11
29
  softDelete: false,
12
30
  userTracking: false
13
31
  };
32
+ /**
33
+ * Ambient tenant-scope enforcement for `userTracking` repos (see
34
+ * `scopePredicate`). Only has effect when `behaviors.userTracking === true`.
35
+ *
36
+ * - `'lenient'` (default): when no ambient requester context is active,
37
+ * reads/writes are NOT scoped — preserves pre-scoping behavior, so adopting
38
+ * ambient scoping is additive. Scoping kicks in automatically once a
39
+ * boundary installs `withRequester(...)`.
40
+ * - `'strict'`: a missing ambient context throws (`requireRequester`),
41
+ * making a forgotten boundary fail loud instead of silently returning
42
+ * cross-tenant rows. Recommended for new multi-tenant consumers — override
43
+ * in a concrete repo or a family base class.
44
+ */
45
+ scopeEnforcement = "lenient";
14
46
  db;
15
47
  constructor(db) {
16
48
  this.db = db;
@@ -33,7 +65,7 @@ var BaseRepository = class {
33
65
  * Returns null if not found (or soft-deleted when softDelete=true).
34
66
  */
35
67
  async findById(id) {
36
- const rows = await this.baseQuery().where(eq(this.table["id"], id)).limit(1);
68
+ const rows = await this.baseQuery(eq(this.table["id"], id)).limit(1);
37
69
  return rows[0] ?? null;
38
70
  }
39
71
  /**
@@ -42,17 +74,14 @@ var BaseRepository = class {
42
74
  */
43
75
  async findByIds(ids) {
44
76
  if (ids.length === 0) return [];
45
- const rows = await this.baseQuery().where(inArray(this.table["id"], ids));
77
+ const rows = await this.baseQuery(inArray(this.table["id"], ids));
46
78
  return rows;
47
79
  }
48
80
  /**
49
81
  * List entities with optional filtering, pagination, and ordering.
50
82
  */
51
83
  async list(options) {
52
- let query = this.baseQuery();
53
- if (options?.where) {
54
- query = query.where(options.where);
55
- }
84
+ let query = this.baseQuery(options?.where);
56
85
  if (options?.orderBy) {
57
86
  query = query.orderBy(options.orderBy);
58
87
  }
@@ -75,13 +104,16 @@ var BaseRepository = class {
75
104
  if (this.behaviors.softDelete) {
76
105
  conditions.push(isNull(this.table["deletedAt"]));
77
106
  }
107
+ const scope = this.scopePredicate();
108
+ if (scope) {
109
+ conditions.push(scope);
110
+ }
78
111
  if (where) {
79
112
  conditions.push(where);
80
113
  }
81
114
  if (conditions.length === 1) {
82
115
  query = query.where(conditions[0]);
83
116
  } else if (conditions.length > 1) {
84
- const { and } = await import("drizzle-orm");
85
117
  query = query.where(and(...conditions));
86
118
  }
87
119
  const rows = await query;
@@ -111,7 +143,7 @@ var BaseRepository = class {
111
143
  */
112
144
  async update(id, input, tx) {
113
145
  const data = this.withTimestamps(input, "update");
114
- const rows = await this.runner(tx).update(this.table).set(data).where(eq(this.table["id"], id)).returning();
146
+ const rows = await this.runner(tx).update(this.table).set(data).where(this.scopeAnd(eq(this.table["id"], id))).returning();
115
147
  return rows[0];
116
148
  }
117
149
  /**
@@ -122,9 +154,9 @@ var BaseRepository = class {
122
154
  async delete(id, tx) {
123
155
  const runner = this.runner(tx);
124
156
  if (this.behaviors.softDelete) {
125
- await runner.update(this.table).set({ deletedAt: /* @__PURE__ */ new Date() }).where(eq(this.table["id"], id));
157
+ await runner.update(this.table).set({ deletedAt: /* @__PURE__ */ new Date() }).where(this.scopeAnd(eq(this.table["id"], id)));
126
158
  } else {
127
- await runner.delete(this.table).where(eq(this.table["id"], id));
159
+ await runner.delete(this.table).where(this.scopeAnd(eq(this.table["id"], id)));
128
160
  }
129
161
  }
130
162
  /**
@@ -139,15 +171,64 @@ var BaseRepository = class {
139
171
  // Protected Helpers
140
172
  // ============================================================================
141
173
  /**
142
- * Base SELECT query that automatically excludes soft-deleted rows
143
- * when softDelete behavior is enabled.
174
+ * Base SELECT query that automatically applies the ambient guards —
175
+ * soft-delete exclusion (when `softDelete`) and tenant scope (when
176
+ * `userTracking` + an active requester context) — combined with an optional
177
+ * caller `extra` predicate into a SINGLE `WHERE`.
178
+ *
179
+ * Pass the leaf predicate as `extra` rather than chaining a second
180
+ * `.where(...)`: Drizzle's `.where()` OVERRIDES (does not AND) a prior
181
+ * `.where()` on a `$dynamic()` query, so a chained call would silently drop
182
+ * the soft-delete and scope guards. `baseQuery(extra)` is the safe form.
144
183
  */
145
- baseQuery() {
184
+ baseQuery(extra) {
146
185
  const query = this.db.select().from(this.table).$dynamic();
147
- if (this.behaviors.softDelete) {
148
- return query.where(isNull(this.table["deletedAt"]));
186
+ const where = this.scopeAnd(extra, { softDelete: this.behaviors.softDelete });
187
+ return where ? query.where(where) : query;
188
+ }
189
+ /**
190
+ * Build the ambient tenant-scope predicate for this repo's table.
191
+ *
192
+ * Returns `undefined` (no scoping) when:
193
+ * - `behaviors.userTracking` is false (repo is not user-owned), or
194
+ * - no ambient requester context is active AND `scopeEnforcement` is
195
+ * `'lenient'` (the default — preserves pre-scoping behavior).
196
+ *
197
+ * When a requester context is active, scopes by `user_id` per the ambient
198
+ * scope: `'user'` → `user_id = ctx.userId`; `'org'` → `user_id IN
199
+ * ctx.orgUserIds` (empty list matches nothing — fail-closed); `'superuser'`
200
+ * → no filter. See `tenant-context.ts` for the boundary-install contract.
201
+ */
202
+ scopePredicate() {
203
+ if (!this.behaviors.userTracking) return void 0;
204
+ const ctx = this.scopeEnforcement === "strict" ? requireRequester() : tryGetRequester();
205
+ if (!ctx) return void 0;
206
+ const scope = ctx.scope ?? "user";
207
+ switch (scope) {
208
+ case "superuser":
209
+ return void 0;
210
+ case "org":
211
+ return ctx.orgUserIds && ctx.orgUserIds.length > 0 ? inArray(this.table["userId"], ctx.orgUserIds) : sql`false`;
212
+ case "user":
213
+ default:
214
+ return eq(this.table["userId"], ctx.userId);
149
215
  }
150
- return query;
216
+ }
217
+ /**
218
+ * Combine the ambient scope predicate (and, optionally, the soft-delete
219
+ * guard) with a caller `extra` predicate into one `SQL`. Returns `undefined`
220
+ * when nothing applies. Used by read + by-id write paths so a single
221
+ * `.where(...)` carries every guard.
222
+ */
223
+ scopeAnd(extra, opts) {
224
+ const conditions = [];
225
+ if (opts?.softDelete) conditions.push(isNull(this.table["deletedAt"]));
226
+ const scope = this.scopePredicate();
227
+ if (scope) conditions.push(scope);
228
+ if (extra) conditions.push(extra);
229
+ if (conditions.length === 0) return void 0;
230
+ if (conditions.length === 1) return conditions[0];
231
+ return and(...conditions);
151
232
  }
152
233
  /**
153
234
  * Merge timestamp fields into an input object.