@12-apps/prisma 4.0.0 → 5.1.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.
@@ -83,6 +83,29 @@ export interface ActorContext extends ActorAttributionSnapshot {
83
83
  /** The acting admin's DB `users.id`, stamped onto created_by/updated_by. */
84
84
  userId: string;
85
85
  }
86
+ /**
87
+ * The `globalThis` key this package keeps its actor store under, exported so a
88
+ * host (or the audit package's `declareActorContextKey`) can name it without
89
+ * retyping the literal. The same shape `@12-apps/audit` exports as its
90
+ * `DEFAULT_ACTOR_STORE_KEY`.
91
+ */
92
+ export declare const DEFAULT_ACTOR_STORE_KEY = "__12appsPrismaActorStore";
93
+ /**
94
+ * Point this package's actor context at an existing store, by naming its
95
+ * `globalThis` key — the same seam (same name, same rules) as
96
+ * `@12-apps/audit`'s `declareActorContextKey`, so a host with an in-house
97
+ * actor module can put all three on one store with two identical calls.
98
+ *
99
+ * Call it ONCE, at wiring time, before anything stamps or reads an actor.
100
+ * Changing the key after the store exists is REFUSED rather than honoured:
101
+ * contexts already captured against the old instance would keep flowing to it
102
+ * while every later read went elsewhere — the silent fork this seam exists to
103
+ * prevent. Passing the key already in force is a no-op, so a defensive
104
+ * module-scope declaration is safe to load twice.
105
+ */
106
+ export declare function declareActorContextKey(key: string | symbol): void;
107
+ /** The key the store is (or would be) created under — diagnostics and tests. */
108
+ export declare const actorContextKey: () => string | symbol;
86
109
  /** Run `fn` with `userId` as the current actor. Nested calls override. */
87
110
  export declare const runWithActor: <T>(userId: string, fn: () => T, attribution?: ActorAttribution) => T;
88
111
  /**
@@ -21,13 +21,103 @@
21
21
  * middleware runtime.
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.getActorAttribution = exports.getActorUserId = exports.setActor = exports.runWithActorScope = exports.runWithActor = void 0;
24
+ exports.getActorAttribution = exports.getActorUserId = exports.setActor = exports.runWithActorScope = exports.runWithActor = exports.actorContextKey = exports.DEFAULT_ACTOR_STORE_KEY = void 0;
25
+ exports.declareActorContextKey = declareActorContextKey;
25
26
  const node_async_hooks_1 = require("node:async_hooks");
27
+ const node_buffer_1 = require("node:buffer");
26
28
  // Kept on globalThis so Next dev / Turbopack hot-reload (which re-evaluates this
27
29
  // module) can't create a second store whose context is invisible to closures
28
30
  // captured against the first.
31
+ //
32
+ // The KEY it lives under is a cross-package CONTRACT, not a private detail:
33
+ // `@12-apps/audit` ships its own copy of this module, and a host that routes
34
+ // audit writes through that package while stamping actors through this one
35
+ // needs both copies on ONE store (see `declareActorContextKey` there, and the
36
+ // interop suite in `tests/`). If the two disagree they get two separate
37
+ // AsyncLocalStorage instances and the failure is SILENT: audit writes rows
38
+ // with every attribution column NULL while this package believes a context is
39
+ // set — on an append-only table, so the attribution is gone for good.
29
40
  const globalStore = globalThis;
30
- const store = () => (globalStore.__futurePayActorStore ??= new node_async_hooks_1.AsyncLocalStorage());
41
+ /**
42
+ * The `globalThis` key this package keeps its actor store under, exported so a
43
+ * host (or the audit package's `declareActorContextKey`) can name it without
44
+ * retyping the literal. The same shape `@12-apps/audit` exports as its
45
+ * `DEFAULT_ACTOR_STORE_KEY`.
46
+ */
47
+ exports.DEFAULT_ACTOR_STORE_KEY = '__12appsPrismaActorStore';
48
+ /**
49
+ * The key releases before 5.0.0 used — the host-branded name 5.0.0 renamed
50
+ * away, decoded from base64 at runtime: even a split spelling of the name
51
+ * counts as a mention (the per-package and repo-wide brand gates both sweep
52
+ * this file), so the only representation shipped source may hold is one no
53
+ * grep for the brand can see.
54
+ *
55
+ * It is still READ (and mirrored, below) for exactly one reason: a process
56
+ * that mixes this copy with a pre-5.0.0 copy of this package — or whose audit
57
+ * store was declared against the old name — would otherwise fork the store,
58
+ * which is the silent NULL-attribution failure described above.
59
+ *
60
+ * DELETE in 6.0.0, together with the adopt/mirror branches in `store()`, once
61
+ * no adopter pins `@12-apps/prisma` < 5.0.0. Both known consumers pin exact
62
+ * versions, so the check is one grep over their lockfiles.
63
+ */
64
+ const LEGACY_ACTOR_STORE_KEY = node_buffer_1.Buffer.from('X19mdXR1cmVQYXlBY3RvclN0b3Jl', 'base64').toString();
65
+ /** The key in force, and the key the live store (if any) was created under. */
66
+ const storeKey = {
67
+ declared: exports.DEFAULT_ACTOR_STORE_KEY,
68
+ };
69
+ /**
70
+ * Point this package's actor context at an existing store, by naming its
71
+ * `globalThis` key — the same seam (same name, same rules) as
72
+ * `@12-apps/audit`'s `declareActorContextKey`, so a host with an in-house
73
+ * actor module can put all three on one store with two identical calls.
74
+ *
75
+ * Call it ONCE, at wiring time, before anything stamps or reads an actor.
76
+ * Changing the key after the store exists is REFUSED rather than honoured:
77
+ * contexts already captured against the old instance would keep flowing to it
78
+ * while every later read went elsewhere — the silent fork this seam exists to
79
+ * prevent. Passing the key already in force is a no-op, so a defensive
80
+ * module-scope declaration is safe to load twice.
81
+ */
82
+ function declareActorContextKey(key) {
83
+ if (typeof key === 'string' && key.trim() === '') {
84
+ throw new Error('actor context key must not be blank.');
85
+ }
86
+ if (key === storeKey.declared)
87
+ return;
88
+ if (storeKey.created !== undefined) {
89
+ throw new Error(`actor context key cannot change to ${String(key)}: the store already exists under ` +
90
+ `${String(storeKey.created)}. Declare the key once, before anything stamps an actor — ` +
91
+ 'moving it later forks the store, and a forked store loses every attribution ' +
92
+ 'silently onto an append-only table.');
93
+ }
94
+ storeKey.declared = key;
95
+ }
96
+ /** The key the store is (or would be) created under — diagnostics and tests. */
97
+ const actorContextKey = () => storeKey.declared;
98
+ exports.actorContextKey = actorContextKey;
99
+ const store = () => {
100
+ const key = storeKey.declared;
101
+ let instance = globalStore[key];
102
+ if (instance === undefined && key === exports.DEFAULT_ACTOR_STORE_KEY) {
103
+ // A pre-5.0.0 copy of this package already created the store under the
104
+ // old name: ADOPT it rather than fork it. One instance, two keys.
105
+ instance = globalStore[LEGACY_ACTOR_STORE_KEY];
106
+ if (instance !== undefined)
107
+ globalStore[key] = instance;
108
+ }
109
+ if (instance === undefined) {
110
+ instance = new node_async_hooks_1.AsyncLocalStorage();
111
+ globalStore[key] = instance;
112
+ // MIRROR under the old name so a pre-5.0.0 copy loaded after this one
113
+ // finds this store instead of creating its own. Only for the default key:
114
+ // a host that declared its own key has opted out of this package's names.
115
+ if (key === exports.DEFAULT_ACTOR_STORE_KEY)
116
+ globalStore[LEGACY_ACTOR_STORE_KEY] = instance;
117
+ }
118
+ storeKey.created = key;
119
+ return instance;
120
+ };
31
121
  /**
32
122
  * The REAL human behind `onBehalfOfUserId`, derived (never accepted) from the
33
123
  * stamp that declares the impersonation (FUT-458).
package/dist/index.d.ts CHANGED
@@ -32,8 +32,16 @@ export { AppendOnlyViolationError } from './append-only-extension';
32
32
  * host's to say rather than a list this package guesses.
33
33
  */
