@stratal/framework 0.0.27 → 0.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +596 -0
  2. package/README.md +166 -24
  3. package/dist/access-control/index.d.mts +43 -16
  4. package/dist/access-control/index.d.mts.map +1 -1
  5. package/dist/access-control/index.mjs +5 -5
  6. package/dist/access-control/index.mjs.map +1 -1
  7. package/dist/{access.service-BmDhE-re.mjs → access.service-BjmnWBEo.mjs} +35 -17
  8. package/dist/access.service-BjmnWBEo.mjs.map +1 -0
  9. package/dist/auth/index.d.mts +105 -103
  10. package/dist/auth/index.d.mts.map +1 -1
  11. package/dist/auth/index.mjs +123 -24
  12. package/dist/auth/index.mjs.map +1 -1
  13. package/dist/{auth-context-CGVbiSX3.d.mts → auth-context-C1om3Zsr.d.mts} +1 -2
  14. package/dist/auth-context-C1om3Zsr.d.mts.map +1 -0
  15. package/dist/{auth-context-C8NBfiMa.mjs → auth-context-cNSS1rmh.mjs} +2 -2
  16. package/dist/{auth-context-C8NBfiMa.mjs.map → auth-context-cNSS1rmh.mjs.map} +1 -1
  17. package/dist/auth.service-Onf3JkyL.d.mts +44 -0
  18. package/dist/auth.service-Onf3JkyL.d.mts.map +1 -0
  19. package/dist/context/index.d.mts +4 -5
  20. package/dist/context/index.d.mts.map +1 -1
  21. package/dist/context/index.mjs +1 -1
  22. package/dist/database/index.d.mts +3 -3
  23. package/dist/database/index.mjs +413 -34
  24. package/dist/database/index.mjs.map +1 -1
  25. package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
  26. package/dist/{decorateParam-DwV9LSPl.mjs → decorateParam-xwTkq9gO.mjs} +2 -2
  27. package/dist/{decorateParam-DwV9LSPl.mjs.map → decorateParam-xwTkq9gO.mjs.map} +1 -1
  28. package/dist/factory/index.d.mts +3 -5
  29. package/dist/factory/index.d.mts.map +1 -1
  30. package/dist/factory/index.mjs.map +1 -1
  31. package/dist/guards/index.d.mts +3 -4
  32. package/dist/guards/index.d.mts.map +1 -1
  33. package/dist/guards/index.mjs +4 -4
  34. package/dist/guards/index.mjs.map +1 -1
  35. package/dist/index-e_u1SRyd.d.mts +921 -0
  36. package/dist/index-e_u1SRyd.d.mts.map +1 -0
  37. package/dist/index.d.mts +1 -1
  38. package/dist/{types-CWZ9q74G.d.mts → types-B35g-lXi.d.mts} +18 -4
  39. package/dist/types-B35g-lXi.d.mts.map +1 -0
  40. package/package.json +31 -27
  41. package/dist/access.service-BmDhE-re.mjs.map +0 -1
  42. package/dist/auth-context-CGVbiSX3.d.mts.map +0 -1
  43. package/dist/index-Dt0YUA7r.d.mts +0 -446
  44. package/dist/index-Dt0YUA7r.d.mts.map +0 -1
  45. package/dist/types-CWZ9q74G.d.mts.map +0 -1
  46. package/dist/types-DabF8LGz.d.mts +0 -11
  47. package/dist/types-DabF8LGz.d.mts.map +0 -1
@@ -1,12 +1,13 @@
1
- import { t as __decorate } from "../decorate-B7nr7eBl.mjs";
2
- import { DI_TOKENS, Transient, inject, lazy } from "stratal/di";
1
+ import { t as __decorate } from "../decorate-RQD1h28J.mjs";
2
+ import { DI_TOKENS, Request, inject, lazy } from "stratal/di";
3
3
  import { Module } from "stratal/module";
4
- import { DatabaseError, HttpException } from "stratal/errors";
4
+ import { ApplicationError, DatabaseError, HttpException } from "stratal/errors";
5
5
  import { I18nModule } from "stratal/i18n";
6
6
  import { Command } from "stratal/quarry";
7
7
  import { ORMError, ORMErrorReason, ZenStackClient } from "@zenstackhq/orm";
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
9
- import { withZodI18n, z } from "stratal/validation";
9
+ import { array, custom, looseObject, minLength, object, optional, refine, string } from "zod/mini";
10
+ import { withZodI18n } from "stratal/validation";
10
11
  //#region src/database/commands/zenstack.command.ts
