@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.
- package/CHANGELOG.md +596 -0
- package/README.md +166 -24
- package/dist/access-control/index.d.mts +43 -16
- package/dist/access-control/index.d.mts.map +1 -1
- package/dist/access-control/index.mjs +5 -5
- package/dist/access-control/index.mjs.map +1 -1
- package/dist/{access.service-BmDhE-re.mjs → access.service-BjmnWBEo.mjs} +35 -17
- package/dist/access.service-BjmnWBEo.mjs.map +1 -0
- package/dist/auth/index.d.mts +105 -103
- package/dist/auth/index.d.mts.map +1 -1
- package/dist/auth/index.mjs +123 -24
- package/dist/auth/index.mjs.map +1 -1
- package/dist/{auth-context-CGVbiSX3.d.mts → auth-context-C1om3Zsr.d.mts} +1 -2
- package/dist/auth-context-C1om3Zsr.d.mts.map +1 -0
- package/dist/{auth-context-C8NBfiMa.mjs → auth-context-cNSS1rmh.mjs} +2 -2
- package/dist/{auth-context-C8NBfiMa.mjs.map → auth-context-cNSS1rmh.mjs.map} +1 -1
- package/dist/auth.service-Onf3JkyL.d.mts +44 -0
- package/dist/auth.service-Onf3JkyL.d.mts.map +1 -0
- package/dist/context/index.d.mts +4 -5
- package/dist/context/index.d.mts.map +1 -1
- package/dist/context/index.mjs +1 -1
- package/dist/database/index.d.mts +3 -3
- package/dist/database/index.mjs +413 -34
- package/dist/database/index.mjs.map +1 -1
- package/dist/{decorate-B7nr7eBl.mjs → decorate-RQD1h28J.mjs} +1 -1
- package/dist/{decorateParam-DwV9LSPl.mjs → decorateParam-xwTkq9gO.mjs} +2 -2
- package/dist/{decorateParam-DwV9LSPl.mjs.map → decorateParam-xwTkq9gO.mjs.map} +1 -1
- package/dist/factory/index.d.mts +3 -5
- package/dist/factory/index.d.mts.map +1 -1
- package/dist/factory/index.mjs.map +1 -1
- package/dist/guards/index.d.mts +3 -4
- package/dist/guards/index.d.mts.map +1 -1
- package/dist/guards/index.mjs +4 -4
- package/dist/guards/index.mjs.map +1 -1
- package/dist/index-e_u1SRyd.d.mts +921 -0
- package/dist/index-e_u1SRyd.d.mts.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/{types-CWZ9q74G.d.mts → types-B35g-lXi.d.mts} +18 -4
- package/dist/types-B35g-lXi.d.mts.map +1 -0
- package/package.json +31 -27
- package/dist/access.service-BmDhE-re.mjs.map +0 -1
- package/dist/auth-context-CGVbiSX3.d.mts.map +0 -1
- package/dist/index-Dt0YUA7r.d.mts +0 -446
- package/dist/index-Dt0YUA7r.d.mts.map +0 -1
- package/dist/types-CWZ9q74G.d.mts.map +0 -1
- package/dist/types-DabF8LGz.d.mts +0 -11
- package/dist/types-DabF8LGz.d.mts.map +0 -1
package/dist/database/index.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { t as __decorate } from "../decorate-
|
|
2
|
-
import { DI_TOKENS,
|
|
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 {
|
|
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("
|
|
168
|
-
if (dbErrorCode
|
|
169
|
-
if (dbErrorCode
|
|
170
|
-
if (dbErrorCode
|
|
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 =
|
|
328
|
-
name:
|
|
329
|
-
schema:
|
|
330
|
-
dialect:
|
|
331
|
-
plugins:
|
|
332
|
-
computedFields:
|
|
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
|
-
|
|
335
|
-
default:
|
|
336
|
-
connections:
|
|
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"))
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|