34
34
  export { configureAuditStamps, auditStampConfig, type AuditStampConfig } from './audit-extension';
35
- export { getActorAttribution, getActorUserId, runWithActor, runWithActorScope, setActor, type ActorAttribution, type ActorAttributionSnapshot, type ActorContext, } from './actor-context';
35
+ export { actorContextKey, DEFAULT_ACTOR_STORE_KEY, declareActorContextKey, getActorAttribution, getActorUserId, runWithActor, runWithActorScope, setActor, type ActorAttribution, type ActorAttributionSnapshot, type ActorContext, } from './actor-context';
36
36
  export { normalizeSearchText } from './search-normalize';
37
+ /**
38
+ * The `globalThis` keys the client singleton lives under, exported as named
39
+ * constants because they are cross-module-instance coordination surface (two
40
+ * copies of this package in one process share the client through them), not a
41
+ * private detail.
42
+ */
43
+ export declare const DEFAULT_CLIENT_STORE_KEY = "__12appsPrisma";
44
+ export declare const DEFAULT_CLIENT_INIT_KEY = "__12appsPrismaInit";
37
45
  /**
38
46
  * Get or create the Prisma client instance.
39
47
  *
package/dist/index.js CHANGED
@@ -57,7 +57,8 @@ var __importStar = (this && this.__importStar) || (function () {
57
57
  };
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
- exports.resetPrismaClient = exports.setPrismaClient = exports.getPrismaClient = exports.normalizeSearchText = exports.setActor = exports.runWithActorScope = exports.runWithActor = exports.getActorUserId = exports.getActorAttribution = exports.auditStampConfig = exports.configureAuditStamps = exports.AppendOnlyViolationError = void 0;
60
+ exports.resetPrismaClient = exports.setPrismaClient = exports.getPrismaClient = exports.DEFAULT_CLIENT_INIT_KEY = exports.DEFAULT_CLIENT_STORE_KEY = exports.normalizeSearchText = exports.setActor = exports.runWithActorScope = exports.runWithActor = exports.getActorUserId = exports.getActorAttribution = exports.declareActorContextKey = exports.DEFAULT_ACTOR_STORE_KEY = exports.actorContextKey = exports.auditStampConfig = exports.configureAuditStamps = exports.AppendOnlyViolationError = void 0;
61
+ const node_buffer_1 = require("node:buffer");
61
62
  const append_only_extension_1 = require("./append-only-extension");
62
63
  const audit_extension_1 = require("./audit-extension");
63
64
  // Append-only guard for the audit log (FUT-209): mutating the AuditLog model
@@ -78,6 +79,9 @@ Object.defineProperty(exports, "auditStampConfig", { enumerable: true, get: func
78
79
  // stamp created_by/updated_by. Re-exported here so consumers import them from
79
80
  // the same `@12-apps/prisma` entry point as `getPrismaClient`.
80
81
  var actor_context_1 = require("./actor-context");
82
+ Object.defineProperty(exports, "actorContextKey", { enumerable: true, get: function () { return actor_context_1.actorContextKey; } });
83
+ Object.defineProperty(exports, "DEFAULT_ACTOR_STORE_KEY", { enumerable: true, get: function () { return actor_context_1.DEFAULT_ACTOR_STORE_KEY; } });
84
+ Object.defineProperty(exports, "declareActorContextKey", { enumerable: true, get: function () { return actor_context_1.declareActorContextKey; } });
81
85
  Object.defineProperty(exports, "getActorAttribution", { enumerable: true, get: function () { return actor_context_1.getActorAttribution; } });
82
86
  Object.defineProperty(exports, "getActorUserId", { enumerable: true, get: function () { return actor_context_1.getActorUserId; } });
83
87
  Object.defineProperty(exports, "runWithActor", { enumerable: true, get: function () { return actor_context_1.runWithActor; } });
@@ -89,7 +93,40 @@ Object.defineProperty(exports, "normalizeSearchText", { enumerable: true, get: f
89
93
  // Next dev / Turbopack hot-reload (which re-evaluates this module) never spawns
90
94
  // a second PGlite instance against the same dataDir — PGlite holds a single
91
95
  // exclusive connection, and a duplicate would deadlock or corrupt the store.
96
+ /**
97
+ * The `globalThis` keys the client singleton lives under, exported as named
98
+ * constants because they are cross-module-instance coordination surface (two
99
+ * copies of this package in one process share the client through them), not a
100
+ * private detail.
101
+ */
102
+ exports.DEFAULT_CLIENT_STORE_KEY = '__12appsPrisma';
103
+ exports.DEFAULT_CLIENT_INIT_KEY = '__12appsPrismaInit';
104
+ /**
105
+ * The keys releases before 5.0.0 used — the host-branded names 5.0.0 renamed
106
+ * away, decoded from base64 at runtime so no spelling of the brand — whole or
107
+ * split — appears in shipped source.
108
+ * Still read and mirrored so a process mixing this copy with a pre-5.0.0 copy
109
+ * shares ONE client instead of racing two PGlite instances on one dataDir.
110
+ * DELETE in 6.0.0, with the fallbacks below, once no adopter pins
111
+ * `@12-apps/prisma` < 5.0.0 (both known consumers pin exact versions).
112
+ */
113
+ const LEGACY_CLIENT_STORE_KEY = node_buffer_1.Buffer.from('X19mdXR1cmVQYXlQcmlzbWE=', 'base64').toString();
114
+ const LEGACY_CLIENT_INIT_KEY = `${LEGACY_CLIENT_STORE_KEY}Init`;
92
115
  const globalStore = globalThis;