11
12
  /**
12
13
  * Base command for ZenStack CLI wrappers.
@@ -123,6 +124,259 @@ var MigrateStatusCommand = class extends ZenStackCommand {
123
124
  }
124
125
  };
125
126
  //#endregion
127
+ //#region src/database/errors/cursor-model-unavailable.error.ts
128
+ /**
129
+ * Raised when `db.$cursor.<model>` cannot reach a model delegate on the client
130
+ * it was built for — the schema declares the model, the client does not answer
131
+ * for it.
132
+ *
133
+ * This is a **programming error**, not bad input: the reader is built from the
134
+ * schema's own model list, so reaching it means the client and the schema it was
135
+ * given have come apart — a model sliced out of the client's options, or a
136
+ * hand-assembled client. No request recovers from it and no retry helps. It
137
+ * deliberately carries no HTTP status, so it is never mistaken for something the
138
+ * client sent wrong.
139
+ *
140
+ * `model` names the client key at fault, and is reported to observability so the
141
+ * raise site stays distinguishable without matching on message text.
142
+ */
143
+ var CursorModelUnavailableError = class extends ApplicationError {
144
+ model;
145
+ constructor(model) {
146
+ super(`[stratal:database] $cursor cannot reach the "${model}" model on this client. The schema declares it, so the client was built with a different schema or with this model sliced out.`);
147
+ this.model = model;
148
+ }
149
+ reportContext() {
150
+ return { model: this.model };
151
+ }
152
+ };
153
+ //#endregion
154
+ //#region src/database/errors/cursor-ordering.error.ts
155
+ /**
156
+ * Raised when a `$cursor` read is asked for an ordering it cannot address a row
157
+ * in: no `orderBy`, a clause with no `asc`/`desc` direction, an ordering
158
+ * without the unique column that breaks ties, or an ordering column that is
159
+ * absent from the returned row or null on it.
160
+ *
161
+ * This is a **programming error**, not bad input — no request can recover from
162
+ * it and no retry helps, because the query as written cannot produce a stable
163
+ * position. Handlers should let it surface as a `500` and report it; the fix is
164
+ * always in the caller's `orderBy`, `uniqueBy` or `select`. It deliberately
165
+ * carries no HTTP status, so it is never mistaken for something the client sent
166
+ * wrong.
167
+ *
168
+ * `field` names the ordering column at fault where one is identifiable, and is
169
+ * reported to observability so the raise sites stay distinguishable without
170
+ * matching on message text.
171
+ */
172
+ var CursorOrderingError = class extends ApplicationError {
173
+ field;
174
+ constructor(message, field) {
175
+ super(message);
176
+ this.field = field;
177
+ }
178
+ reportContext() {
179
+ return this.field === void 0 ? void 0 : { field: this.field };
180
+ }
181
+ };
182
+ //#endregion
183
+ //#region src/database/errors/malformed-cursor.error.ts
184
+ /**
185
+ * Raised when a pagination cursor cannot be read: it is not the base64url
186
+ * payload `$cursor` mints, or it decodes to something that is not a cursor.
187
+ *
188
+ * This is **bad input**, not a broken query. A cursor travels in a query string,
189
+ * so a truncated link, a hand-edited URL, or one minted by an older build all
190
+ * land here, and the request itself is still answerable. It carries its own
191
+ * `400`, so a handler lets it surface rather than catching it to serve the
192
+ * first page — the same refusal any other damaged parameter gets.
193
+ */
194
+ var MalformedCursorError = class extends HttpException {
195
+ constructor(cause) {
196
+ super(400, "[stratal:database] Malformed pagination cursor.", cause);
197
+ }
198
+ };
199
+ //#endregion
200
+ //#region src/database/pagination/cursor.ts
201
+ const CURSOR_DIRECTION_KEY = "_next";
202
+ /**
203
+ * base64url, so a cursor survives a query string without escaping.
204
+ *
205
+ * The UTF-8 round trip is load-bearing, not ceremony: `btoa` accepts only
206
+ * Latin-1, and `JSON.stringify` leaves non-ASCII characters as they are. An
207
+ * ordering column is whatever the caller ordered by — a `title`, a `name`, a
208
+ * `slug` — so one row whose value carries CJK, Cyrillic or an emoji would throw
209
+ * `DOMException` while every ASCII row encoded fine. The first page would still
210
+ * answer, because it mints no cursor; the 500 would land on the follow-up.
211
+ */
212
+ function base64UrlEncode(input) {
213
+ const bytes = new TextEncoder().encode(input);
214
+ const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
215
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
216
+ }
217
+ function base64UrlDecode(input) {
218
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
219
+ const binary = atob(padded.padEnd(padded.length + (4 - padded.length % 4) % 4, "="));
220
+ return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
221
+ }
222
+ /**
223
+ * Encodes the ordering values of a row plus the direction it points.
224
+ *
225
+ * The whole ordering tuple travels, not a row id, so the cursor still resolves
226
+ * after the row it was built from is deleted — which is the case cursors are
227
+ * for. The direction rides along because a caller sends every cursor under one
228
+ * query parameter name and nothing else distinguishes forward from backward.
229
+ */
230
+ function encodeCursor(values, direction) {
231
+ return base64UrlEncode(JSON.stringify({
232
+ ...values,
233
+ [CURSOR_DIRECTION_KEY]: direction === "next"
234
+ }));
235
+ }
236
+ /** Reads a cursor minted by {@link encodeCursor}. Throws on anything else. */
237
+ function decodeCursor(cursor) {
238
+ let parsed;
239
+ try {
240
+ parsed = JSON.parse(base64UrlDecode(cursor));
241
+ } catch (cause) {
242
+ throw new MalformedCursorError(cause);
243
+ }
244
+ if (typeof parsed !== "object" || parsed === null || !(CURSOR_DIRECTION_KEY in parsed)) throw new MalformedCursorError();
245
+ const { [CURSOR_DIRECTION_KEY]: pointsToNext, ...values } = parsed;
246
+ return {
247
+ values,
248
+ direction: pointsToNext ? "next" : "prev"
249
+ };
250
+ }
251
+ function normalizeOrderBy(orderBy) {
252
+ return (Array.isArray(orderBy) ? orderBy : [orderBy]).flatMap((clause) => Object.entries(clause)).map(([field, direction]) => {
253
+ if (direction !== "asc" && direction !== "desc") throw new CursorOrderingError(`[stratal:database] $cursor needs a direction for "${field}"; got ${JSON.stringify(direction)}.`, field);
254
+ return [field, direction];
255
+ });
256
+ }
257
+ /**
258
+ * Builds the keyset condition for "the rows after (or before) this position".
259
+ *
260
+ * For `updatedAt desc, id desc` this is
261
+ * `updatedAt < :updatedAt OR (updatedAt = :updatedAt AND id < :id)` — one
262
+ * disjunct per ordering column, each pinning the columns to its left to
263
+ * equality. Reading backwards flips every comparison.
264
+ */
265
+ function buildKeysetCondition(order, values, direction) {
266
+ const disjuncts = [];
267
+ for (let i = 0; i < order.length; i++) {
268
+ const conjunct = {};
269
+ for (let j = 0; j < i; j++) {
270
+ const [field] = order[j];
271
+ conjunct[field] = values[field];
272
+ }
273
+ const [field, sort] = order[i];
274
+ conjunct[field] = { [sort === "desc" === (direction === "next") ? "lt" : "gt"]: values[field] };
275
+ disjuncts.push(conjunct);
276
+ }
277
+ return { OR: disjuncts };
278
+ }
279
+ /** The ordering values of a row, which is what a cursor is made of. */
280
+ function cursorValuesOf(row, order) {
281
+ const values = {};
282
+ for (const [field] of order) {
283
+ if (!(field in row)) throw new CursorOrderingError(`[stratal:database] Cannot build a cursor: the ordering column "${field}" is not on the returned row. Either it is not a field of this model, or a \`select\` dropped it.`, field);
284
+ const value = row[field];
285
+ if (value === null || value === void 0) throw new CursorOrderingError(`[stratal:database] Cannot build a cursor: the ordering column "${field}" is null. \$cursor cannot order on a nullable column — null has no position in an ordering.`, field);
286
+ values[field] = value;
287
+ }
288
+ return values;
289
+ }
290
+ /**
291
+ * Reads one page of rows by cursor.
292
+ *
293
+ * Fetches one row more than asked for, which is how the next page is known to
294
+ * exist without a `COUNT` — and a count is what a growing list cannot give a
295
+ * stable answer to anyway.
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * const page = await db.$cursor.$from(threadUnion, {
300
+ * cursor: ctx.query('cursor'),
301
+ * take: 20,
302
+ * orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
303
+ * where: { boardId },
304
+ * })
305
+ * ```
306
+ */
307
+ async function readCursorPage(delegate, args) {
308
+ const order = normalizeOrderBy(args.orderBy);
309
+ if (order.length === 0) throw new CursorOrderingError("[stratal:database] $cursor requires `orderBy`. A cursor addresses a row's position in an ordering, so there is no position to address without one.");
310
+ const uniqueBy = args.uniqueBy ?? "id";
311
+ if (!order.some(([field]) => field === uniqueBy)) throw new CursorOrderingError(`[stratal:database] $cursor requires the ordering to include the unique column "${uniqueBy}", otherwise tied rows share a position and paging over them skips or repeats. Add it last — e.g. \`orderBy: [{ ${order[0][0]}: '${order[0][1]}' }, { ${uniqueBy}: '${order[0][1]}' }]\` — or name a different unique column with \`uniqueBy\`.`);
312
+ const decoded = args.cursor ? decodeCursor(args.cursor) : null;
313
+ const readingBackwards = decoded?.direction === "prev";
314
+ const queryOrder = readingBackwards ? order.map(([field, sort]) => [field, sort === "asc" ? "desc" : "asc"]) : order;
315
+ const conditions = [];
316
+ if (args.where) conditions.push(args.where);
317
+ if (decoded) conditions.push(buildKeysetCondition(order, decoded.values, decoded.direction));
318
+ const query = {
319
+ ...conditions.length > 0 ? { where: conditions.length === 1 ? conditions[0] : { AND: conditions } } : {},
320
+ orderBy: queryOrder.map(([field, sort]) => ({ [field]: sort })),
321
+ take: args.take + 1,
322
+ ...args.include ? { include: args.include } : {},
323
+ ...args.select ? { select: args.select } : {},
324
+ ...args.omit ? { omit: args.omit } : {}
325
+ };
326
+ const rows = await delegate.findMany(query);
327
+ const hasExtraRow = rows.length > args.take;
328
+ const page = hasExtraRow ? rows.slice(0, args.take) : rows;
329
+ const data = readingBackwards ? [...page].reverse() : page;
330
+ const first = data[0];
331
+ const last = data[data.length - 1];
332
+ const nextCursor = last === void 0 ? null : readingBackwards ? encodeCursor(cursorValuesOf(last, order), "next") : hasExtraRow ? encodeCursor(cursorValuesOf(last, order), "next") : null;
333
+ const prevCursor = first === void 0 ? null : readingBackwards ? hasExtraRow ? encodeCursor(cursorValuesOf(first, order), "prev") : null : decoded ? encodeCursor(cursorValuesOf(first, order), "prev") : null;
334
+ return {
335
+ data,
336
+ perPage: args.take,
337
+ cursorName: args.cursorName ?? "cursor",
338
+ cursor: args.cursor ?? null,
339
+ nextCursor,
340
+ prevCursor
341
+ };
342
+ }
343
+ //#endregion
344
+ //#region src/database/pagination/cursor-reader.ts
345
+ /**
346
+ * Builds the reader eagerly, one entry per model the schema declares.
347
+ *
348
+ * A plain object rather than a `Proxy`: the model list is known here, and a
349
+ * proxy would answer every property — `then`, `constructor`, an inspector's
350
+ * probe — with something that looks like a reader.
351
+ *
352
+ * The delegate is resolved per call, not captured: ZenStack builds a fresh CRUD
353
+ * handler on each model access, and holding one would pin it for the life of
354
+ * the client.
355
+ */
356
+ function createCursorReader(client, schema) {
357
+ const source = client;
358
+ const modelEntries = Object.keys(schema.models ?? {}).map((model) => {
359
+ const key = model.charAt(0).toLowerCase() + model.slice(1);
360
+ return [key, { findMany: (args) => {
361
+ const delegate = source[key];
362
+ if (!delegate) throw new CursorModelUnavailableError(key);
363
+ const { cursor, take, orderBy, uniqueBy, cursorName, where, include, select, omit } = args;
364
+ return readCursorPage(delegate, {
365
+ cursor,
366
+ take,
367
+ orderBy,
368
+ uniqueBy,
369
+ cursorName,
370
+ where,
371
+ include,
372
+ select,
373
+ omit
374
+ });
375
+ } }];
376
+ });
377
+ return Object.assign(Object.fromEntries(modelEntries), { $from: (delegate, args) => readCursorPage(delegate, args) });
378
+ }
379
+ //#endregion
126
380
  //#region src/database/errors/record-not-found.error.ts
