@stratal/framework 0.0.0-canary-ccb3f17 → 0.0.0-canary-e5681b8

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 (36) hide show
  1. package/CHANGELOG.md +655 -0
  2. package/README.md +166 -24
  3. package/dist/access-control/index.d.mts +8 -8
  4. package/dist/access-control/index.d.mts.map +1 -1
  5. package/dist/access-control/index.mjs +2 -2
  6. package/dist/{access.service-rsEreT-4.mjs → access.service-BjmnWBEo.mjs} +3 -3
  7. package/dist/{access.service-rsEreT-4.mjs.map → access.service-BjmnWBEo.mjs.map} +1 -1
  8. package/dist/auth/index.d.mts +49 -48
  9. package/dist/auth/index.d.mts.map +1 -1
  10. package/dist/auth/index.mjs +86 -17
  11. package/dist/auth/index.mjs.map +1 -1
  12. package/dist/{auth-context-CE_-TV27.mjs → auth-context-cNSS1rmh.mjs} +2 -2
  13. package/dist/{auth-context-CE_-TV27.mjs.map → auth-context-cNSS1rmh.mjs.map} +1 -1
  14. package/dist/context/index.d.mts +3 -3
  15. package/dist/context/index.d.mts.map +1 -1
  16. package/dist/context/index.mjs +1 -1
  17. package/dist/database/index.d.mts +3 -3
  18. package/dist/database/index.mjs +288 -9
  19. package/dist/database/index.mjs.map +1 -1
  20. package/dist/{decorate-ZDdbRmsv.mjs → decorate-RQD1h28J.mjs} +1 -1
  21. package/dist/{decorateParam-DQ95ttaa.mjs → decorateParam-xwTkq9gO.mjs} +2 -2
  22. package/dist/{decorateParam-DQ95ttaa.mjs.map → decorateParam-xwTkq9gO.mjs.map} +1 -1
  23. package/dist/factory/index.d.mts +3 -4
  24. package/dist/factory/index.d.mts.map +1 -1
  25. package/dist/guards/index.d.mts +3 -3
  26. package/dist/guards/index.d.mts.map +1 -1
  27. package/dist/guards/index.mjs +4 -4
  28. package/dist/index-e_u1SRyd.d.mts +921 -0
  29. package/dist/index-e_u1SRyd.d.mts.map +1 -0
  30. package/dist/index.d.mts +1 -1
  31. package/dist/{types-DjLXYLlD.d.mts → types-B35g-lXi.d.mts} +18 -3
  32. package/dist/types-B35g-lXi.d.mts.map +1 -0
  33. package/package.json +29 -25
  34. package/dist/index-B8EVn7T5.d.mts +0 -481
  35. package/dist/index-B8EVn7T5.d.mts.map +0 -1
  36. package/dist/types-DjLXYLlD.d.mts.map +0 -1
@@ -1,7 +1,7 @@
1
- import { t as __decorate } from "../decorate-ZDdbRmsv.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";
@@ -124,6 +124,259 @@ var MigrateStatusCommand = class extends ZenStackCommand {
124
124
  }
125
125
  };
126
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
127
380
  //#region src/database/errors/record-not-found.error.ts
128
381
  var RecordNotFoundError = class extends HttpException {
129
382
  details;
@@ -352,6 +605,23 @@ object({
352
605
  return new Set(names).size === names.length;
353
606
  }, withZodI18n("database.duplicateConnections")), refine((config) => config.connections.some((c) => c.name === config.default), withZodI18n("database.defaultConnectionNotFound")));
354
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
+ }
624
+ /**
355
625
  * Wrap a ZenStack client so `$transaction` is reentrant: when a transaction is
356
626
  * already open on this connection (tracked per-connection via
357
627
  * {@link AsyncLocalStorage}), nested calls run within the active transaction's
@@ -367,8 +637,13 @@ object({
367
637
  *
368
638
  * ZenStackClient's constructor returns a Proxy (for dynamic model accessors), so
369
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.
370
645
  */
371
- function makeReentrantTransaction(client, activeTransaction) {
646
+ function makeReentrantTransaction(client, activeTransaction, prepareTransactionClient) {
372
647
  return new Proxy(client, { get(target, prop, receiver) {
373
648
  if (prop === Symbol.asyncDispose) return () => target.$disconnect();
374
649
  if (prop !== "$transaction") return Reflect.get(target, prop, receiver);
@@ -377,7 +652,10 @@ function makeReentrantTransaction(client, activeTransaction) {
377
652
  const active = activeTransaction.getStore();
378
653
  if (active) return typeof input === "function" ? input(active) : active.$transaction(input, options);
379
654
  if (typeof input !== "function") return transaction.call(target, input, options);
380
- 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);
381
659
  };
382
660
  } });
383
661
  }
@@ -397,7 +675,8 @@ function createDatabaseService(conn, eventRegistry) {
397
675
  plugins,
398
676
  computedFields: conn.computedFields
399
677
  });
400
- const client = makeReentrantTransaction(this, activeTransaction);
678
+ defineCursorReader(this, conn.schema);
679
+ const client = makeReentrantTransaction(this, activeTransaction, (tx) => defineCursorReader(tx, conn.schema));
401
680
  for (const ref of instances) if (ref.deref() === void 0) instances.delete(ref);
402
681
  instances.add(new WeakRef(client));
403
682
  return client;
@@ -416,7 +695,7 @@ function createDatabaseService(conn, eventRegistry) {
416
695
  }));
417
696
  }
418
697
  };
419
- DatabaseClient = __decorate([Transient()], DatabaseClient);
698
+ DatabaseClient = __decorate([Request()], DatabaseClient);
420
699
  return DatabaseClient;
421
700
  }
422
701
  //#endregion
@@ -462,7 +741,7 @@ let DatabaseModule = _DatabaseModule = class DatabaseModule {
462
741
  };
463
742
  }
464
743
  async onInitialize(context) {
465
- const config = context.container.resolve(DATABASE_TOKENS.Options);
744
+ const config = await context.container.resolve(DATABASE_TOKENS.Options);
466
745
  const eventRegistry = (await context.container.resolve(DI_TOKENS.LazyModuleLoader).load(() => import("stratal/events").then((m) => m.EventsModule))).get(DI_TOKENS.EventRegistry);
467
746
  for (const conn of config.connections) {
468
747
  const Service = createDatabaseService(conn, eventRegistry);
@@ -583,6 +862,6 @@ function InjectDB(name) {
583
862
  return inject(connectionSymbol(name));
584
863
  }
585
864
  //#endregion
586
- export { DATABASE_TOKENS, DB_SHARED_POOL_ENV, DatabaseModule, DbGenerateCommand, DbPullCommand, DbPushCommand, ErrorHandlerPlugin, EventEmitterPlugin, InjectDB, MigrateDeployCommand, MigrateDevCommand, MigrateResetCommand, MigrateStatusCommand, RecordNotFoundError, SchemaSwitcher, UniqueConstraintError, ZenStackCommand, connectionSymbol, createPoolFactory, 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 };
587
866
 
588
867
  //# sourceMappingURL=index.mjs.map