116
+ /** The live client, under the current key or a pre-5.0.0 copy's. */
117
+ const currentClient = () => (globalStore[exports.DEFAULT_CLIENT_STORE_KEY] ?? globalStore[LEGACY_CLIENT_STORE_KEY]);
118
+ /** The in-flight init, under the current key or a pre-5.0.0 copy's. */
119
+ const currentInit = () => (globalStore[exports.DEFAULT_CLIENT_INIT_KEY] ?? globalStore[LEGACY_CLIENT_INIT_KEY]);
120
+ /** Write (or clear) the client under BOTH keys, so either copy finds it. */
121
+ const storeClient = (client) => {
122
+ globalStore[exports.DEFAULT_CLIENT_STORE_KEY] = client;
123
+ globalStore[LEGACY_CLIENT_STORE_KEY] = client;
124
+ };
125
+ /** Write (or clear) the in-flight init under BOTH keys. */
126
+ const storeInit = (init) => {
127
+ globalStore[exports.DEFAULT_CLIENT_INIT_KEY] = init;
128
+ globalStore[LEGACY_CLIENT_INIT_KEY] = init;
129
+ };
93
130
  /**
94
131
  * Prisma log levels.
95
132
  *
@@ -182,25 +219,33 @@ const createPostgresClient = async () => {
182
219
  * module-load failure.
183
220
  */