127
381
  var RecordNotFoundError = class extends HttpException {
128
382
  details;
@@ -158,19 +412,31 @@ function parseDatabaseError(error) {
158
412
  const dbErrorCode = error.dbErrorCode;
159
413
  if (dbErrorCode) {
160
414
  if (dbErrorCode === "23505") return new UniqueConstraintError([error.model ?? "unknown"], error);
161
- if (dbErrorCode === "23503") return new DatabaseError("Foreign key constraint violation", error);
162
- if (dbErrorCode === "23502") return new DatabaseError("Required field is missing", error);
163
- if (dbErrorCode === "23514") return new DatabaseError("Database constraint violated", error);
164
- if (dbErrorCode === "42P01") return new DatabaseError("Table does not exist", error);
165
- if (dbErrorCode === "42703") return new DatabaseError("Column does not exist", error);
166
- if (dbErrorCode.startsWith("42")) return new DatabaseError("Database syntax or access error", error);
167
- if (dbErrorCode.startsWith("08")) return new DatabaseError("Database connection failed", error);
168
- if (dbErrorCode === "57014") return new DatabaseError("Database query timeout", error);
169
- if (dbErrorCode.startsWith("40")) return new DatabaseError("Transaction conflict or deadlock", error);
170
- if (dbErrorCode === "53300") return new DatabaseError("Too many database connections", error);
415
+ if (dbErrorCode === "23503") return new DatabaseError(withCode("Foreign key constraint violation", dbErrorCode), error);
416
+ if (dbErrorCode === "23502") return new DatabaseError(withCode("Required field is missing", dbErrorCode), error);
417
+ if (dbErrorCode === "23514") return new DatabaseError(withCode("Database constraint violated", dbErrorCode), error);
418
+ if (dbErrorCode === "42P01") return new DatabaseError(withCode("Table does not exist", dbErrorCode), error);
419
+ if (dbErrorCode === "42703") return new DatabaseError(withCode("Column does not exist", dbErrorCode), error);
420
+ if (dbErrorCode.startsWith("42")) return new DatabaseError(withCode("Database syntax or access error", dbErrorCode), error);
421
+ if (dbErrorCode.startsWith("22")) return new DatabaseError(withCode("Invalid data value for column", dbErrorCode), error);
422
+ if (dbErrorCode.startsWith("08")) return new DatabaseError(withCode("Database connection failed", dbErrorCode), error);
423
+ if (dbErrorCode === "57014") return new DatabaseError(withCode("Database query timeout", dbErrorCode), error);
424
+ if (dbErrorCode.startsWith("40")) return new DatabaseError(withCode("Transaction conflict or deadlock", dbErrorCode), error);
425
+ if (dbErrorCode === "53300") return new DatabaseError(withCode("Too many database connections", dbErrorCode), error);
426
+ return new DatabaseError(withCode("Database error", dbErrorCode), error);
171
427
  }
172
428
  return new DatabaseError("Database error", error);
173
429
  }
430
+ /**
431
+ * Append the raw SQLSTATE to a human message so an operator can tell distinct
432
+ * failures apart in production logs. The 5-character SQLSTATE is a fixed
433
+ * PostgreSQL error code (e.g. `22021`), not query text, values, or schema — it
434
+ * carries no sensitive data, so it is safe to surface where the full driver
435
+ * message and stack are deliberately withheld.
436
+ */
437
+ function withCode(message, dbErrorCode) {
438
+ return `${message} [SQLSTATE ${dbErrorCode}]`;
439
+ }
174
440
  //#endregion
175
441
  //#region src/database/plugins/error-handler.plugin.ts
176
442
  /**
@@ -324,20 +590,37 @@ var SchemaSwitcher = class {
324
590
  };
325
591
  //#endregion
326
592
  //#region src/database/database.helpers.ts
327
- const databaseConnectionSchema = z.object({
328
- name: z.string().min(1, withZodI18n("database.connectionNameRequired")),
329
- schema: z.object({}).loose(),
330
- dialect: z.function(),
331
- plugins: z.array(z.object({}).loose()).optional(),
332
- computedFields: z.object({}).loose().optional()
593
+ const databaseConnectionSchema = object({
594
+ name: string().check(minLength(1, withZodI18n("database.connectionNameRequired"))),
595
+ schema: looseObject({}),
596
+ dialect: custom((value) => typeof value === "function"),
597
+ plugins: optional(array(looseObject({}))),
598
+ computedFields: optional(looseObject({}))
333
599
  });
334
- z.object({
335
- default: z.string().min(1, withZodI18n("database.defaultConnectionRequired")),
336
- connections: z.array(databaseConnectionSchema).min(1, withZodI18n("database.connectionRequired"))
337
- }).refine((config) => {
600
+ object({
601
+ default: string().check(minLength(1, withZodI18n("database.defaultConnectionRequired"))),
602
+ connections: array(databaseConnectionSchema).check(minLength(1, withZodI18n("database.connectionRequired")))
603
+ }).check(refine((config) => {
338
604
  const names = config.connections.map((c) => c.name);
339
605
  return new Set(names).size === names.length;
340
- }, withZodI18n("database.duplicateConnections")).refine((config) => config.connections.some((c) => c.name === config.default), withZodI18n("database.defaultConnectionNotFound"));
606
+ }, withZodI18n("database.duplicateConnections")), refine((config) => config.connections.some((c) => c.name === config.default), withZodI18n("database.defaultConnectionNotFound")));
607
+ /**
608
+ * Puts `$cursor` on a client, base or transaction.
609
+ *
610
+ * Defined on the client rather than contributed as a ZenStack plugin because
611
+ * the client proxy hands a plugin's member back unbound, and this one has to
612
+ * reach the client's own model delegates. Non-enumerable so it stays out of
613
+ * enumeration of the client, and skipped when already present — a reentrant
614
+ * `$transaction` hands back a client that has been through here.
615
+ */
616
+ function defineCursorReader(client, schema) {
617
+ if ("$cursor" in client) return;
618
+ Object.defineProperty(client, "$cursor", {
619
+ value: createCursorReader(client, schema),
620
+ enumerable: false,
621
+ configurable: true
622
+ });
623
+ }
341
624
  /**
342
625
  * Wrap a ZenStack client so `$transaction` is reentrant: when a transaction is
343
626
  * already open on this connection (tracked per-connection via
@@ -354,8 +637,13 @@ z.object({
354
637
  *
355
638
  * ZenStackClient's constructor returns a Proxy (for dynamic model accessors), so
356
639
  * a subclass method override is shadowed — hence the proxy wrapper here.
640
+ *
641
+ * `prepareTransactionClient` runs against every transaction client before the
642
+ * callback sees it, which is how the built-in members reach it. ZenStack builds
643
+ * that client itself and types it with its own contract, so there is no other
644
+ * point at which it passes through this package.
357
645
  */
358
- function makeReentrantTransaction(client, activeTransaction) {
646
+ function makeReentrantTransaction(client, activeTransaction, prepareTransactionClient) {
359
647
  return new Proxy(client, { get(target, prop, receiver) {
360
648
  if (prop === Symbol.asyncDispose) return () => target.$disconnect();
361
649
  if (prop !== "$transaction") return Reflect.get(target, prop, receiver);
@@ -364,7 +652,10 @@ function makeReentrantTransaction(client, activeTransaction) {
364
652
  const active = activeTransaction.getStore();
365
653
  if (active) return typeof input === "function" ? input(active) : active.$transaction(input, options);
366
654
  if (typeof input !== "function") return transaction.call(target, input, options);
367
- return transaction.call(target, (tx) => activeTransaction.run(tx, () => input(tx)), options);
655
+ return transaction.call(target, (tx) => {
656
+ prepareTransactionClient?.(tx);
657
+ return activeTransaction.run(tx, () => input(tx));
658
+ }, options);
368
659
  };
369
660
  } });
370
661
  }
@@ -384,12 +675,13 @@ function createDatabaseService(conn, eventRegistry) {
384
675
  plugins,
385
676
  computedFields: conn.computedFields
386
677
  });
387
- const client = makeReentrantTransaction(this, activeTransaction);
678
+ defineCursorReader(this, conn.schema);
679
+ const client = makeReentrantTransaction(this, activeTransaction, (tx) => defineCursorReader(tx, conn.schema));
388
680
  for (const ref of instances) if (ref.deref() === void 0) instances.delete(ref);
389
681
  instances.add(new WeakRef(client));
390
682
  return client;
391
683
  }
