@12-apps/prisma 5.0.0 → 6.0.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.__12appsPrismaActorStore ??= 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.__12appsPrisma)
186
- return globalStore.__12appsPrisma;
187
- if (globalStore.__12appsPrismaInit)
188
- return globalStore.__12appsPrismaInit;
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.__12appsPrisma = client;
193
- globalStore.__12appsPrismaInit = undefined;
237
+ storeClient(client);
238
+ storeInit(undefined);
194
239
  return client;
195
240
  })
196
241
  .catch((error) => {
197
- globalStore.__12appsPrismaInit = 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.__12appsPrismaInit = 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.__12appsPrisma = client;
212
- globalStore.__12appsPrismaInit = 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.__12appsPrisma = undefined;
220
- globalStore.__12appsPrismaInit = 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": "5.0.0",
3
+ "version": "6.0.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",
@@ -20,7 +20,7 @@
20
20
  "lint": "pnpm run lint:files .",
21
21
  "lint:fix": "bash -c 'eslint \"${@:-.}\" --max-warnings 0 --fix' _",
22
22
  "typecheck": "tsc --noEmit",
23
- "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
23
+ "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-auth-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
24
24
  "prisma:migrate": "prisma migrate dev",
25
25
  "prisma:push": "prisma db push",
26
26
  "prisma:studio": "prisma studio",
@@ -49,7 +49,9 @@
49
49
  "prisma:sync-realtime": "node scripts/sync-realtime-schema.mjs",
50
50
  "prisma:sync-realtime:check": "node scripts/sync-realtime-schema.mjs --check",
51
51
  "prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
52
- "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check"
52
+ "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check",
53
+ "prisma:sync-auth": "node scripts/sync-auth-schema.mjs",
54
+ "prisma:sync-auth:check": "node scripts/sync-auth-schema.mjs --check"
53
55
  },
54
56
  "dependencies": {
55
57
  "@electric-sql/pglite": "0.2.17",
@@ -59,20 +61,21 @@
59
61
  "pglite-prisma-adapter": "0.7.2"
60
62
  },
61
63
  "devDependencies": {
62
- "@12-apps/audit": "^4.0.0",
63
- "@12-apps/entitlements": "^3.0.0",
64
- "@12-apps/entity-lifecycle": "^4.0.0",
64
+ "@12-apps/audit": "^5.0.1",
65
+ "@12-apps/auth": "^2.0.0",
66
+ "@12-apps/entitlements": "^3.1.0",
67
+ "@12-apps/entity-lifecycle": "^4.2.0",
65
68
  "@12-apps/eslint-config": "^1.20.0",
66
- "@12-apps/jobs": "^4.0.0",
67
- "@12-apps/mcp": "^3.0.0",
68
- "@12-apps/notifications": "^4.0.0",
69
- "@12-apps/onboarding": "^2.0.0",
70
- "@12-apps/payments-backend": "^4.0.0",
69
+ "@12-apps/jobs": "^4.2.0",
70
+ "@12-apps/mcp": "^3.4.0",
71
+ "@12-apps/notifications": "^4.1.1",
72
+ "@12-apps/onboarding": "^2.0.1",
73
+ "@12-apps/payments-backend": "^4.11.0",
71
74
  "@12-apps/product-research": "^2.0.0",
72
- "@12-apps/rbac": "^4.0.0",
75
+ "@12-apps/rbac": "^4.0.2",
73
76
  "@12-apps/realtime": "^2.0.0",
74
- "@12-apps/report-builder": "^5.0.0",
75
- "@12-apps/shift": "^3.0.0",
77
+ "@12-apps/report-builder": "^5.0.2",
78
+ "@12-apps/shift": "^3.0.1",
76
79
  "@12-apps/typescript-config": "^1.20.0",
77
80
  "@types/node": "^22.10.6",
78
81
  "@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. The origin host's demo-store reset does exactly that on EVERY deploy,
8
+ -- transaction. Future Pay'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 the origin host's
21
+ -- The columns, defaults, indexes and CHECK are future-pay'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 (the origin host's is ON DELETE CASCADE).
27
+ -- one keeps its own constraint (future-pay'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 the origin host in a
38
+ -- followed by a guarded `ADD COLUMN` for every column that reached future-pay 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 the origin host's own history ran: `user_sub` arrived in a SECOND
115
+ -- precisely how future-pay'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 the origin host's pair
118
+ -- and then fail on every refresh the package serves. Mirror future-pay'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 (the origin host added it in a later migration) gets it here; a fresh host
150
+ -- column (future-pay 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 the origin host's
5
+ -- The columns, defaults, indexes and the status CHECK are future-pay'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 (the origin host's are ON DELETE CASCADE) and they stay
11
+ -- constraints (future-pay'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
- -- the origin host applies this and nothing changes, no `prisma migrate resolve`
19
+ -- future-pay 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: the origin host created them by hand before the package existed, so this
27
+ -- tables: future-pay 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
@@ -0,0 +1,58 @@
1
+ -- @12-apps/auth's own tables. Self-contained by design: every user reference is
2
+ -- an opaque `user_id` with NO foreign key into a host table, so this migration
3
+ -- applies to any schema regardless of what the host calls its users.
4
+
5
+ CREATE TABLE IF NOT EXISTS "auth_credentials" (
6
+ "user_id" TEXT NOT NULL,
7
+ "password_hash" TEXT,
8
+ "password_updated_at" TIMESTAMP(3),
9
+ "email_verified_at" TIMESTAMP(3),
10
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+
13
+ CONSTRAINT "auth_credentials_pkey" PRIMARY KEY ("user_id")
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS "auth_tokens" (
17
+ "id" TEXT NOT NULL,
18
+ "user_id" TEXT NOT NULL,
19
+ "purpose" TEXT NOT NULL,
20
+ "token_hash" TEXT NOT NULL,
21
+ "expires_at" TIMESTAMP(3) NOT NULL,
22
+ "consumed_at" TIMESTAMP(3),
23
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
24
+
25
+ CONSTRAINT "auth_tokens_pkey" PRIMARY KEY ("id")
26
+ );
27
+
28
+ -- The purposes, as a CHECK rather than a Prisma enum (house style). A token of
29
+ -- one purpose presented to the other endpoint must read as "no such token".
30
+ ALTER TABLE "auth_tokens"
31
+ ADD CONSTRAINT "auth_tokens_purpose_check"
32
+ CHECK ("purpose" IN ('EMAIL_VERIFICATION', 'PASSWORD_RESET'));
33
+
34
+ -- Unique because the hash IS the lookup key: a collision would mean two
35
+ -- accounts sharing one link.
36
+ CREATE UNIQUE INDEX IF NOT EXISTS "auth_tokens_token_hash_key" ON "auth_tokens"("token_hash");
37
+ CREATE INDEX IF NOT EXISTS "auth_tokens_user_id_purpose_idx" ON "auth_tokens"("user_id", "purpose");
38
+ CREATE INDEX IF NOT EXISTS "auth_tokens_expires_at_idx" ON "auth_tokens"("expires_at");
39
+
40
+ CREATE TABLE IF NOT EXISTS "auth_platform_settings" (
41
+ "key" TEXT NOT NULL,
42
+ "value" JSONB NOT NULL,
43
+ "updated_by" TEXT,
44
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
45
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+
47
+ CONSTRAINT "auth_platform_settings_pkey" PRIMARY KEY ("key")
48
+ );
49
+
50
+ -- The two switches, seeded to the posture this ships in: the method is OFF
51
+ -- until an operator turns it on, and verification is ON so that turning the
52
+ -- method on cannot accidentally open unverified registration. Both are the
53
+ -- conservative half of their own trade-off.
54
+ INSERT INTO "auth_platform_settings" ("key", "value", "updated_at")
55
+ VALUES
56
+ ('auth.email_password.enabled', 'false'::jsonb, CURRENT_TIMESTAMP),
57
+ ('auth.email_password.require_verification', 'true'::jsonb, CURRENT_TIMESTAMP)
58
+ ON CONFLICT ("key") DO NOTHING;
@@ -31,6 +31,7 @@
31
31
  "20260813120000_add_audit_log",
32
32
  "20260813120000_add_entity_lifecycle_tables",
33
33
  "20260813140000_add_notification_tables",
34
- "20260813180000_add_realtime_outbox"
34
+ "20260813180000_add_realtime_outbox",
35
+ "20260819230000_auth_email_password"
35
36
  ]
36
37
  }
@@ -0,0 +1,90 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // OWNED BY @12-apps/auth — the host's multi-file schema folder holds a COPY of
3
+ // this file, written by `pnpm --filter @12-apps/auth prisma:sync`. The
4
+ // credential persistence model lives here, in the package, never in the
5
+ // application.
6
+ //
7
+ // COPIED, never symlinked (the entity-lifecycle doctrine): `npm pack` silently
8
+ // drops symlinked entries, `turbo prune` dangles a link whose owner is not a
9
+ // declared dependency, and Prisma silently SKIPS a symlinked migration
10
+ // directory — a green deploy that applied no schema.
11
+ //
12
+ // Deliberately self-contained (the payments-backend doctrine, and the same rule
13
+ // `report-builder.prisma` states): every user reference is a `user_id` String
14
+ // column — NO foreign key into any host table — so these models work in a repo
15
+ // whose user table is called something else, or is in another database, or does
16
+ // not exist yet at migrate time.
17
+ //
18
+ // That constraint is why the three credential columns are a TABLE here rather
19
+ // than columns on the host's `users`, which is where they started. A package
20
+ // cannot add columns to a table it does not own, and a host should not have to
21
+ // hand-edit its own model to install a login flow.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ /// One account's password state, keyed by the host's user id as an OPAQUE
25
+ /// scalar. A row exists only once the address has a credential — an account
26
+ /// that has only ever signed in with Google has none, which is exactly the
27
+ /// "no password yet" state the security card reads.
28
+ model AuthCredential {
29
+ /// The host's user id. No relation by design: a stale id simply matches no
30
+ /// row, which is the same answer a deleted user should produce.
31
+ userId String @id @map("user_id")
32
+ /// `@12-apps/auth`'s self-describing scrypt string. Nullable because a row
33
+ /// may be created by verification before any password is set.
34
+ passwordHash String? @map("password_hash")
35
+ passwordUpdatedAt DateTime? @map("password_updated_at")
36
+ /// Answers "has this address been proven to belong to its owner", which is
37
+ /// not the same question as "does this account have a password".
38
+ emailVerifiedAt DateTime? @map("email_verified_at")
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime @updatedAt @map("updated_at")
41
+
42
+ @@map("auth_credentials")
43
+ }
44
+
45
+ /// A single-use link: e-mail confirmation, or password reset.
46
+ ///
47
+ /// Only the HASH is stored. The raw token exists in the mail and nowhere else,
48
+ /// so a database read cannot mint a working link.
49
+ model AuthToken {
50
+ id String @id @default(uuid())
51
+ /// The host's user id, opaque. See the header — no FK.
52
+ userId String @map("user_id")
53
+ /// EMAIL_VERIFICATION | PASSWORD_RESET — a String + DB CHECK, the house style
54
+ /// used elsewhere rather than a Prisma enum. The two purposes never share a
55
+ /// namespace: a verification token presented to the reset endpoint is not a
56
+ /// token at all.
57
+ purpose String
58
+ /// SHA-256 of the raw token, lower-case hex. Unique because it IS the lookup
59
+ /// key, and a collision would mean two accounts sharing one link.
60
+ tokenHash String @unique @map("token_hash")
61
+ expiresAt DateTime @map("expires_at")
62
+ consumedAt DateTime? @map("consumed_at")
63
+ createdAt DateTime @default(now()) @map("created_at")
64
+
65
+ /// The sweep-on-password-change query, and the resend path's "does this user
66
+ /// already have one".
67
+ @@index([userId, purpose])
68
+ /// The retention job drops abandoned rows cheaply.
69
+ @@index([expiresAt])
70
+ @@map("auth_tokens")
71
+ }
72
+
73
+ /// Platform-wide switches, read on every sign-in attempt.
74
+ ///
75
+ /// Prefixed `auth_` rather than the bare `platform_settings` it started as: a
76
+ /// host may well own a settings table of its own, and a package must not claim
77
+ /// a name that generic. Deliberately NOT tenant-scoped — sign-in happens before
78
+ /// a tenant is known.
79
+ model AuthPlatformSetting {
80
+ key String @id
81
+ value Json
82
+ /// Who last changed it, by e-mail, for the audit trail the settings screen
83
+ /// shows. A plain string rather than a relation: the person may be an env
84
+ /// allowlist superadmin with no user row of their own.
85
+ updatedBy String? @map("updated_by")
86
+ updatedAt DateTime @updatedAt @map("updated_at")
87
+ createdAt DateTime @default(now()) @map("created_at")
88
+
89
+ @@map("auth_platform_settings")
90
+ }
@@ -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
- __12appsPrismaActorStore?: 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.__12appsPrismaActorStore ??= 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
- __12appsPrisma?: PrismaClient;
67
- __12appsPrismaInit?: 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.__12appsPrisma) return globalStore.__12appsPrisma;
200
- if (globalStore.__12appsPrismaInit) return globalStore.__12appsPrismaInit;
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.__12appsPrisma = client;
208
- globalStore.__12appsPrismaInit = undefined;
265
+ storeClient(client);
266
+ storeInit(undefined);
209
267
  return client;
210
268
  })
211
269
  .catch((error: unknown) => {
212
- globalStore.__12appsPrismaInit = 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.__12appsPrismaInit = 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.__12appsPrisma = client;
231
- globalStore.__12appsPrismaInit = 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.__12appsPrisma = undefined;
239
- globalStore.__12appsPrismaInit = undefined;
296
+ storeClient(undefined);
297
+ storeInit(undefined);
240
298
  };