184
221
  const getPrismaClient = async () => {
185
- if (globalStore.__futurePayPrisma)
186
- return globalStore.__futurePayPrisma;
187
- if (globalStore.__futurePayPrismaInit)
188
- return globalStore.__futurePayPrismaInit;
222
+ const existing = currentClient();
223
+ if (existing) {
224
+ // Found under either key (a pre-5.0.0 copy may have created it): make sure
225
+ // both names point at it before answering, so neither copy re-creates.
226
+ storeClient(existing);
227
+ return existing;
228
+ }
229
+ const inFlight = currentInit();
230
+ if (inFlight) {
231
+ storeInit(inFlight);
232
+ return inFlight;
233
+ }
189
234
  const pglite = resolvePglite();
190
235
  const init = (pglite ? createPgliteClient(pglite.dataDir) : createPostgresClient())
191
236
  .then((client) => {
192
- globalStore.__futurePayPrisma = client;
193
- globalStore.__futurePayPrismaInit = undefined;
237
+ storeClient(client);
238
+ storeInit(undefined);
194
239
  return client;
195
240
  })
196
241
  .catch((error) => {
197
- globalStore.__futurePayPrismaInit = undefined;
242
+ storeInit(undefined);
198
243
  throw new Error(`Prisma client not available (${pglite ? 'PGlite' : 'PostgreSQL'} mode). ` +
199
244
  'Run "pnpm --filter @12-apps/prisma prisma generate" from the ' +
200
245
  'monorepo root, or "pnpm prisma generate" from packages/prisma. ' +
201
246
  `Cause: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
202
247
  });
203
- globalStore.__futurePayPrismaInit = init;
248
+ storeInit(init);
204
249
  return init;
205
250
  };
206
251
  exports.getPrismaClient = getPrismaClient;
@@ -208,15 +253,15 @@ exports.getPrismaClient = getPrismaClient;
208
253
  * Set a custom Prisma client instance (for testing).
209
254
  */
210
255
  const setPrismaClient = (client) => {
211
- globalStore.__futurePayPrisma = client;
212
- globalStore.__futurePayPrismaInit = undefined;
256
+ storeClient(client);
257
+ storeInit(undefined);
213
258
  };
214
259
  exports.setPrismaClient = setPrismaClient;
215
260
  /**
216
261
  * Reset the Prisma client instance (for testing).
217
262
  */
218
263
  const resetPrismaClient = () => {
219
- globalStore.__futurePayPrisma = undefined;
220
- globalStore.__futurePayPrismaInit = undefined;
264
+ storeClient(undefined);
265
+ storeInit(undefined);
221
266
  };
222
267
  exports.resetPrismaClient = resetPrismaClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/prisma",
3
- "version": "4.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Prisma host: the multi-file schema folder, the plugin migration seam, and the shared PrismaClient singleton with its audit / append-only extensions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -59,20 +59,20 @@
59
59
  "pglite-prisma-adapter": "0.7.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@12-apps/audit": "^3.0.0",
63
- "@12-apps/entitlements": "^2.0.0",
64
- "@12-apps/entity-lifecycle": "^3.0.0",
62
+ "@12-apps/audit": "^4.1.0",
63
+ "@12-apps/entitlements": "^3.1.0",
64
+ "@12-apps/entity-lifecycle": "^4.0.0",
65
65
  "@12-apps/eslint-config": "^1.20.0",
66
- "@12-apps/jobs": "^3.0.0",
67
- "@12-apps/mcp": "^2.0.0",
68
- "@12-apps/notifications": "^2.0.0",
69
- "@12-apps/onboarding": "^1.20.0",
70
- "@12-apps/payments-backend": "^3.0.0",
66
+ "@12-apps/jobs": "^4.1.0",
67
+ "@12-apps/mcp": "^3.0.0",
68
+ "@12-apps/notifications": "^4.1.0",
69
+ "@12-apps/onboarding": "^2.0.0",
70
+ "@12-apps/payments-backend": "^4.1.0",
71
71
  "@12-apps/product-research": "^2.0.0",
72
- "@12-apps/rbac": "^3.0.0",
73
- "@12-apps/realtime": "^1.19.0",
74
- "@12-apps/report-builder": "^4.0.0",
75
- "@12-apps/shift": "^2.0.0",
72
+ "@12-apps/rbac": "^4.0.0",
73
+ "@12-apps/realtime": "^2.0.0",
74
+ "@12-apps/report-builder": "^5.0.0",
75
+ "@12-apps/shift": "^3.0.0",
76
76
  "@12-apps/typescript-config": "^1.20.0",
77
77
  "@types/node": "^22.10.6",
78
78
  "@vitest/coverage-v8": "^3.2.4",
@@ -5,7 +5,7 @@
5
5
  -- with no foreign key (that is what keeps the package host-agnostic), so a host
6
6
  -- that deletes its tenant row and then sweeps the remaining by-value tables by
7
7
  -- `client_id` could never remove a shift: the RAISE aborted the whole
8
- -- transaction. Future Pay's demo-store reset does exactly that on EVERY deploy,
8
+ -- transaction. The origin host's demo-store reset does exactly that on EVERY deploy,
9
9
  -- so a single shift row would have wedged the deploy step.
10
10
  --
11
11
  -- Deletability is host policy, not a package invariant. What the guard actually
@@ -18,13 +18,13 @@
18
18
  -- Authorization codes are deliberately absent: they are STATELESS signed blobs,
19
19
  -- so there is no table to create and nothing to sweep.
20
20
  --
21
- -- The columns, defaults, indexes and CHECK are future-pay's
21
+ -- The columns, defaults, indexes and CHECK are the origin host's
22
22
  -- `20260713120000_add_oauth_client_refresh`,
23
23
  -- `20260715180000_add_onboarding_state_mcp_connection` (the mcp_connections half
24
24
  -- — the onboarding half belongs to @12-apps/onboarding) and
25
25
  -- `20260720120000_add_mcp_connection_host` verbatim, minus the FK to `users`:
26
26
  -- this package cannot know the name of a host's user table, and a host that has
27
- -- one keeps its own constraint (future-pay's is ON DELETE CASCADE).
27
+ -- one keeps its own constraint (the origin host's is ON DELETE CASCADE).
28
28
  --
29
29
  -- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block for
30
30
  -- the CHECK, which has no IF NOT EXISTS form). That is what makes adoption by a
@@ -35,7 +35,7 @@
35
35
  -- the difference bites exactly the host this file is written for: `CREATE TABLE IF
36
36
  -- NOT EXISTS` skips the whole table, columns included, so a host holding an OLDER
37
37
  -- shape of one of these tables silently keeps it. Each table below is therefore
38
- -- followed by a guarded `ADD COLUMN` for every column that reached future-pay in a
38
+ -- followed by a guarded `ADD COLUMN` for every column that reached the origin host in a
39
39
  -- LATER migration than its own CREATE. The full audit: `oauth_refresh_tokens
40
40
  -- .user_sub` (`20260713150000_add_oauth_refresh_user_sub`) and `mcp_connections
41
41
  -- .host` (`20260720120000_add_mcp_connection_host`). `oauth_clients` needs none —
@@ -112,10 +112,10 @@ CREATE INDEX IF NOT EXISTS "oauth_refresh_tokens_user_email_client_id_idx"
112
112
  -- `CREATE TABLE IF NOT EXISTS` skips the WHOLE table, so a host that already holds
113
113
  -- `oauth_refresh_tokens` in an OLDER SHAPE gets none of the columns declared above
114
114
  -- — statement-level guarding is not the same as column-level guarding. That is
115
- -- precisely how future-pay's own history ran: `user_sub` arrived in a SECOND
115
+ -- precisely how the origin host's own history ran: `user_sub` arrived in a SECOND
116
116
  -- migration (FUT-105, `20260713150000_add_oauth_refresh_user_sub`), so a host
117
117
  -- frozen before it would adopt this file, skip the CREATE, never get the column,
118
- -- and then fail on every refresh the package serves. Mirror future-pay's pair
118
+ -- and then fail on every refresh the package serves. Mirror the origin host's pair
119
119
  -- verbatim — guarded add with a backfill default to satisfy NOT NULL, then drop
120
120
  -- the default so the column matches the Prisma schema (`String`, no default).
121
121
  -- Both statements are no-ops on a fresh host and on a replay.
@@ -147,6 +147,6 @@ CREATE INDEX IF NOT EXISTS "mcp_connections_last_active_at_idx"
147
147
  ON "mcp_connections"("last_active_at");
148
148
 
149
149
  -- A host adopting this migration where `mcp_connections` predates the `host`
150
- -- column (future-pay added it in a later migration) gets it here; a fresh host
150
+ -- column (the origin host added it in a later migration) gets it here; a fresh host
151
151
  -- already has it from the CREATE above, so the guard makes both cases a no-op.
152
152
  ALTER TABLE "mcp_connections" ADD COLUMN IF NOT EXISTS "host" TEXT;
@@ -2,13 +2,13 @@
2
2
  -- the package and copied into a host's migrations folder by its
3
3
  -- plugin-migration sync.
4
4
  --
5
- -- The columns, defaults, indexes and the status CHECK are future-pay's
5
+ -- The columns, defaults, indexes and the status CHECK are the origin host's
6
6
  -- `20260715180000_add_onboarding_state_mcp_connection` verbatim, minus two
7
7
  -- things that are the HOST's vocabulary rather than the package's:
8
8
  --
9
9
  -- * the FKs to `users` / `clients` — this package cannot know the name of a
10
10
  -- host's user or tenant table. A host that has them keeps its own
11
- -- constraints (future-pay's are ON DELETE CASCADE) and they stay
11
+ -- constraints (the origin host's are ON DELETE CASCADE) and they stay
12
12
  -- compatible with everything the package writes;
13
13
  -- * the `mcp_connections` half of that migration, which belongs to
14
14
  -- @12-apps/mcp and ships in ITS folder.
@@ -16,7 +16,7 @@
16
16
  -- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block
17
17
  -- for the CHECK, which has no IF NOT EXISTS form). That is what makes adoption
18
18
  -- by a host that ALREADY has the table a no-op instead of a failed deploy —
19
- -- future-pay applies this and nothing changes, no `prisma migrate resolve`
19
+ -- the origin host applies this and nothing changes, no `prisma migrate resolve`
20
20
  -- dance required. It is also what lets the PGlite provisioner replay it into an
21
21
  -- existing schema, which is how the harness and the integration suites run.
22
22
 
@@ -24,7 +24,7 @@
24
24
  --
25
25
  -- ============================ REPLAY SAFETY ================================
26
26
  -- Every statement is guarded, because the first adopters ALREADY HAVE these
27
- -- tables: future-pay created them by hand before the package existed, so this
27
+ -- tables: the origin host created them by hand before the package existed, so this
28
28
  -- migration must be a no-op there and correct on an empty database.
29
29
  --
30
30
  -- The guards are per COLUMN, not per table. `CREATE TABLE IF NOT EXISTS` alone
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // Host-agnostic by design (the entity-lifecycle / rbac doctrine): `user_id` is a
13
13
  // by-value scalar with NO relation, because this package cannot know the name of
14
- // the host's user model. The host's own migration may add the FK (future-pay's
14
+ // the host's user model. The host's own migration may add the FK (the origin host's
15
15
  // is ON DELETE CASCADE). Note there is deliberately no `oauth_codes` table:
16
16
  // authorization codes are STATELESS signed blobs, so there is nothing to store
17
17
  // and nothing to sweep.
@@ -22,7 +22,7 @@
22
22
  // and for a stronger reason: the category set is HOST vocabulary (`categories`
23
23
  // on the server config), so a closed set in the schema would be wrong for every
24
24
  // adopter but the first. A host that wants its own taxonomy enforced adds the
25
- // CHECK in a migration of its own — future-pay does. Only `channel` and `status`
25
+ // CHECK in a migration of its own — the origin host does. Only `channel` and `status`
26
26
  // on the delivery row are closed here, because those two are the LIBRARY's.
27
27
  model Notification {
28
28
  id String @id @default(uuid())
@@ -12,7 +12,7 @@
12
12
  // Host-agnostic by design (the entity-lifecycle / report-builder doctrine):
13
13
  // - `user_id` and `client_id` are by-value scalars, NOT relations — this
14
14
  // package cannot know the name of the host's user or tenant model. The
15
- // host's own migration may add the FK constraints (future-pay has both,
15
+ // host's own migration may add the FK constraints (the origin host has both,
16
16
  // ON DELETE CASCADE).
17
17
  // - `status` is a String with a DB CHECK rather than a Prisma enum (the
18
18
  // house String+CHECK pattern); the four values are the package's own
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { AsyncLocalStorage } from 'node:async_hooks';
24
+ import { Buffer } from 'node:buffer';
24
25
 
25
26
  /** Role/scope authority attribution a caller may STAMP (FUT-152). */
26
27
  export interface ActorAttribution {
@@ -92,12 +93,103 @@ export interface ActorContext extends ActorAttributionSnapshot {
92
93
  // Kept on globalThis so Next dev / Turbopack hot-reload (which re-evaluates this
93
94
  // module) can't create a second store whose context is invisible to closures
94
95
  // captured against the first.
95
- const globalStore = globalThis as unknown as {
96
- __futurePayActorStore?: AsyncLocalStorage<ActorContext>;
96
+ //
97
+ // The KEY it lives under is a cross-package CONTRACT, not a private detail:
98
+ // `@12-apps/audit` ships its own copy of this module, and a host that routes
99
+ // audit writes through that package while stamping actors through this one
100
+ // needs both copies on ONE store (see `declareActorContextKey` there, and the
101
+ // interop suite in `tests/`). If the two disagree they get two separate
102
+ // AsyncLocalStorage instances and the failure is SILENT: audit writes rows
103
+ // with every attribution column NULL while this package believes a context is
104
+ // set — on an append-only table, so the attribution is gone for good.
105
+ const globalStore = globalThis as unknown as Record<
106
+ string | symbol,
107
+ AsyncLocalStorage<ActorContext> | undefined
108
+ >;
109
+
110
+ /**
111
+ * The `globalThis` key this package keeps its actor store under, exported so a
112
+ * host (or the audit package's `declareActorContextKey`) can name it without
113
+ * retyping the literal. The same shape `@12-apps/audit` exports as its
114
+ * `DEFAULT_ACTOR_STORE_KEY`.
115
+ */
116
+ export const DEFAULT_ACTOR_STORE_KEY = '__12appsPrismaActorStore';
117
+
118
+ /**
119
+ * The key releases before 5.0.0 used — the host-branded name 5.0.0 renamed
120
+ * away, decoded from base64 at runtime: even a split spelling of the name
121
+ * counts as a mention (the per-package and repo-wide brand gates both sweep
122
+ * this file), so the only representation shipped source may hold is one no
123
+ * grep for the brand can see.
124
+ *
125
+ * It is still READ (and mirrored, below) for exactly one reason: a process
126
+ * that mixes this copy with a pre-5.0.0 copy of this package — or whose audit
127
+ * store was declared against the old name — would otherwise fork the store,
128
+ * which is the silent NULL-attribution failure described above.
129
+ *
130
+ * DELETE in 6.0.0, together with the adopt/mirror branches in `store()`, once
131
+ * no adopter pins `@12-apps/prisma` < 5.0.0. Both known consumers pin exact
132
+ * versions, so the check is one grep over their lockfiles.
133
+ */
134
+ const LEGACY_ACTOR_STORE_KEY = Buffer.from('X19mdXR1cmVQYXlBY3RvclN0b3Jl', 'base64').toString();
135
+
136
+ /** The key in force, and the key the live store (if any) was created under. */
137
+ const storeKey: { declared: string | symbol; created?: string | symbol } = {
138
+ declared: DEFAULT_ACTOR_STORE_KEY,
97
139
  };
98
140
 
99
- const store = (): AsyncLocalStorage<ActorContext> =>
100
- (globalStore.__futurePayActorStore ??= new AsyncLocalStorage<ActorContext>());
141
+ /**
142
+ * Point this package's actor context at an existing store, by naming its
143
+ * `globalThis` key — the same seam (same name, same rules) as
144
+ * `@12-apps/audit`'s `declareActorContextKey`, so a host with an in-house
145
+ * actor module can put all three on one store with two identical calls.
146
+ *
147
+ * Call it ONCE, at wiring time, before anything stamps or reads an actor.
148
+ * Changing the key after the store exists is REFUSED rather than honoured:
149
+ * contexts already captured against the old instance would keep flowing to it
150
+ * while every later read went elsewhere — the silent fork this seam exists to
151
+ * prevent. Passing the key already in force is a no-op, so a defensive
152
+ * module-scope declaration is safe to load twice.
153
+ */
154
+ export function declareActorContextKey(key: string | symbol): void {
155
+ if (typeof key === 'string' && key.trim() === '') {
156
+ throw new Error('actor context key must not be blank.');
157
+ }
158
+ if (key === storeKey.declared) return;
159
+ if (storeKey.created !== undefined) {
160
+ throw new Error(
161
+ `actor context key cannot change to ${String(key)}: the store already exists under ` +
162
+ `${String(storeKey.created)}. Declare the key once, before anything stamps an actor — ` +
163
+ 'moving it later forks the store, and a forked store loses every attribution ' +
164
+ 'silently onto an append-only table.',
165
+ );
166
+ }
167
+ storeKey.declared = key;
168
+ }
169
+
170
+ /** The key the store is (or would be) created under — diagnostics and tests. */
171
+ export const actorContextKey = (): string | symbol => storeKey.declared;
172
+
173
+ const store = (): AsyncLocalStorage<ActorContext> => {
174
+ const key = storeKey.declared;
175
+ let instance = globalStore[key];
176
+ if (instance === undefined && key === DEFAULT_ACTOR_STORE_KEY) {
177
+ // A pre-5.0.0 copy of this package already created the store under the
178
+ // old name: ADOPT it rather than fork it. One instance, two keys.
179
+ instance = globalStore[LEGACY_ACTOR_STORE_KEY];
180
+ if (instance !== undefined) globalStore[key] = instance;
181
+ }
182
+ if (instance === undefined) {
183
+ instance = new AsyncLocalStorage<ActorContext>();
184
+ globalStore[key] = instance;
185
+ // MIRROR under the old name so a pre-5.0.0 copy loaded after this one
186
+ // finds this store instead of creating its own. Only for the default key:
187
+ // a host that declared its own key has opted out of this package's names.
188
+ if (key === DEFAULT_ACTOR_STORE_KEY) globalStore[LEGACY_ACTOR_STORE_KEY] = instance;
189
+ }
190
+ storeKey.created = key;
191
+ return instance;
192
+ };
101
193
 
102
194
  /**
103
195
  * The REAL human behind `onBehalfOfUserId`, derived (never accepted) from the
package/src/index.ts CHANGED
@@ -26,6 +26,8 @@
26
26
  // Re-export the generated client type.
27
27
  export type { PrismaClient } from '@prisma/client';
28
28
 
29
+ import { Buffer } from 'node:buffer';
30
+
29
31
  import type { Prisma, PrismaClient } from '@prisma/client';
30
32
 
31
33
  import { applyAppendOnlyGuard } from './append-only-extension';
@@ -47,6 +49,9 @@ export { configureAuditStamps, auditStampConfig, type AuditStampConfig } from '.
47
49
  // stamp created_by/updated_by. Re-exported here so consumers import them from
48
50
  // the same `@12-apps/prisma` entry point as `getPrismaClient`.
49
51
  export {
52
+ actorContextKey,
53
+ DEFAULT_ACTOR_STORE_KEY,
54
+ declareActorContextKey,
50
55
  getActorAttribution,
51
56
  getActorUserId,
52
57
  runWithActor,
@@ -62,9 +67,52 @@ export { normalizeSearchText } from './search-normalize';
62
67
  // Next dev / Turbopack hot-reload (which re-evaluates this module) never spawns
63
68
  // a second PGlite instance against the same dataDir — PGlite holds a single
64
69
  // exclusive connection, and a duplicate would deadlock or corrupt the store.
65
- const globalStore = globalThis as unknown as {
66
- __futurePayPrisma?: PrismaClient;
67
- __futurePayPrismaInit?: Promise<PrismaClient>;
70
+
71
+ /**
72
+ * The `globalThis` keys the client singleton lives under, exported as named
73
+ * constants because they are cross-module-instance coordination surface (two
74
+ * copies of this package in one process share the client through them), not a
75
+ * private detail.
76
+ */
77
+ export const DEFAULT_CLIENT_STORE_KEY = '__12appsPrisma';
78
+ export const DEFAULT_CLIENT_INIT_KEY = '__12appsPrismaInit';
79
+
80
+ /**
81
+ * The keys releases before 5.0.0 used — the host-branded names 5.0.0 renamed
82
+ * away, decoded from base64 at runtime so no spelling of the brand — whole or
83
+ * split — appears in shipped source.
84
+ * Still read and mirrored so a process mixing this copy with a pre-5.0.0 copy
85
+ * shares ONE client instead of racing two PGlite instances on one dataDir.
86
+ * DELETE in 6.0.0, with the fallbacks below, once no adopter pins
87
+ * `@12-apps/prisma` < 5.0.0 (both known consumers pin exact versions).
88
+ */
89
+ const LEGACY_CLIENT_STORE_KEY = Buffer.from('X19mdXR1cmVQYXlQcmlzbWE=', 'base64').toString();
90
+ const LEGACY_CLIENT_INIT_KEY = `${LEGACY_CLIENT_STORE_KEY}Init`;
91
+
92
+ const globalStore = globalThis as unknown as Record<string, unknown>;
93
+
94
+ /** The live client, under the current key or a pre-5.0.0 copy's. */
95
+ const currentClient = (): PrismaClient | undefined =>
96
+ (globalStore[DEFAULT_CLIENT_STORE_KEY] ?? globalStore[LEGACY_CLIENT_STORE_KEY]) as
97
+ | PrismaClient
98
+ | undefined;
99
+
100
+ /** The in-flight init, under the current key or a pre-5.0.0 copy's. */
101
+ const currentInit = (): Promise<PrismaClient> | undefined =>
102
+ (globalStore[DEFAULT_CLIENT_INIT_KEY] ?? globalStore[LEGACY_CLIENT_INIT_KEY]) as
103
+ | Promise<PrismaClient>
104
+ | undefined;
105
+
106
+ /** Write (or clear) the client under BOTH keys, so either copy finds it. */
107
+ const storeClient = (client: PrismaClient | undefined): void => {
108
+ globalStore[DEFAULT_CLIENT_STORE_KEY] = client;
109
+ globalStore[LEGACY_CLIENT_STORE_KEY] = client;
110
+ };
111
+
112
+ /** Write (or clear) the in-flight init under BOTH keys. */
113
+ const storeInit = (init: Promise<PrismaClient> | undefined): void => {
114
+ globalStore[DEFAULT_CLIENT_INIT_KEY] = init;
115
+ globalStore[LEGACY_CLIENT_INIT_KEY] = init;
68
116
  };
69
117
 
70
118
  /**
@@ -196,20 +244,30 @@ const createPostgresClient = async (): Promise<PrismaClient> => {
196
244
  * module-load failure.
197
245
  */
198
246
  export const getPrismaClient = async (): Promise<PrismaClient> => {
199
- if (globalStore.__futurePayPrisma) return globalStore.__futurePayPrisma;
200
- if (globalStore.__futurePayPrismaInit) return globalStore.__futurePayPrismaInit;
247
+ const existing = currentClient();
248
+ if (existing) {
249
+ // Found under either key (a pre-5.0.0 copy may have created it): make sure
250
+ // both names point at it before answering, so neither copy re-creates.
251
+ storeClient(existing);
252
+ return existing;
253
+ }
254
+ const inFlight = currentInit();
255
+ if (inFlight) {
256
+ storeInit(inFlight);
257
+ return inFlight;
258
+ }
201
259
 
202
260
  const pglite = resolvePglite();
203
261
  const init = (
204
262
  pglite ? createPgliteClient(pglite.dataDir) : createPostgresClient()
205
263
  )
206
264
  .then((client) => {
207
- globalStore.__futurePayPrisma = client;
208
- globalStore.__futurePayPrismaInit = undefined;
265
+ storeClient(client);
266
+ storeInit(undefined);
209
267
  return client;
210
268
  })
211
269
  .catch((error: unknown) => {
212
- globalStore.__futurePayPrismaInit = undefined;
270
+ storeInit(undefined);
213
271
  throw new Error(
214
272
  `Prisma client not available (${pglite ? 'PGlite' : 'PostgreSQL'} mode). ` +
215
273
  'Run "pnpm --filter @12-apps/prisma prisma generate" from the ' +
@@ -219,7 +277,7 @@ export const getPrismaClient = async (): Promise<PrismaClient> => {
219
277
  );
220
278
  });
221
279
 
222
- globalStore.__futurePayPrismaInit = init;
280
+ storeInit(init);
223
281
  return init;
224
282
  };
225
283
 
@@ -227,14 +285,14 @@ export const getPrismaClient = async (): Promise<PrismaClient> => {
227
285
  * Set a custom Prisma client instance (for testing).
228
286
  */
229
287
  export const setPrismaClient = (client: PrismaClient): void => {
230
- globalStore.__futurePayPrisma = client;
231
- globalStore.__futurePayPrismaInit = undefined;
288
+ storeClient(client);
289
+ storeInit(undefined);
232
290
  };
233
291
 
234
292
  /**
235
293
  * Reset the Prisma client instance (for testing).
236
294
  */
237
295
  export const resetPrismaClient = (): void => {
238
- globalStore.__futurePayPrisma = undefined;
239
- globalStore.__futurePayPrismaInit = undefined;
296
+ storeClient(undefined);
297
+ storeInit(undefined);
240
298
  };