392
- static async disposeInstances() {
684
+ static async disposeInstances(logger) {
393
685
  const live = [...instances];
394
686
  instances.clear();
395
687
  await Promise.all(live.map(async (ref) => {
@@ -398,12 +690,12 @@ function createDatabaseService(conn, eventRegistry) {
398
690
  try {
399
691
  await client.$disconnect();
400
692
  } catch (error) {
401
- console.error(`[stratal] Failed to disconnect database client "${conn.name}":`, error);
693
+ logger.error(`Failed to disconnect database client "${conn.name}"`, error instanceof Error ? error : new Error(String(error)));
402
694
  }
403
695
  }));
404
696
  }
405
697
  };
406
- DatabaseClient = __decorate([Transient()], DatabaseClient);
698
+ DatabaseClient = __decorate([Request()], DatabaseClient);
407
699
  return DatabaseClient;
408
700
  }
409
701
  //#endregion
@@ -449,7 +741,7 @@ let DatabaseModule = _DatabaseModule = class DatabaseModule {
449
741
  };
450
742
  }
451
743
  async onInitialize(context) {
452
- const config = context.container.resolve(DATABASE_TOKENS.Options);
744
+ const config = await context.container.resolve(DATABASE_TOKENS.Options);
453
745
  const eventRegistry = (await context.container.resolve(DI_TOKENS.LazyModuleLoader).load(() => import("stratal/events").then((m) => m.EventsModule))).get(DI_TOKENS.EventRegistry);
454
746
  for (const conn of config.connections) {
455
747
  const Service = createDatabaseService(conn, eventRegistry);
@@ -460,7 +752,7 @@ let DatabaseModule = _DatabaseModule = class DatabaseModule {
460
752
  context.logger.info("DatabaseModule initialized");
461
753
  }
462
754
  async onShutdown(context) {
463
- await Promise.all(this.services.map((service) => service.disposeInstances()));
755
+ await Promise.all(this.services.map((service) => service.disposeInstances(context.logger)));
464
756
  this.services.length = 0;
465
757
  context.logger.info("DatabaseModule shutdown");
466
758
  }
@@ -478,11 +770,98 @@ DatabaseModule = _DatabaseModule = __decorate([Module({
478
770
  ]
479
771
  })], DatabaseModule);
480
772
  //#endregion
773
+ //#region src/database/pool.ts
774
+ /**
775
+ * Env binding the framework reads to decide DB connection topology. Set by
776
+ * `@stratal/testing` (the test harness runs against a DIRECT Postgres with no
777
+ * Hyperdrive in front); never set in dev / staging / production.
778
+ */
779
+ const DB_SHARED_POOL_ENV = "STRATAL_DB_SHARED_POOL";
780
+ /**
781
+ * Build the lazy pool factory a consumer hands to its ZenStack dialect,
782
+ * choosing the connection topology from the environment instead of hard-coding
783
+ * one. The consumer writes `dialect: () => new PostgresDialect({ pool })` where
784
+ * `pool = createPoolFactory(env, () => new Pool(poolConfig))` — so the prod-vs-
785
+ * test decision lives here, in the framework, not as an `IS_TEST` branch in app
786
+ * config.
787
+ *
788
+ * - **Default (dev / staging / production):** returns a FRESH pool on every
789
+ * call. The dialect is rebuilt per request resolution, so each request owns
790
+ * its own pool/socket — mandatory on workerd, where a pool opened in one
791
+ * request's I/O context cannot be reused by another (the cross-request I/O is
792
+ * cancelled and the request hangs forever). Hyperdrive fronts these pools and
793
+ * multiplexes the real server connections, so they never accumulate.
794
+ * - **Shared (`DB_SHARED_POOL_ENV === 'true'`):** memoizes ONE pool per
795
+ * connection, reused across every resolution. `@stratal/testing` sets this
796
+ * because the test harness hits a direct Postgres with no Hyperdrive — a fresh
797
+ * pool per resolution would exhaust `max_connections` across parallel test
798
+ * files. One shared pool per connection mirrors what Hyperdrive does in prod.
799
+ *
800
+ * This single pool is forced **persistent** ({@link withPersistentConnection}):
801
+ * its idle reaper is disabled regardless of the config `makePool` passed. A pool
802
+ * reused for the whole worker run must never idle-close — a non-zero
803
+ * `idleTimeoutMillis` reaps its connection between operations, and under real
804
+ * network latency (a CI Postgres service, not a local socket) that eviction
805
+ * races in-flight and subsequent queries → "Connection terminated unexpectedly",
806
+ * cascading into half-applied writes and cross-test row leakage. It passes on a
807
+ * fast local socket and only surfaces under latency, so consumers can't be
808
+ * trusted to configure it right — the shared branch enforces it. The consumer's
809
+ * own idle settings still apply to the fresh-per-resolution prod pools (below).
810
+ *
811
+ * Either way the pool is created LAZILY: `makePool` is invoked by Kysely on the
812
+ * first query, inside the request's I/O context — never at module-eval / global
813
+ * scope, which workerd forbids ("Disallowed operation within global scope").
814
+ */
815
+ function createPoolFactory(env, makePool) {
816
+ if (!(env["STRATAL_DB_SHARED_POOL"] === "true")) return () => Promise.resolve(makePool());
817
+ let pool;
818
+ return () => Promise.resolve(pool ??= withPersistentConnection(withIdempotentEnd(makePool())));
819
+ }
820
+ /**
821
+ * Force a SHARED pool to keep its connection: disable the idle reaper so it is
822
+ * never torn down between operations. The shared pool is ONE connection reused
823
+ * for the whole worker run, so a non-zero `idleTimeoutMillis` (correct for the
824
+ * fresh-per-resolution prod pools, wrong for this long-lived one) reaps it mid-
825
+ * run; under real network latency that eviction races in-flight/next queries →
826
+ * "Connection terminated unexpectedly", cascading into half-applied writes and
827
+ * cross-test row leakage. `pg` reads `options.idleTimeoutMillis`/`allowExitOnIdle`
828
+ * when a client is released, so clearing them on the constructed pool disables
829
+ * future reaping without the consumer having to special-case their pool config.
830
+ * A no-op for pools that don't expose `options` (non-`pg` implementations).
831
+ */
832
+ function withPersistentConnection(pool) {
833
+ const candidate = pool;
834
+ if (candidate.options) {
835
+ candidate.options.idleTimeoutMillis = 0;
836
+ candidate.options.allowExitOnIdle = false;
837
+ }
838
+ return pool;
839
+ }
840
+ /**
841
+ * A SHARED pool is handed to every `@Transient` `DatabaseClient`'s dialect, so on
842
+ * shutdown `DatabaseModule.onShutdown` → `disposeInstances` calls `$disconnect()`
843
+ * (→ Kysely `destroy()` → `pool.end()`) once PER live client instance — all of
844
+ * them targeting the single shared pool. pg-pool throws "Called end on pool more
845
+ * than once" on the 2nd+ call, which `disposeInstances` then logs once per extra
846
+ * instance (harmless but noisy, and a real correctness wart). Each owner releasing
847
+ * its reference is legitimate, so make `end()` idempotent: tear the socket down
848
+ * exactly once and have every caller await that same teardown. Fresh-per-resolution
849
+ * pools (the dev/staging/prod default) are untouched — each is already ended once.
850
+ */
851
+ function withIdempotentEnd(pool) {
852
+ const candidate = pool;
853
+ if (typeof candidate.end !== "function") return pool;
854
+ const end = candidate.end.bind(candidate);
855
+ let ending;
856
+ candidate.end = () => ending ??= end();
857
+ return pool;
858
+ }
859
+ //#endregion
481
860
  //#region src/database/decorators/inject-db.decorator.ts
482
861
  function InjectDB(name) {
483
862
  return inject(connectionSymbol(name));
484
863
  }
485
864
  //#endregion
486
- export { DATABASE_TOKENS, DatabaseModule, DbGenerateCommand, DbPullCommand, DbPushCommand, ErrorHandlerPlugin, EventEmitterPlugin, InjectDB, MigrateDeployCommand, MigrateDevCommand, MigrateResetCommand, MigrateStatusCommand, RecordNotFoundError, SchemaSwitcher, UniqueConstraintError, ZenStackCommand, connectionSymbol, databaseMessages, fromZenStackError };
865
+ export { CursorModelUnavailableError, CursorOrderingError, DATABASE_TOKENS, DB_SHARED_POOL_ENV, DatabaseModule, DbGenerateCommand, DbPullCommand, DbPushCommand, ErrorHandlerPlugin, EventEmitterPlugin, InjectDB, MalformedCursorError, MigrateDeployCommand, MigrateDevCommand, MigrateResetCommand, MigrateStatusCommand, RecordNotFoundError, SchemaSwitcher, UniqueConstraintError, ZenStackCommand, connectionSymbol, createCursorReader, createPoolFactory, databaseMessages, decodeCursor, encodeCursor, fromZenStackError };
487
866
 
488
867
  //# sourceMappingURL=index.mjs.map