@shapio/local 0.5.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/dist/index.js ADDED
@@ -0,0 +1,4481 @@
1
+ import { c as assertNotNested, d as toDatetimeText, f as AppError, i as createMysqlDb, l as withConnectionScope, n as createSqliteDb, o as DEFAULT_POOL_ACQUIRE_TIMEOUT_MS, s as pgAcquireError, t as lockKeyFromId, u as toStoredTimestamp } from "./advisoryLocks-COlJQc1r.js";
2
+ import { i as isValidStorageKey, n as ObjectNotFoundError, r as encodeKeyPath, t as InvalidStorageKeyError } from "./errors-SK0PwPKC.js";
3
+ import { t as assertNodeRuntime } from "./nodeRuntime-DzuiHUhY.js";
4
+ import { ShapioApiError, createClient, createDeliveryClient } from "@shapio/client";
5
+ import { Value } from "typebox/value";
6
+ import { BIGINTEGER_PATTERN, DATE_PATTERN, DECIMAL_PATTERN, EMPTY_SEO_DEFAULTS, SCALAR_DATA_TYPES, SEO_COMPONENT_ID, SEO_FIELD_IDS, SeoDefaultsSchema, TEXT_TITLE_FIELD_TYPES, TIME_PATTERN, effectiveLayout, effectiveTitleField, embeddedComponentIds, findDependentModels, hashDefinition, isComponentDefinition, isModelDefinition, isSeoField, isStableId, renderRichTextHtml, resolveSeo, richTextMediaIds, routeKeyOf, seoDefaultsForLocale, withDerivedPlural } from "@shapio/schema";
7
+ import { Type } from "typebox";
8
+ import { Kysely, PostgresDialect, sql } from "kysely";
9
+ import { createHash, createHmac, randomBytes } from "node:crypto";
10
+ import pg from "pg";
11
+ import { dirname, relative, resolve, sep } from "node:path";
12
+ import { pino } from "pino";
13
+ import { createReadStream, createWriteStream } from "node:fs";
14
+ import { mkdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
15
+ import { pipeline } from "node:stream/promises";
16
+ //#endregion
17
+ //#region ../../apps/api/src/constants/version.ts
18
+ const SHAPIO_VERSION$1 = "0.5.0";
19
+ //#endregion
20
+ //#region ../../apps/api/src/helpers/errorResponse.ts
21
+ const STATUS_CODES = {
22
+ 400: "BAD_REQUEST",
23
+ 401: "UNAUTHORIZED",
24
+ 403: "FORBIDDEN",
25
+ 404: "NOT_FOUND",
26
+ 405: "METHOD_NOT_ALLOWED",
27
+ 406: "NOT_ACCEPTABLE",
28
+ 409: "CONFLICT",
29
+ 413: "PAYLOAD_TOO_LARGE",
30
+ 415: "UNSUPPORTED_MEDIA_TYPE",
31
+ 422: "UNPROCESSABLE_ENTITY",
32
+ 429: "RATE_LIMITED",
33
+ 503: "SERVICE_UNAVAILABLE"
34
+ };
35
+ /** Errors raised by @fastify/csrf-protection (plugins/csrf.ts). */
36
+ const CSRF_ERROR_CODES = /* @__PURE__ */ new Set(["FST_CSRF_INVALID_TOKEN", "FST_CSRF_MISSING_SECRET"]);
37
+ /**
38
+ * The one error shape every endpoint returns, `{ error: { code, message, details? } }`, and its status.
39
+ * Server errors (5xx) never carry their internal text; the caller logs the original error.
40
+ */
41
+ const toErrorResponse = (error) => {
42
+ if (error instanceof AppError) {
43
+ const body = { error: {
44
+ code: error.code,
45
+ message: error.message
46
+ } };
47
+ if (error.details !== void 0) body.error.details = error.details;
48
+ return {
49
+ statusCode: error.statusCode,
50
+ body
51
+ };
52
+ }
53
+ if (error.code !== void 0 && CSRF_ERROR_CODES.has(error.code)) return {
54
+ statusCode: 403,
55
+ body: { error: {
56
+ code: "CSRF_INVALID",
57
+ message: "Missing or invalid CSRF token"
58
+ } }
59
+ };
60
+ if (error.validation) return {
61
+ statusCode: 400,
62
+ body: { error: {
63
+ code: "VALIDATION_ERROR",
64
+ message: error.message,
65
+ details: error.validation.map(({ instancePath, message, params }) => ({
66
+ instancePath,
67
+ message,
68
+ params
69
+ }))
70
+ } }
71
+ };
72
+ const statusCode = error.statusCode !== void 0 && error.statusCode >= 400 ? error.statusCode : 500;
73
+ if (statusCode >= 500) return {
74
+ statusCode,
75
+ body: { error: {
76
+ code: "INTERNAL_ERROR",
77
+ message: "Internal server error"
78
+ } }
79
+ };
80
+ return {
81
+ statusCode,
82
+ body: { error: {
83
+ code: STATUS_CODES[statusCode] ?? "ERROR",
84
+ message: error.message
85
+ } }
86
+ };
87
+ };
88
+ //#endregion
89
+ //#region ../../apps/api/src/delivery/errors.ts
90
+ /**
91
+ * The server's release and this library's differ, or the database predates in-process delivery: reading
92
+ * across versions could run SQL against a schema it was not written for (plan next-in-process §2). Callers
93
+ * that catch `ShapioApiError` see a 503 `VERSION_SKEW`.
94
+ */
95
+ var ShapioVersionSkewError$1 = class extends ShapioApiError {
96
+ serverRelease;
97
+ libraryRelease;
98
+ constructor(serverRelease, libraryRelease, options) {
99
+ super(503, { error: {
100
+ code: "VERSION_SKEW",
101
+ message: serverRelease === null ? `The Shapio database has not been served by Shapio ${libraryRelease} yet; start the Shapio server of this release (or install the release the server runs) before reading in process` : `The Shapio server runs ${serverRelease} and this in-process reader is ${libraryRelease}; install the same release on both`
102
+ } });
103
+ this.name = "ShapioVersionSkewError";
104
+ if (options?.cause !== void 0) this.cause = options.cause;
105
+ this.serverRelease = serverRelease;
106
+ this.libraryRelease = libraryRelease;
107
+ }
108
+ };
109
+ /** Thrown when the runtime cannot be created for this database (SQLite, an unknown URL, a second database). */
110
+ var DeliveryRuntimeError$1 = class extends Error {
111
+ constructor(message) {
112
+ super(message);
113
+ this.name = "DeliveryRuntimeError";
114
+ }
115
+ };
116
+ /** The body and status the HTTP API would have answered, thrown as the client throws it. */
117
+ const toShapioApiError = (error, log) => {
118
+ if (error instanceof ShapioApiError) return error;
119
+ const failure = error instanceof Error ? error : new Error(String(error));
120
+ const { statusCode, body } = toErrorResponse(failure);
121
+ if (statusCode >= 500) log.error({ err: error }, "in-process delivery read failed");
122
+ return new ShapioApiError(statusCode, body);
123
+ };
124
+ //#endregion
125
+ //#region ../../apps/api/src/constants/sites.ts
126
+ /** Delivery requests may name their site with this query parameter (it must match a site token's site). */
127
+ const SITE_QUERY_PARAMETER = "site";
128
+ //#endregion
129
+ //#region ../../apps/api/src/content/model.ts
130
+ const modelNotFound = (key) => new AppError(404, "MODEL_NOT_FOUND", `No content model "${key}"`, { modelKey: key });
131
+ const collectComponents = (snapshot, model) => {
132
+ const found = /* @__PURE__ */ new Map();
133
+ const queue = embeddedComponentIds(model);
134
+ while (queue.length > 0) {
135
+ const id = queue.shift();
136
+ const active = snapshot.byId.get(id);
137
+ if (!active || found.has(id) || !isComponentDefinition(active.definition)) continue;
138
+ found.set(id, {
139
+ definition: active.definition,
140
+ version: active.version
141
+ });
142
+ queue.push(...embeddedComponentIds(active.definition));
143
+ }
144
+ return found;
145
+ };
146
+ const toContentModel = (snapshot, active) => ({
147
+ definition: active.definition,
148
+ version: active.version,
149
+ revisionId: active.revisionId,
150
+ components: collectComponents(snapshot, active.definition)
151
+ });
152
+ const isModel = (active) => active !== void 0 && !isComponentDefinition(active.definition);
153
+ /**
154
+ * Resolves `/api/…/content/:modelKey` through the registry at request time (CONTRIBUTING.md rule 2). Only a
155
+ * site's view resolves API IDs: another site's model is not in it, so it reads as not found.
156
+ */
157
+ const resolveModel = (snapshot, modelKey) => {
158
+ const active = snapshot.modelsByApiKey.get(modelKey);
159
+ if (!isModel(active)) throw modelNotFound(modelKey);
160
+ return toContentModel(snapshot, active);
161
+ };
162
+ /**
163
+ * Resolves the delivery and preview routes' `:modelKey`, the route key (`routeKeyOf`): the plural API ID
164
+ * of a collection, the API ID of a singleton. A collection's singular API ID is not a route key (404).
165
+ */
166
+ const resolveRouteModel = (snapshot, routeKey) => {
167
+ const active = snapshot.modelsByRouteKey.get(routeKey);
168
+ if (!active || isComponentDefinition(active.definition)) throw modelNotFound(routeKey);
169
+ return resolveModel(snapshot, active.definition.apiKey);
170
+ };
171
+ /** The API ID behind a route key: REST entry points translate with it, then call the services. */
172
+ const apiKeyOfRoute = (snapshot, routeKey) => resolveRouteModel(snapshot, routeKey).definition.apiKey;
173
+ /** By stable ID: works on a view and on the full set (jobs and conversions that already know the site). */
174
+ const resolveModelById = (snapshot, modelId) => {
175
+ const active = snapshot.byId.get(modelId);
176
+ return isModel(active) ? toContentModel(snapshot, active) : void 0;
177
+ };
178
+ const findFieldByApiKey = (fields, apiKey) => fields.find((field) => field.apiKey === apiKey && !field.deprecated);
179
+ //#endregion
180
+ //#region ../../apps/api/src/helpers/siteQuery.ts
181
+ const isSiteSegment = (segment) => {
182
+ const key = segment.split("=", 1)[0] ?? "";
183
+ try {
184
+ return decodeURIComponent(key.replace(/\+/g, " ")) === SITE_QUERY_PARAMETER;
185
+ } catch {
186
+ return false;
187
+ }
188
+ };
189
+ /**
190
+ * A raw querystring (without `?`) without its `site` parameter: site resolution consumes it, and the content
191
+ * compiler refuses unknown keys. The rest is left exactly as sent (bracket syntax is the compiler's to parse).
192
+ */
193
+ const withoutSiteParameter = (rawQuery) => rawQuery.split("&").filter((segment) => !isSiteSegment(segment)).join("&");
194
+ //#endregion
195
+ //#region ../../apps/api/src/constants/auth.ts
196
+ /** `last_seen_at` / `last_used_at` are written at most this often, so reads stay reads. */
197
+ const LAST_SEEN_WRITE_INTERVAL_MS = 6e4;
198
+ //#endregion
199
+ //#region ../../apps/api/src/permissions/types.ts
200
+ const CONTENT_ACTIONS = [
201
+ "read",
202
+ "create",
203
+ "update",
204
+ "delete",
205
+ "publish",
206
+ "schemaManage"
207
+ ];
208
+ /**
209
+ * Content actions about the schema rather than one site's entries: network roles only for a shared
210
+ * definition; for a site's own definition, the roles that apply on that site (when the principal acts there).
211
+ */
212
+ const NETWORK_CONTENT_ACTIONS = /* @__PURE__ */ new Set(["schemaManage"]);
213
+ /**
214
+ * Actions that are not scoped to one model, in two kinds (sites plan §H):
215
+ * - Network actions are about the whole instance (creating models, managing admin users, roles and sites,
216
+ * reading the audit log). Only roles assigned on every site grant them: a role held on one site never does,
217
+ * so a site admin cannot grant themselves other sites.
218
+ * - Site actions are about one site's library, tokens and publishing; any role held on the site grants them.
219
+ */
220
+ const NETWORK_ACTIONS = [
221
+ "schema.create",
222
+ "users.manage",
223
+ "roles.manage",
224
+ "audit.read",
225
+ "sites.manage"
226
+ ];
227
+ /**
228
+ * Network actions a role held on one site also grants for that site alone (plan site-schema, rule 4):
229
+ * `schema.create` creates that site's own definitions. They are checked with `canPerformOnSite` and the
230
+ * request's site; `canPerform` keeps the every-site meaning (shared definitions, locales, the schema lock).
231
+ */
232
+ const SITE_GRANTABLE_ACTION_LIST = ["schema.create"];
233
+ const SITE_GRANTABLE_ACTIONS = new Set(SITE_GRANTABLE_ACTION_LIST);
234
+ const SITE_ACTIONS = [
235
+ "tokens.manage",
236
+ "media.read",
237
+ "media.write",
238
+ "media.manage",
239
+ "publishing.manage",
240
+ "webhooks.manage",
241
+ "deployments.manage",
242
+ "deployments.trigger",
243
+ "changes.manage",
244
+ "changes.ship",
245
+ "site.settings"
246
+ ];
247
+ const GLOBAL_ACTIONS = [...NETWORK_ACTIONS, ...SITE_ACTIONS];
248
+ const EMPTY_MASK$1 = {
249
+ mode: "only",
250
+ fieldIds: []
251
+ };
252
+ const DENIED_POLICY = Object.freeze({
253
+ allowed: false,
254
+ rowFilter: null,
255
+ readMask: EMPTY_MASK$1,
256
+ writeMask: EMPTY_MASK$1
257
+ });
258
+ //#endregion
259
+ //#region ../../apps/api/src/routes/schemas/adminIdentity.ts
260
+ /** Schemas shared by the admin identity routes (setup, auth, users, roles, tokens). */
261
+ /** A timestamp in a response: a Date in handlers, ISO-8601 on the wire (fast-json-stringify formats it). */
262
+ const DateTimeSchema = Type.Unsafe({
263
+ type: "string",
264
+ format: "date-time"
265
+ });
266
+ const NullableDateTimeSchema = Type.Union([DateTimeSchema, Type.Null()]);
267
+ Type.String({ format: "date-time" });
268
+ const UuidSchema = Type.String({ format: "uuid" });
269
+ Type.Object({ id: UuidSchema });
270
+ Type.String({
271
+ format: "email",
272
+ minLength: 3,
273
+ maxLength: 254
274
+ });
275
+ Type.String({
276
+ minLength: 12,
277
+ maxLength: 256
278
+ });
279
+ Type.String({
280
+ minLength: 1,
281
+ maxLength: 256
282
+ });
283
+ Type.String({
284
+ minLength: 1,
285
+ maxLength: 200
286
+ });
287
+ Type.String({
288
+ minLength: 16,
289
+ maxLength: 128,
290
+ pattern: "^[A-Za-z0-9_-]+$"
291
+ });
292
+ /** A role held on one site, or on every site (`siteId` null). */
293
+ const RoleAssignmentSchema = Type.Object({
294
+ roleId: UuidSchema,
295
+ siteId: Type.Union([UuidSchema, Type.Null()])
296
+ }, { additionalProperties: false });
297
+ const AdminUserSchema = Type.Object({
298
+ id: UuidSchema,
299
+ email: Type.String(),
300
+ name: Type.String(),
301
+ status: Type.Union([Type.Literal("active"), Type.Literal("disabled")]),
302
+ /** Where each role applies. */
303
+ assignments: Type.Array(RoleAssignmentSchema),
304
+ /** Deprecated: the distinct roles of `assignments`, on any site. */
305
+ roleIds: Type.Array(UuidSchema),
306
+ lastLoginAt: NullableDateTimeSchema,
307
+ createdAt: DateTimeSchema,
308
+ updatedAt: DateTimeSchema
309
+ });
310
+ Type.Object({
311
+ user: AdminUserSchema,
312
+ csrfToken: Type.String()
313
+ });
314
+ Type.Enum(CONTENT_ACTIONS);
315
+ Type.Enum(GLOBAL_ACTIONS);
316
+ Type.Enum(NETWORK_ACTIONS);
317
+ Type.Enum(SITE_ACTIONS);
318
+ Type.Enum([...SITE_ACTIONS, ...SITE_GRANTABLE_ACTION_LIST]);
319
+ Type.Object({
320
+ action: Type.Enum([...CONTENT_ACTIONS, ...GLOBAL_ACTIONS]),
321
+ modelId: Type.Union([Type.String({
322
+ minLength: 1,
323
+ maxLength: 100
324
+ }), Type.Null()]),
325
+ condition: Type.Union([Type.Literal("ownedByPrincipal"), Type.Null()]),
326
+ fieldIds: Type.Union([Type.Array(Type.String({
327
+ minLength: 1,
328
+ maxLength: 100
329
+ }), { maxItems: 500 }), Type.Null()])
330
+ }, { additionalProperties: false });
331
+ //#endregion
332
+ //#region ../../apps/api/src/routes/schemas/error.ts
333
+ /** The one error shape every endpoint returns (central error handler). */
334
+ const ErrorResponseSchema = Type.Object({ error: Type.Object({
335
+ code: Type.String(),
336
+ message: Type.String(),
337
+ details: Type.Optional(Type.Unknown())
338
+ }) });
339
+ //#endregion
340
+ //#region ../../apps/api/src/routes/snapshots/schemas.ts
341
+ const SeqSchema = Type.Integer({ minimum: 0 });
342
+ const NullableInteger = Type.Union([Type.Integer(), Type.Null()]);
343
+ const ERRORS = {
344
+ 400: ErrorResponseSchema,
345
+ 401: ErrorResponseSchema
346
+ };
347
+ const SnapshotChangesQuerySchema = Type.Object({
348
+ from: SeqSchema,
349
+ /** Defaults to the current snapshot. */
350
+ to: Type.Optional(SeqSchema),
351
+ /** `nextCursor` of the previous page. */
352
+ after: Type.Optional(UuidSchema),
353
+ limit: Type.Optional(Type.Integer({
354
+ minimum: 1,
355
+ maximum: 500
356
+ }))
357
+ }, { additionalProperties: false });
358
+ const SnapshotChangeSchema = Type.Object({
359
+ id: UuidSchema,
360
+ modelId: UuidSchema,
361
+ modelKey: Type.String(),
362
+ routeKey: Type.String(),
363
+ /** From the revision live at `to` (at `from` when unpublished); null without a readable title. */
364
+ title: Type.Union([Type.String(), Type.Null()]),
365
+ /** The cover image's media asset ID, when the model has a readable cover. */
366
+ coverMediaId: Type.Union([UuidSchema, Type.Null()]),
367
+ locales: Type.Array(Type.Object({
368
+ locale: Type.String(),
369
+ change: Type.Enum([
370
+ "published",
371
+ "updated",
372
+ "unpublished"
373
+ ]),
374
+ /** The revision live at `to`, or at `from` for `unpublished`. */
375
+ revisionId: Type.Union([UuidSchema, Type.Null()])
376
+ }))
377
+ });
378
+ Type.Object({
379
+ from: Type.Integer(),
380
+ to: Type.Integer(),
381
+ schemaVersions: Type.Object({
382
+ from: NullableInteger,
383
+ to: NullableInteger
384
+ }),
385
+ items: Type.Array(SnapshotChangeSchema),
386
+ nextCursor: Type.Union([UuidSchema, Type.Null()])
387
+ }), { ...ERRORS };
388
+ Type.Object({
389
+ snapshot: Type.Integer(),
390
+ schemaVersion: Type.Integer(),
391
+ publishedAt: NullableDateTimeSchema
392
+ }), { ...ERRORS };
393
+ //#endregion
394
+ //#region ../../apps/api/src/content/compiler/types.ts
395
+ /** The parsed, allowlisted form of a content query. Nothing in it is raw request text used as SQL. */
396
+ const FILTER_OPERATORS = [
397
+ "$eq",
398
+ "$ne",
399
+ "$in",
400
+ "$nin",
401
+ "$lt",
402
+ "$lte",
403
+ "$gt",
404
+ "$gte",
405
+ "$contains",
406
+ "$notContains",
407
+ "$containsi",
408
+ "$startsWith",
409
+ "$endsWith",
410
+ "$null",
411
+ "$notNull"
412
+ ];
413
+ /** System attributes every entry has. They are not stored in `data`. */
414
+ const SYSTEM_ATTRIBUTES = [
415
+ "id",
416
+ "createdAt",
417
+ "updatedAt"
418
+ ];
419
+ /**
420
+ * Delivery shapes of a rich-text value (ADR 0003): `json` the stored document `{ format, version, doc }`,
421
+ * `html` `{ format, version, html }` (sanitized HTML rendered from the document), `both` the document plus
422
+ * `html`.
423
+ */
424
+ const RICH_TEXT_MODES = [
425
+ "json",
426
+ "html",
427
+ "both"
428
+ ];
429
+ /** `?seo=`: SEO fields as stored, or merged with the site's SEO defaults (plan seo-fields). */
430
+ const SEO_MODES = ["raw", "resolved"];
431
+ const ENTRY_LIST_STATUSES = [
432
+ "draft",
433
+ "published",
434
+ "modified"
435
+ ];
436
+ const QUERY_LIMITS = {
437
+ maxPageSize: 100,
438
+ defaultPageSize: 25,
439
+ maxConditions: 50,
440
+ maxFilterDepth: 6,
441
+ maxInValues: 100,
442
+ maxValueLength: 1e3,
443
+ maxParameters: 200,
444
+ maxPopulateDepth: 3,
445
+ maxSortTerms: 5
446
+ };
447
+ /** A malformed or disallowed query: 400 for unknown/invalid, 403 for fields the caller may not use. */
448
+ const queryInvalid = (message, details) => new AppError(400, "INVALID_QUERY", message, details);
449
+ const queryForbidden = (message, details) => new AppError(403, "FORBIDDEN_FIELD", message, details);
450
+ //#endregion
451
+ //#region ../../apps/api/src/content/compiler/paginate.ts
452
+ const toLimitOffset = (page, pageSize) => {
453
+ const size = Math.min(Math.max(1, pageSize), QUERY_LIMITS.maxPageSize);
454
+ return {
455
+ limit: size,
456
+ offset: (Math.max(1, page) - 1) * size
457
+ };
458
+ };
459
+ const paginationMeta = (page, pageSize, total) => ({
460
+ page,
461
+ pageSize,
462
+ total,
463
+ pageCount: Math.ceil(total / pageSize)
464
+ });
465
+ //#endregion
466
+ //#region ../../apps/api/src/content/errors.ts
467
+ /** Errors the content services throw; the central error handler renders them. */
468
+ const entryNotFound = (id) => new AppError(404, "ENTRY_NOT_FOUND", `No entry ${id}`, { id });
469
+ const snapshotInvalid = (requested, current) => new AppError(400, "SNAPSHOT_INVALID", `Snapshot ${requested} does not exist yet (latest is ${current})`, {
470
+ requested,
471
+ current
472
+ });
473
+ //#endregion
474
+ //#region ../../apps/api/src/usage/keys.ts
475
+ /**
476
+ * How delivery reads are attributed (plan developer-face §5). Delivery tokens are consumers, one key per
477
+ * token; app users are counted together, and so are anonymous callers. Admin reads (admin users and
478
+ * admin-scope API tokens), system work and previews are not consumers and are never counted.
479
+ */
480
+ const APP_USERS_PRINCIPAL_KEY = "app_users";
481
+ const ANONYMOUS_PRINCIPAL_KEY = "anonymous";
482
+ /** `token:<id>`, `app_users`, `anonymous`, or null for principals whose reads are not counted. */
483
+ const usagePrincipalKey = (principal) => {
484
+ switch (principal.kind) {
485
+ case "token": return principal.scope === "delivery" ? `token:${principal.tokenId}` : null;
486
+ case "appUser": return APP_USERS_PRINCIPAL_KEY;
487
+ case "anonymous": return ANONYMOUS_PRINCIPAL_KEY;
488
+ default: return null;
489
+ }
490
+ };
491
+ /** A populated relation's target field: `<relation field ID>.<target field ID>`. */
492
+ const relationFieldPath = (relationFieldId, targetFieldId) => `${relationFieldId}.${targetFieldId}`;
493
+ //#endregion
494
+ //#region ../../apps/api/src/content/validator/scalars.ts
495
+ const INPUT_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
496
+ const isRealDate = (text) => {
497
+ if (!DATE_PATTERN.test(text)) return false;
498
+ const parsed = /* @__PURE__ */ new Date(`${text}T00:00:00.000Z`);
499
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(text);
500
+ };
501
+ const canonicalDate = (value) => typeof value === "string" && isRealDate(value) ? value : void 0;
502
+ const canonicalDatetime = (value) => {
503
+ if (typeof value !== "string") return;
504
+ const match = INPUT_DATETIME.exec(value);
505
+ if (!match || !isRealDate(`${match[1]}-${match[2]}-${match[3]}`)) return;
506
+ const parsed = new Date(value);
507
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed.toISOString();
508
+ };
509
+ const canonicalTime = (value) => {
510
+ if (typeof value !== "string" || !TIME_PATTERN.test(value)) return;
511
+ const [hours, minutes, rest = "00"] = value.split(":");
512
+ const [seconds = "00", millis = "000"] = rest.split(".");
513
+ return `${hours}:${minutes}:${seconds}.${millis.padEnd(3, "0")}`;
514
+ };
515
+ const canonicalDecimal = (value) => {
516
+ if (typeof value === "number" && Number.isFinite(value)) {
517
+ const text = String(value);
518
+ return DECIMAL_PATTERN.test(text) ? text : void 0;
519
+ }
520
+ return typeof value === "string" && DECIMAL_PATTERN.test(value) ? value : void 0;
521
+ };
522
+ const canonicalBigInteger = (value) => {
523
+ if (typeof value === "number" && Number.isSafeInteger(value)) return String(value);
524
+ return typeof value === "string" && BIGINTEGER_PATTERN.test(value) ? value : void 0;
525
+ };
526
+ //#endregion
527
+ //#region ../../apps/api/src/content/validator/index.ts
528
+ /**
529
+ * The content validator (build plan §4.E2). Built from the active model and the components it embeds,
530
+ * compiled once per combination of schema revisions and cached. Values arrive keyed by API keys at the
531
+ * boundary (`fromInput`) and are validated in storage form, keyed by stable field IDs (`validate`).
532
+ *
533
+ * Storage format of structured values:
534
+ * - component: an object keyed by the component's field IDs (an array of them when repeatable);
535
+ * - dynamic zone: an array of such objects, each with `__component` holding the component's stable ID
536
+ * (its API key at the boundary);
537
+ * - relation: the target entry ID (`one`) or an ordered array of IDs (`many`);
538
+ * - media: an asset ID, or an array of them when `multiple`.
539
+ * Missing values (absent, null, empty string, empty list, empty rich text) are not stored.
540
+ * Keys that no field defines (values of removed fields) are preserved untouched (brief §5: field removal
541
+ * keeps values until an explicit cleanup); deprecated fields' values are kept but not validated.
542
+ */
543
+ const COMPONENT_KEY = "__component";
544
+ //#endregion
545
+ //#region ../../apps/api/src/db/dialect.ts
546
+ const SQLITE_PREFIX = "sqlite:";
547
+ const MEMORY = ":memory:";
548
+ /** The dialect a DATABASE_URL names, or undefined when it names none of them. */
549
+ const dialectOfUrl = (url) => {
550
+ if (url.startsWith(SQLITE_PREFIX)) return "sqlite";
551
+ if (url.startsWith("mysql://")) return "mysql";
552
+ return /^postgres(ql)?:\/\//.test(url) ? "postgres" : void 0;
553
+ };
554
+ /**
555
+ * Where a `sqlite:` URL points: `sqlite::memory:` or `sqlite:<path>` (relative to the working directory or
556
+ * absolute; `sqlite:///abs/path` is accepted too). Throws when the path is empty.
557
+ */
558
+ const sqliteLocationOfUrl = (url) => {
559
+ const rest = url.slice(7);
560
+ if (rest === MEMORY) return { kind: "memory" };
561
+ const path = rest.startsWith("//") ? rest.slice(2) : rest;
562
+ if (path === "") throw new Error("A sqlite: DATABASE_URL needs a file path, e.g. sqlite:./shapio.db");
563
+ return {
564
+ kind: "file",
565
+ path
566
+ };
567
+ };
568
+ /** `host:port/database` (PostgreSQL, MySQL) or the file path (SQLite) from DATABASE_URL, never the credentials. */
569
+ const describeDatabaseTarget = (connectionString) => {
570
+ if (dialectOfUrl(connectionString) === "sqlite") {
571
+ const location = sqliteLocationOfUrl(connectionString);
572
+ return location.kind === "memory" ? "an in-memory database" : location.path;
573
+ }
574
+ try {
575
+ const url = new URL(connectionString);
576
+ const defaultPort = dialectOfUrl(connectionString) === "mysql" ? "3306" : "5432";
577
+ return `${url.hostname}:${url.port || defaultPort}${url.pathname}`;
578
+ } catch {
579
+ return "the configured database";
580
+ }
581
+ };
582
+ let current;
583
+ /** The dialect queries are built for. PostgreSQL until a database handle says otherwise. */
584
+ const currentDialect = () => current ?? "postgres";
585
+ /**
586
+ * Sets the process dialect. `createDb` calls it; one process talks to one dialect, so switching after a
587
+ * handle exists is a bug (`force` is for unit tests that compile SQL for both dialects without a database).
588
+ */
589
+ const setCurrentDialect = (dialect, { force = false } = {}) => {
590
+ if (!force && current !== void 0 && current !== dialect) throw new Error(`This process already uses ${current}; it cannot also open a ${dialect} database`);
591
+ current = dialect;
592
+ };
593
+ const isSqlite = () => currentDialect() === "sqlite";
594
+ const isMysql = () => currentDialect() === "mysql";
595
+ //#endregion
596
+ //#region ../../apps/api/src/db/mysql/fieldIndexColumns.ts
597
+ /**
598
+ * On MySQL a field index is built on an invisible virtual column holding the field's value expression
599
+ * (`content/compiler/dialect/mysql.ts`); the column is named after the index: `eh_<hash>` → `ev_<hash>`.
600
+ */
601
+ const fieldIndexColumnName = (indexName) => indexName.replace(/^eh_/, "ev_");
602
+ //#endregion
603
+ //#region ../../apps/api/src/content/compiler/heads.ts
604
+ /** Content heads (package E). Expression indexes live on this table only. */
605
+ const CONTENT_HEADS_TABLE = "entry_heads";
606
+ /** Columns of `entry_heads` the index layout depends on. Package E must keep these names. */
607
+ const CONTENT_HEADS_COLUMNS = {
608
+ data: "data",
609
+ siteId: "site_id",
610
+ modelId: "model_id",
611
+ locale: "locale",
612
+ state: "state"
613
+ };
614
+ //#endregion
615
+ //#region ../../apps/api/src/content/compiler/dialect/mysql.ts
616
+ /**
617
+ * MySQL content SQL (ADR 0001, "MySQL"). `data` is a JSON column; there is no containment index and no
618
+ * partial index, so:
619
+ * - each filterable or sortable field gets an invisible virtual column holding its value expression and one
620
+ * index `(site_id, model_id[, locale], state, column)`, built online (`ALGORITHM=INPLACE, LOCK=NONE`; a
621
+ * functional index cannot be). Queries use the expression; MySQL matches it to the column for `=`, ranges
622
+ * and ORDER BY (it does not for `<=>`, so equality is `expr = ? and expr is not null`: false, never null,
623
+ * for a missing value, as containment is);
624
+ * - values are read with `json_value`, which gives SQL NULL for JSON null (`->>` gives the text 'null');
625
+ * text indexes and compares the first 255 characters (an index key is bounded), and equality with a longer
626
+ * value also compares the whole text; numbers compare as DOUBLE, decimal and biginteger strings as
627
+ * DECIMAL(65,30) (parameters are cast the same way, or MySQL does not use the column);
628
+ * - text compares in the session's binary `utf8mb4_0900_bin` collation (case-sensitive, code point order),
629
+ * matched with `locate`, so no LIKE escaping; `$containsi` and search fold both sides with `lower()`;
630
+ * - list membership is `member of` (unindexed); bound lists are `in (?, …)`;
631
+ * - JSON functions return `utf8mb4_bin` text, which MySQL refuses to compare with a literal in the session's
632
+ * collation, so JSON is compared as JSON (`= cast('null' as json)`) or the literal's collation is named;
633
+ * - NULL placement in sorts is written out: `(expr is null), expr`.
634
+ */
635
+ const DATA$2 = sql.ref(CONTENT_HEADS_COLUMNS.data);
636
+ /** Longest text `json_value` returns; longer values read as NULL. */
637
+ const TEXT_LIMIT = 1e6;
638
+ /**
639
+ * The JSON path of a field. The `_utf8mb4` introducer gives the literal the character set's own collation
640
+ * whatever the session's: MySQL stores a generated column's expression that way, and only matches a query's
641
+ * expression to the column (to use its index) when every literal in it has the same collation.
642
+ */
643
+ const path = (fieldId) => sql`_utf8mb4${sql.lit(`$."${fieldId}"`)}`;
644
+ const fieldJson$1 = (fieldId) => sql`json_extract(${DATA$2}, ${path(fieldId)})`;
645
+ const fieldText$2 = (fieldId) => sql`json_value(${DATA$2}, ${path(fieldId)} returning char(${sql.lit(TEXT_LIMIT)}))`;
646
+ /** decimal and biginteger: exact to 35 integer and 30 fractional digits; other numbers are doubles. */
647
+ const DECIMAL = sql`decimal(65,30)`;
648
+ const fieldValue$2 = (fieldId, cast, numericString = false) => {
649
+ switch (cast) {
650
+ case "numeric": return numericString ? sql`json_value(${DATA$2}, ${path(fieldId)} returning ${DECIMAL})` : sql`json_value(${DATA$2}, ${path(fieldId)} returning double)`;
651
+ case "boolean": return sql`json_value(${DATA$2}, ${path(fieldId)} returning char(5))`;
652
+ default: return sql`left(${fieldText$2(fieldId)}, ${sql.lit(255)})`;
653
+ }
654
+ };
655
+ const booleanText = (value) => value === true || value === "true" ? "true" : "false";
656
+ const castParameter$3 = (value, cast, numericString = false) => {
657
+ switch (cast) {
658
+ case "numeric": return numericString ? sql`cast(${value} as ${DECIMAL})` : sql`cast(${value} as double)`;
659
+ case "boolean": return sql`${booleanText(value)}`;
660
+ default: return sql`${value}`;
661
+ }
662
+ };
663
+ const listValue = (value, element) => element === "timestamp" ? toDatetimeText(value) : value;
664
+ /** `expr = ? and expr is not null`: false (not null) for a missing value, and still index-served. */
665
+ const presentAndEqual = (expression, value) => sql`(${expression} = ${value} and ${expression} is not null)`;
666
+ const textEquals = (fieldId, value) => {
667
+ const prefix = fieldValue$2(fieldId, "text");
668
+ if (typeof value === "string" && [...value].length < 255) return presentAndEqual(prefix, sql`${value}`);
669
+ return sql`(${prefix} = left(${value}, ${sql.lit(255)}) and ${fieldText$2(fieldId)} = ${value})`;
670
+ };
671
+ const COLUMN_TYPES = {
672
+ text: sql`varchar(${sql.lit(255)}) collate utf8mb4_0900_bin`,
673
+ numeric: sql`double`,
674
+ boolean: sql`varchar(5) collate utf8mb4_0900_bin`
675
+ };
676
+ const addIndexColumn = ({ name, table, expression, cast, numericString }) => sql`alter table ${sql.table(table)} add column ${sql.id(fieldIndexColumnName(name))} ${numericString ? DECIMAL : COLUMN_TYPES[cast]}
677
+ generated always as (${expression}) virtual invisible, algorithm=inplace, lock=none`;
678
+ const mysqlContentDialect = {
679
+ name: "mysql",
680
+ uuid: (value) => sql`${value}`,
681
+ timestamp: (value) => sql`${toDatetimeText(value)}`,
682
+ bool: (value) => value ? sql`true` : sql`false`,
683
+ oneOf: (column, values, element) => values.length === 0 ? sql`false` : sql`${column} in (${sql.join(values.map((value) => listValue(value, element)))})`,
684
+ fieldValue: fieldValue$2,
685
+ fieldText: fieldText$2,
686
+ fieldJson: fieldJson$1,
687
+ castParameter: castParameter$3,
688
+ fieldEquals: (target, value) => {
689
+ if (target.list) {
690
+ const json = fieldJson$1(target.fieldId);
691
+ return sql`(${json} is not null and json_type(${json}) = 'ARRAY' collate utf8mb4_bin and ${value} member of (${json}))`;
692
+ }
693
+ if (target.numericString && target.cast) return sql`(${fieldValue$2(target.fieldId, target.cast, true)} = ${castParameter$3(value, target.cast, true)})`;
694
+ if (target.cast === null || target.cast === "text") return target.cast === null ? presentAndEqual(fieldText$2(target.fieldId), sql`${value}`) : textEquals(target.fieldId, value);
695
+ return presentAndEqual(fieldValue$2(target.fieldId, target.cast), castParameter$3(value, target.cast));
696
+ },
697
+ fieldMissing: (fieldId) => {
698
+ const json = fieldJson$1(fieldId);
699
+ return sql`(${json} is null or ${json} = cast('null' as json) or ${json} = cast('""' as json) or ${json} = json_array())`;
700
+ },
701
+ textMatch: (fieldId, operator, text) => {
702
+ const value = fieldText$2(fieldId);
703
+ switch (operator) {
704
+ case "$startsWith": return sql`(locate(${text}, ${value}) = 1)`;
705
+ case "$endsWith": return sql`(right(${value}, ${sql.lit([...text].length)}) = ${text})`;
706
+ case "$notContains": return sql`(locate(${text}, coalesce(${value}, '')) = 0)`;
707
+ default: return sql`(locate(${text}, ${value}) > 0)`;
708
+ }
709
+ },
710
+ containsInsensitive: (fieldId, text) => sql`(locate(lower(${text}), lower(${fieldText$2(fieldId)})) > 0)`,
711
+ localeRank: (chain, column) => sql`(case ${column} ${sql.join(chain.map((locale, index) => sql`when ${locale} then ${sql.lit(index)}`), sql` `)} end)`,
712
+ sortDirection: (direction) => direction === "desc" ? sql`desc` : sql`asc`,
713
+ sortTerm: (expression, direction, nullable) => {
714
+ const order = direction === "desc" ? sql`desc` : sql`asc`;
715
+ return nullable ? sql`(${expression} is null) ${order}, ${expression} ${order}` : sql`${expression} ${order}`;
716
+ },
717
+ snapshotCte: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
718
+ select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
719
+ r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
720
+ en.created_at as entry_created_at
721
+ from publication_log pl
722
+ join content_revisions r on r.id = pl.revision_id
723
+ join entries en on en.id = pl.entry_id
724
+ where pl.site_id = ${siteId} and pl.model_id = ${modelId}
725
+ and pl.from_seq <= ${seq}
726
+ and (pl.to_seq is null or pl.to_seq > ${seq})
727
+ ) `,
728
+ fieldIndexColumn: {
729
+ add: addIndexColumn,
730
+ drop: (name) => sql`alter table ${sql.table(CONTENT_HEADS_TABLE)} drop column ${sql.id(fieldIndexColumnName(name))}, algorithm=inplace, lock=none`
731
+ },
732
+ createFieldIndex: ({ name, table, localized, modelIdColumn }) => {
733
+ const { siteId, locale, state } = CONTENT_HEADS_COLUMNS;
734
+ const leading = localized ? sql`${sql.ref(siteId)}, ${sql.ref(modelIdColumn)}, ${sql.ref(locale)}, ${sql.ref(state)}` : sql`${sql.ref(siteId)}, ${sql.ref(modelIdColumn)}, ${sql.ref(state)}`;
735
+ return sql`create index ${sql.id(name)} on ${sql.table(table)} (${leading}, ${sql.id(fieldIndexColumnName(name))}) algorithm=inplace lock=none`;
736
+ },
737
+ createFieldStatistics: () => null,
738
+ dropFieldIndex: (name) => sql`drop index ${sql.id(name)} on ${sql.table(CONTENT_HEADS_TABLE)} algorithm=inplace lock=none`,
739
+ dropFieldStatistics: () => null,
740
+ analyze: (table) => sql`analyze table ${sql.table(table)}`
741
+ };
742
+ //#endregion
743
+ //#region ../../apps/api/src/content/compiler/dialect/postgres.ts
744
+ /**
745
+ * PostgreSQL content SQL (ADR 0001): JSONB with a GIN `jsonb_path_ops` index serving containment, so
746
+ * equality on a field without an index is emitted as `data @> {...}`; ranges, sorts and equality on indexed
747
+ * fields (filterable or sortable) use per-field B-tree expression indexes built `CONCURRENTLY` with a
748
+ * matching `CREATE STATISTICS` object.
749
+ */
750
+ const DATA$1 = sql.ref(CONTENT_HEADS_COLUMNS.data);
751
+ const ARRAY_TYPES$1 = {
752
+ uuid: sql`uuid[]`,
753
+ text: sql`text[]`,
754
+ timestamp: sql`timestamptz[]`
755
+ };
756
+ const MISSING_JSON = sql`('null'::jsonb, '""'::jsonb, '[]'::jsonb)`;
757
+ /** LIKE escaping (the default `\` escape character). */
758
+ const escapeLike = (text) => text.replace(/[\\%_]/g, (char) => `\\${char}`);
759
+ const likePattern = (operator, text) => {
760
+ const escaped = escapeLike(text);
761
+ switch (operator) {
762
+ case "$startsWith": return `${escaped}%`;
763
+ case "$endsWith": return `%${escaped}`;
764
+ default: return `%${escaped}%`;
765
+ }
766
+ };
767
+ const fieldText$1 = (fieldId) => sql`(${DATA$1} ->> ${sql.lit(fieldId)})`;
768
+ const fieldValue$1 = (fieldId, cast) => {
769
+ const text = fieldText$1(fieldId);
770
+ switch (cast) {
771
+ case "numeric": return sql`(${text}::numeric)`;
772
+ case "boolean": return sql`(${text}::boolean)`;
773
+ default: return text;
774
+ }
775
+ };
776
+ const castParameter$2 = (value, cast) => {
777
+ switch (cast) {
778
+ case "numeric": return sql`${value}::numeric`;
779
+ case "boolean": return sql`${value}::boolean`;
780
+ default: return sql`${value}::text`;
781
+ }
782
+ };
783
+ /**
784
+ * `data @> $1::jsonb`: the only form the GIN index serves. The document is a parameter, field IDs included,
785
+ * so nothing from the request is ever spliced into the SQL text.
786
+ */
787
+ const containment = (document) => sql`(${DATA$1} @> ${JSON.stringify(document)}::jsonb)`;
788
+ const postgresContentDialect = {
789
+ name: "postgres",
790
+ uuid: (value) => sql`${value}::uuid`,
791
+ timestamp: (value) => sql`${value}::timestamptz`,
792
+ bool: (value) => sql`${value}::boolean`,
793
+ oneOf: (column, values, element) => sql`${column} = any(${[...values]}::${ARRAY_TYPES$1[element]})`,
794
+ fieldValue: fieldValue$1,
795
+ fieldText: fieldText$1,
796
+ fieldJson: (fieldId) => sql`(${DATA$1} -> ${sql.lit(fieldId)})`,
797
+ castParameter: castParameter$2,
798
+ fieldEquals: (target, value) => {
799
+ if (target.numericString && target.cast) return sql`(${fieldValue$1(target.fieldId, target.cast)} = ${castParameter$2(value, target.cast)})`;
800
+ if (target.indexed && target.cast) {
801
+ const expression = fieldValue$1(target.fieldId, target.cast);
802
+ return sql`(${expression} = ${castParameter$2(value, target.cast)} and ${expression} is not null)`;
803
+ }
804
+ return containment({ [target.fieldId]: target.list ? [value] : value });
805
+ },
806
+ fieldMissing: (fieldId) => {
807
+ const json = postgresContentDialect.fieldJson(fieldId);
808
+ return sql`(${json} is null or ${json} in ${MISSING_JSON})`;
809
+ },
810
+ textMatch: (fieldId, operator, text) => operator === "$notContains" ? sql`(coalesce(${fieldText$1(fieldId)}, '') not like ${likePattern(operator, text)})` : sql`(${fieldText$1(fieldId)} like ${likePattern(operator, text)})`,
811
+ containsInsensitive: (fieldId, text) => sql`(${fieldText$1(fieldId)} ilike ${likePattern("$containsi", text)})`,
812
+ localeRank: (chain, column) => sql`array_position(${[...chain]}::text[], ${column})`,
813
+ sortDirection: (direction) => direction === "desc" ? sql`desc` : sql`asc`,
814
+ snapshotCte: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
815
+ select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published'::text as state, r.data, 0 as version,
816
+ r.id as revision_id, pl.published_at as updated_at, null::timestamptz as autosaved_at,
817
+ en.created_at as entry_created_at
818
+ from publication_log pl
819
+ join content_revisions r on r.id = pl.revision_id
820
+ join entries en on en.id = pl.entry_id
821
+ where pl.site_id = ${siteId}::uuid and pl.model_id = ${modelId}
822
+ and pl.from_seq <= ${seq}::bigint
823
+ and (pl.to_seq is null or pl.to_seq > ${seq}::bigint)
824
+ ) `,
825
+ createFieldIndex: ({ name, table, leading, expression, modelIdColumn, modelId }) => sql`create index concurrently if not exists ${sql.id(name)} on ${sql.table(table)} (${leading}, ${expression}) where ${sql.ref(modelIdColumn)} = ${modelId}`,
826
+ createFieldStatistics: (name, expression, table) => sql`create statistics if not exists ${sql.id(name)} on ${expression} from ${sql.table(table)}`,
827
+ dropFieldIndex: (name) => sql`drop index concurrently if exists ${sql.id(name)}`,
828
+ dropFieldStatistics: (name) => sql`drop statistics if exists ${sql.id(name)}`,
829
+ analyze: (table) => sql`analyze ${sql.table(table)}`
830
+ };
831
+ //#endregion
832
+ //#region ../../apps/api/src/content/compiler/dialect/sqlite.ts
833
+ /**
834
+ * SQLite content SQL (ADR 0001, "D2: as built"). `data` is JSON text read with JSON1's `->`/`->>`; there is
835
+ * no GIN index and no containment, so:
836
+ * - one partial expression index per filterable or sortable field serves equality, ranges and sorts, and
837
+ * scalar equality is emitted on that same expression (`expr is ?`, which the planner treats like `=` and
838
+ * which is false, never null, for a missing value, as containment is);
839
+ * - list membership is `json_each` (unindexed; the model's heads are scanned);
840
+ * - LIKE folds ASCII case, so case-sensitive matches use `instr`/`substr`, and case-insensitive ones use
841
+ * `shapio_fold` (JavaScript `toLowerCase`, a deterministic function registered on every connection by
842
+ * the database layer) on the column and the same folding of the parameter in JavaScript;
843
+ * - there are no `::` casts and no array parameters: lists are bound as one JSON text parameter, and
844
+ * timestamps as the stored text (`toStoredTimestamp`), which compares as time;
845
+ * - indexes are built with a plain `CREATE INDEX` (no `CONCURRENTLY`: the build holds the single write lock
846
+ * until it finishes) and there is no `CREATE STATISTICS`.
847
+ */
848
+ const DATA = sql.ref(CONTENT_HEADS_COLUMNS.data);
849
+ /** The name of the case-folding SQL function the database layer registers on each SQLite connection. */
850
+ const FOLD_FUNCTION = "shapio_fold";
851
+ /** What `shapio_fold` computes, for the parameter side (and the function's registration). */
852
+ const foldText = (text) => text.toLowerCase();
853
+ const listParameter = (values, element) => JSON.stringify(element === "timestamp" ? values.map((value) => toStoredTimestamp(value)) : [...values]);
854
+ const fieldText = (fieldId) => sql`(${DATA} ->> ${sql.lit(fieldId)})`;
855
+ const fieldJson = (fieldId) => sql`(${DATA} -> ${sql.lit(fieldId)})`;
856
+ /** Booleans extract as 1/0 and numbers as numbers; decimal and biginteger strings need the cast. */
857
+ const fieldValue = (fieldId, cast) => cast === "numeric" ? sql`cast(${fieldText(fieldId)} as numeric)` : fieldText(fieldId);
858
+ const castParameter$1 = (value, cast) => {
859
+ switch (cast) {
860
+ case "numeric": return sql`cast(${value} as numeric)`;
861
+ case "boolean": return sql`${value === true || value === "true" ? 1 : 0}`;
862
+ default: return sql`${value}`;
863
+ }
864
+ };
865
+ //#endregion
866
+ //#region ../../apps/api/src/content/compiler/dialect/index.ts
867
+ const DIALECTS = {
868
+ postgres: postgresContentDialect,
869
+ sqlite: {
870
+ name: "sqlite",
871
+ uuid: (value) => sql`${value}`,
872
+ timestamp: (value) => sql`${toStoredTimestamp(value)}`,
873
+ bool: (value) => value ? sql`true` : sql`false`,
874
+ oneOf: (column, values, element) => sql`${column} in (select value from json_each(${listParameter(values, element)}))`,
875
+ fieldValue,
876
+ fieldText,
877
+ fieldJson,
878
+ castParameter: castParameter$1,
879
+ fieldEquals: (target, value) => {
880
+ if (target.list) {
881
+ const json = fieldJson(target.fieldId);
882
+ return sql`(json_type(${json}) = 'array' and exists (select 1 from json_each(${json}) where value = ${value}))`;
883
+ }
884
+ if (target.numericString && target.cast) return sql`(${fieldValue(target.fieldId, target.cast)} = ${castParameter$1(value, target.cast)})`;
885
+ const expression = target.cast ? fieldValue(target.fieldId, target.cast) : fieldText(target.fieldId);
886
+ return sql`(${expression} is ${castParameter$1(value, target.cast ?? "text")})`;
887
+ },
888
+ fieldMissing: (fieldId) => {
889
+ const json = fieldJson(fieldId);
890
+ return sql`(${json} is null or ${json} in ('null', '""', '[]'))`;
891
+ },
892
+ textMatch: (fieldId, operator, text) => {
893
+ const value = fieldText(fieldId);
894
+ switch (operator) {
895
+ case "$startsWith": return sql`(instr(${value}, ${text}) = 1)`;
896
+ case "$endsWith": return sql`(substr(${value}, -length(${text})) = ${text})`;
897
+ case "$notContains": return sql`(instr(coalesce(${value}, ''), ${text}) = 0)`;
898
+ default: return sql`(instr(${value}, ${text}) > 0)`;
899
+ }
900
+ },
901
+ containsInsensitive: (fieldId, text) => sql`(instr(${sql.raw(FOLD_FUNCTION)}(${fieldText(fieldId)}), ${foldText(text)}) > 0)`,
902
+ localeRank: (chain, column) => sql`(case ${column} ${sql.join(chain.map((locale, index) => sql`when ${locale} then ${sql.lit(index)}`), sql` `)} end)`,
903
+ sortDirection: (direction, nullable) => {
904
+ if (!nullable) return direction === "desc" ? sql`desc` : sql`asc`;
905
+ return direction === "desc" ? sql`desc nulls first` : sql`asc nulls last`;
906
+ },
907
+ snapshotCte: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
908
+ select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
909
+ r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
910
+ en.created_at as entry_created_at
911
+ from publication_log pl
912
+ join content_revisions r on r.id = pl.revision_id
913
+ join entries en on en.id = pl.entry_id
914
+ where pl.site_id = ${siteId} and pl.model_id = ${modelId}
915
+ and pl.from_seq <= ${seq}
916
+ and (pl.to_seq is null or pl.to_seq > ${seq})
917
+ ) `,
918
+ createFieldIndex: ({ name, table, leading, expression, modelIdColumn, modelId }) => sql`create index if not exists ${sql.id(name)} on ${sql.table(table)} (${leading}, ${expression}) where ${sql.ref(modelIdColumn)} = ${modelId}`,
919
+ createFieldStatistics: () => null,
920
+ dropFieldIndex: (name) => sql`drop index if exists ${sql.id(name)}`,
921
+ dropFieldStatistics: () => null,
922
+ analyze: (table) => sql`analyze ${sql.table(table)}`
923
+ },
924
+ mysql: mysqlContentDialect
925
+ };
926
+ const contentDialectFor = (name) => DIALECTS[name];
927
+ //#endregion
928
+ //#region ../../apps/api/src/content/compiler/currentDialect.ts
929
+ /**
930
+ * The content dialect of this process's database (`db/dialect.ts`, chosen from `DATABASE_URL`). Every
931
+ * compiler function takes a dialect as an optional last argument that defaults to this one.
932
+ */
933
+ const contentDialect = () => contentDialectFor(currentDialect());
934
+ //#endregion
935
+ //#region ../../apps/api/src/content/compiler/policy.ts
936
+ /**
937
+ * Policies in content queries (ADR 0005): row filters compile to SQL here (and only here); masks decide
938
+ * which fields a caller sees, may filter on and may write. Row-filter conditions are an enumerated set,
939
+ * never free-form expressions.
940
+ */
941
+ /** Entry columns row conditions read. `e` is the entries alias of every head query. */
942
+ const OWNER_COLUMNS = {
943
+ admin: "e.created_by_admin_id",
944
+ appUser: "e.owner_app_user_id"
945
+ };
946
+ const compileRowFilter = (filter, principal, dialect = contentDialect()) => {
947
+ if (!filter) return null;
948
+ switch (filter.kind) {
949
+ case "ownedByPrincipal":
950
+ if (principal.kind === "admin") return sql`${sql.ref(OWNER_COLUMNS.admin)} = ${dialect.uuid(principal.adminUserId)}`;
951
+ if (principal.kind === "appUser") return sql`${sql.ref(OWNER_COLUMNS.appUser)} = ${dialect.uuid(principal.appUserId)}`;
952
+ return sql`false`;
953
+ case "and":
954
+ case "or": {
955
+ const parts = filter.filters.map((child) => compileRowFilter(child, principal, dialect) ?? sql`true`);
956
+ if (parts.length === 0) return dialect.bool(filter.kind === "and");
957
+ return sql`(${sql.join(parts, filter.kind === "and" ? sql` and ` : sql` or `)})`;
958
+ }
959
+ }
960
+ };
961
+ /**
962
+ * Whether a mask lets the caller see a field. A wildcard mask covers every live field; deprecated fields
963
+ * stay hidden unless a mask names them explicitly.
964
+ */
965
+ const maskAllows = (mask, field) => mask.mode === "all" ? !field.deprecated : mask.fieldIds.includes(field.id);
966
+ /**
967
+ * One condition over the entries row `e` for reads spanning several models (the snapshot diff): an entry
968
+ * passes when its model is readable without a row filter, or its model's row filter holds for it.
969
+ */
970
+ const compileModelRowFilters = (models, principal, dialect = contentDialect()) => {
971
+ if (models.every((model) => model.rowFilter === null)) return null;
972
+ const open = models.filter((model) => model.rowFilter === null).map((model) => model.modelId);
973
+ const parts = [...open.length > 0 ? [dialect.oneOf(sql`e.model_id`, open, "uuid")] : [], ...models.flatMap((model) => model.rowFilter === null ? [] : [sql`(e.model_id = ${dialect.uuid(model.modelId)} and ${compileRowFilter(model.rowFilter, principal, dialect) ?? sql`true`})`])];
974
+ return sql`(${sql.join(parts, sql` or `)})`;
975
+ };
976
+ //#endregion
977
+ //#region ../../apps/api/src/content/compiler/select.ts
978
+ /**
979
+ * Storage → API projection with explicit masks (build plan §3.10: generic content routes cannot carry
980
+ * fixed response schemas, so this is where leaks are prevented). Only fields the mask allows, and the
981
+ * caller selected, are emitted; field IDs become API keys; relation IDs are limited to targets the caller
982
+ * may see (published, readable) unless they were populated. Absent values are emitted as null (or [] for
983
+ * lists) so every entry of a model has the same shape.
984
+ */
985
+ /** The top-level fields a response carries: readable, live (unless named by the mask) and selected. */
986
+ const selectFields = (model, mask, selected) => {
987
+ const allowed = model.definition.fields.filter((field) => maskAllows(mask, field));
988
+ return selected ? allowed.filter((field) => selected.some((choice) => choice.id === field.id)) : allowed;
989
+ };
990
+ /**
991
+ * Renders a delivered rich-text value's HTML on demand (GraphQL's `RichText.html` resolver). A symbol key, so
992
+ * `JSON.stringify` never writes it: REST responses carry `html` only when the request asked for it.
993
+ */
994
+ const RICH_TEXT_HTML = Symbol("shapio.richTextHtml");
995
+ const projectRichText = (document, mode, options) => {
996
+ const render = () => renderRichTextHtml(document, (id) => options.mediaAssets?.get(id)?.url);
997
+ const { doc, ...envelope } = document;
998
+ const shaped = mode === "json" ? { ...document } : mode === "html" ? {
999
+ ...envelope,
1000
+ html: render()
1001
+ } : {
1002
+ ...envelope,
1003
+ doc,
1004
+ html: render()
1005
+ };
1006
+ shaped[RICH_TEXT_HTML] = render;
1007
+ return shaped;
1008
+ };
1009
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1010
+ const isListField = (field) => field.type === "relation" && field.settings.cardinality === "many" || field.type === "media" && field.settings.multiple || field.type === "component" && field.settings.repeatable || field.type === "dynamiczone";
1011
+ const projectRelation = (field, value, options, topLevel) => {
1012
+ const ids = (Array.isArray(value) ? value : [value]).filter((id) => typeof id === "string");
1013
+ const populated = topLevel ? options.populated.get(field.id) : void 0;
1014
+ const shown = ids.flatMap((id) => {
1015
+ if (populated) return populated.has(id) ? [populated.get(id)] : [];
1016
+ return options.visibleTargets === null || options.visibleTargets.has(id) ? [id] : [];
1017
+ });
1018
+ return field.settings.cardinality === "many" ? shown : shown[0] ?? null;
1019
+ };
1020
+ const projectComponent = (component, value, options) => component && isRecord$2(value) ? projectFields(component.fields.filter((field) => !field.deprecated), value, options, false) : null;
1021
+ const isStringOrStrings = (value) => typeof value === "string" || Array.isArray(value) && value.every((item) => typeof item === "string");
1022
+ /**
1023
+ * Whether a stored value has the shape the field's current type produces. Old snapshots are served through
1024
+ * the current schema (plan developer-face §5): a value written before a type change (text → rich text) is
1025
+ * not converted at read time, it is emitted as empty instead of being passed off as the new type.
1026
+ */
1027
+ const matchesFieldType = (field, value) => {
1028
+ switch (field.type) {
1029
+ case "number":
1030
+ case "integer": return typeof value === "number";
1031
+ case "boolean": return typeof value === "boolean";
1032
+ case "json": return true;
1033
+ case "richtext": return isRecord$2(value) && isRecord$2(value.doc);
1034
+ case "relation":
1035
+ case "media": return isStringOrStrings(value);
1036
+ case "enum": return field.settings.multiple ? Array.isArray(value) && value.every((item) => typeof item === "string") : typeof value === "string";
1037
+ case "component": return field.settings.repeatable ? Array.isArray(value) : isRecord$2(value);
1038
+ case "dynamiczone": return Array.isArray(value);
1039
+ default: return typeof value === "string";
1040
+ }
1041
+ };
1042
+ const projectValue = (field, value, options, topLevel) => {
1043
+ if (value === void 0 || value === null || !matchesFieldType(field, value)) return isListField(field) ? [] : null;
1044
+ switch (field.type) {
1045
+ case "relation": return projectRelation(field, value, options, topLevel);
1046
+ case "component": {
1047
+ const component = options.model.components.get(field.settings.component)?.definition;
1048
+ return field.settings.repeatable ? (Array.isArray(value) ? value : []).map((item) => projectComponent(component, item, options)) : projectComponent(component, value, options);
1049
+ }
1050
+ case "dynamiczone": return (Array.isArray(value) ? value : []).flatMap((item) => {
1051
+ const component = isRecord$2(item) ? options.model.components.get(String(item[COMPONENT_KEY]))?.definition : void 0;
1052
+ const projected = projectComponent(component, item, options);
1053
+ return component && projected ? [{
1054
+ [COMPONENT_KEY]: component.apiKey,
1055
+ ...projected
1056
+ }] : [];
1057
+ });
1058
+ case "richtext": return options.richText ? projectRichText(value, options.richText, options) : value;
1059
+ case "media": {
1060
+ if (!options.mediaAssets) return value;
1061
+ const assets = (Array.isArray(value) ? value : [value]).flatMap((id) => {
1062
+ const asset = typeof id === "string" ? options.mediaAssets?.get(id) : void 0;
1063
+ return asset ? [asset] : [];
1064
+ });
1065
+ return field.settings.multiple ? assets : assets[0] ?? null;
1066
+ }
1067
+ default: return value;
1068
+ }
1069
+ };
1070
+ function projectFields(fields, data, options, topLevel) {
1071
+ const output = {};
1072
+ for (const field of fields) output[field.apiKey] = projectValue(field, data[field.id], options, topLevel);
1073
+ return output;
1074
+ }
1075
+ /** A head's data as the API returns it. */
1076
+ const projectData = (data, options) => projectFields(options.fields, data, options, true);
1077
+ //#endregion
1078
+ //#region ../../apps/api/src/usage/restFieldReads.ts
1079
+ const restFieldReads = async ({ snapshot, model, policy, query, policyFor }) => {
1080
+ const fields = selectFields(model, policy.readMask, query.fields);
1081
+ const selection = query.fields === null ? "implicit" : "explicit";
1082
+ const reads = fields.map((field) => ({
1083
+ path: field.id,
1084
+ selection
1085
+ }));
1086
+ for (const field of fields) {
1087
+ if (field.type !== "relation" || !query.populate.has(field.id)) continue;
1088
+ const target = resolveModelById(snapshot, field.settings.target);
1089
+ if (!target) continue;
1090
+ const targetPolicy = await policyFor(target.definition.id);
1091
+ if (!targetPolicy.allowed) continue;
1092
+ for (const targetField of selectFields(target, targetPolicy.readMask, null)) reads.push({
1093
+ path: relationFieldPath(field.id, targetField.id),
1094
+ selection: "implicit"
1095
+ });
1096
+ }
1097
+ return reads;
1098
+ };
1099
+ //#endregion
1100
+ //#region ../../apps/api/src/content/compiler/expressions.ts
1101
+ const NUMERIC_TYPES = /* @__PURE__ */ new Set([
1102
+ "number",
1103
+ "integer",
1104
+ "decimal",
1105
+ "biginteger"
1106
+ ]);
1107
+ /** Numbers stored as JSON strings (exact decimals, big integers). */
1108
+ const NUMERIC_STRING_TYPES$1 = /* @__PURE__ */ new Set(["decimal", "biginteger"]);
1109
+ /**
1110
+ * Whether a field has a B-tree expression index: filterable or sortable, and not deprecated. The schema
1111
+ * planner builds an index for exactly these fields (`indexStepsOf`), and PostgreSQL equality compares on
1112
+ * the index expression for them (`EqualityTarget.indexed`).
1113
+ */
1114
+ const hasFieldIndex = (field) => (field.filterable || field.sortable) && !field.deprecated;
1115
+ const valueCastFor = (type) => {
1116
+ if (NUMERIC_TYPES.has(type)) return "numeric";
1117
+ return type === "boolean" ? "boolean" : "text";
1118
+ };
1119
+ var UnsafeIdentifierError = class extends Error {
1120
+ constructor(what) {
1121
+ super(`Refusing to build SQL with ${what}: not a stable ID`);
1122
+ this.name = "UnsafeIdentifierError";
1123
+ }
1124
+ };
1125
+ const assertStableId = (value, what) => {
1126
+ if (!isStableId(value)) throw new UnsafeIdentifierError(what);
1127
+ };
1128
+ /**
1129
+ * The expression for one top-level scalar field of a head's `data`, e.g. `((data ->> '0b1c…')::numeric)`
1130
+ * on PostgreSQL. Throws for non-scalar types or a malformed field ID.
1131
+ */
1132
+ const fieldValueExpression = (fieldId, type, dialect = contentDialect()) => {
1133
+ assertStableId(fieldId, "a field ID");
1134
+ if (!SCALAR_DATA_TYPES.has(type)) throw new Error(`${type} fields have no scalar expression`);
1135
+ return dialect.fieldValue(fieldId, valueCastFor(type), NUMERIC_STRING_TYPES$1.has(type));
1136
+ };
1137
+ /**
1138
+ * Equality on a top-level field (lists: "contains"). PostgreSQL compares the field's index expression when
1139
+ * the field has one (`EqualityTarget.indexed`) and emits containment, which its GIN index serves, otherwise;
1140
+ * SQLite and MySQL compare the field's index expression.
1141
+ */
1142
+ const fieldEqualsExpression = (target, value, dialect = contentDialect()) => {
1143
+ assertStableId(target.fieldId, "a field ID");
1144
+ return dialect.fieldEquals(target, value);
1145
+ };
1146
+ /** The field's value is missing: absent, null, "" or []. */
1147
+ const fieldMissingExpression = (fieldId, dialect = contentDialect()) => {
1148
+ assertStableId(fieldId, "a field ID");
1149
+ return dialect.fieldMissing(fieldId);
1150
+ };
1151
+ /** Case-sensitive text match on a top-level field; `text` is a parameter, never SQL. */
1152
+ const textMatchExpression = (fieldId, operator, text, dialect = contentDialect()) => {
1153
+ assertStableId(fieldId, "a field ID");
1154
+ return dialect.textMatch(fieldId, operator, text);
1155
+ };
1156
+ /** Case-insensitive contains on a top-level field (`$containsi`, search). */
1157
+ const containsInsensitiveExpression = (fieldId, text, dialect = contentDialect()) => {
1158
+ assertStableId(fieldId, "a field ID");
1159
+ return dialect.containsInsensitive(fieldId, text);
1160
+ };
1161
+ /**
1162
+ * The model ID as a SQL literal for `model_id = '…'`. Expression indexes are partial on the model, and
1163
+ * the planner can only prove a partial index's predicate against a literal, never a parameter.
1164
+ */
1165
+ const modelIdLiteral = (modelId) => {
1166
+ assertStableId(modelId, "a model ID");
1167
+ return sql.lit(modelId);
1168
+ };
1169
+ /** A comparison value cast the way `fieldValueExpression` casts the stored value, so indexes match. */
1170
+ const castParameter = (value, type, dialect = contentDialect()) => dialect.castParameter(value, valueCastFor(type), NUMERIC_STRING_TYPES$1.has(type));
1171
+ //#endregion
1172
+ //#region ../../apps/api/src/content/compiler/operators.ts
1173
+ const familyOf = (field) => {
1174
+ switch (field.type) {
1175
+ case "string":
1176
+ case "text":
1177
+ case "slug":
1178
+ case "email":
1179
+ case "url":
1180
+ case "uid": return "text";
1181
+ case "enum": return field.settings.multiple ? "list" : "text";
1182
+ case "date":
1183
+ case "datetime":
1184
+ case "time": return "temporal";
1185
+ case "number":
1186
+ case "integer": return "number";
1187
+ case "decimal":
1188
+ case "biginteger": return "numericString";
1189
+ case "boolean": return "boolean";
1190
+ case "relation":
1191
+ case "media": return "reference";
1192
+ default: return "none";
1193
+ }
1194
+ };
1195
+ const EQUALITY = [
1196
+ "$eq",
1197
+ "$ne",
1198
+ "$in",
1199
+ "$nin",
1200
+ "$null",
1201
+ "$notNull"
1202
+ ];
1203
+ const RANGE = [
1204
+ "$lt",
1205
+ "$lte",
1206
+ "$gt",
1207
+ "$gte"
1208
+ ];
1209
+ const TEXT_MATCH = [
1210
+ "$contains",
1211
+ "$notContains",
1212
+ "$containsi",
1213
+ "$startsWith",
1214
+ "$endsWith"
1215
+ ];
1216
+ const OPERATORS_BY_FAMILY = {
1217
+ text: [
1218
+ ...EQUALITY,
1219
+ ...RANGE,
1220
+ ...TEXT_MATCH
1221
+ ],
1222
+ temporal: [...EQUALITY, ...RANGE],
1223
+ number: [...EQUALITY, ...RANGE],
1224
+ numericString: [...EQUALITY, ...RANGE],
1225
+ boolean: [
1226
+ "$eq",
1227
+ "$ne",
1228
+ "$null",
1229
+ "$notNull"
1230
+ ],
1231
+ list: EQUALITY,
1232
+ reference: EQUALITY,
1233
+ none: []
1234
+ };
1235
+ const isRangeOperator = (operator) => RANGE.includes(operator);
1236
+ const isTextMatchOperator = (operator) => TEXT_MATCH.includes(operator);
1237
+ const isListOperator = (operator) => operator === "$in" || operator === "$nin";
1238
+ const isNullOperator = (operator) => operator === "$null" || operator === "$notNull";
1239
+ /** Whether the field holds a list (equality means "contains"). */
1240
+ const isListValued = (field) => field.type === "enum" && field.settings.multiple || field.type === "relation" && field.settings.cardinality === "many" || field.type === "media" && field.settings.multiple;
1241
+ /** Types whose stored JSON string is not unique per value, so equality compares numerically. */
1242
+ const NUMERIC_STRING_TYPES = /* @__PURE__ */ new Set(["decimal", "biginteger"]);
1243
+ const SYSTEM_OPERATORS = {
1244
+ id: [
1245
+ "$eq",
1246
+ "$ne",
1247
+ "$in",
1248
+ "$nin"
1249
+ ],
1250
+ createdAt: [
1251
+ "$eq",
1252
+ "$ne",
1253
+ "$in",
1254
+ "$nin",
1255
+ ...RANGE
1256
+ ],
1257
+ updatedAt: [
1258
+ "$eq",
1259
+ "$ne",
1260
+ "$in",
1261
+ "$nin",
1262
+ ...RANGE
1263
+ ]
1264
+ };
1265
+ /** Throws 400 unless the operator is allowed for the target. */
1266
+ const assertOperatorAllowed = (target, operator, label) => {
1267
+ if (target.kind === "system") {
1268
+ if (!(SYSTEM_OPERATORS[target.name] ?? []).includes(operator)) throw queryInvalid(`Operator ${operator} is not supported on "${label}"`);
1269
+ return;
1270
+ }
1271
+ const { field } = target;
1272
+ if (!OPERATORS_BY_FAMILY[familyOf(field)].includes(operator)) throw queryInvalid(`Operator ${operator} is not supported on "${label}" (${field.type})`);
1273
+ if (isRangeOperator(operator) && !field.filterable && !field.sortable) throw queryInvalid(`"${label}" is not filterable: mark it filterable to use ${operator}`);
1274
+ if (isTextMatchOperator(operator) && !field.filterable) throw queryInvalid(`"${label}" is not filterable: mark it filterable to use ${operator}`);
1275
+ };
1276
+ /** Whether a field can be sorted on: an expression index exists only for sortable/filterable scalars. */
1277
+ const isSortable = (field) => field.sortable && familyOf(field) !== "none" && familyOf(field) !== "list" && familyOf(field) !== "reference";
1278
+ const coerceBoolean = (raw, label) => {
1279
+ if (raw === true || raw === "true") return true;
1280
+ if (raw === false || raw === "false") return false;
1281
+ throw queryInvalid(`"${label}" needs true or false`);
1282
+ };
1283
+ const coerceText = (raw, label) => {
1284
+ if (typeof raw !== "string") throw queryInvalid(`"${label}" needs a single text value`);
1285
+ return raw;
1286
+ };
1287
+ const coerceOrFail = (value, label, expected) => {
1288
+ if (value === void 0) throw queryInvalid(`"${label}" needs ${expected}`);
1289
+ return value;
1290
+ };
1291
+ /** One comparison value for a field, canonicalized like stored values. */
1292
+ const coerceFieldValue = (field, raw, label) => {
1293
+ switch (familyOf(field)) {
1294
+ case "number": {
1295
+ const number = Number(coerceText(raw, label));
1296
+ if (!Number.isFinite(number) || raw.trim() === "") throw queryInvalid(`"${label}" needs a number`);
1297
+ return number;
1298
+ }
1299
+ case "numericString": return coerceOrFail(field.type === "decimal" ? canonicalDecimal(coerceText(raw, label)) : canonicalBigInteger(coerceText(raw, label)), label, "a number");
1300
+ case "temporal": {
1301
+ const text = coerceText(raw, label);
1302
+ const canonical = field.type === "date" ? canonicalDate(text) : field.type === "datetime" ? canonicalDatetime(text) : canonicalTime(text);
1303
+ return coerceOrFail(canonical, label, `a valid ${field.type}`);
1304
+ }
1305
+ case "boolean": return coerceBoolean(raw, label);
1306
+ case "reference": {
1307
+ const id = coerceText(raw, label);
1308
+ if (!isStableId(id)) throw queryInvalid(`"${label}" needs an ID`);
1309
+ return id;
1310
+ }
1311
+ default: return coerceText(raw, label);
1312
+ }
1313
+ };
1314
+ const coerceSystemValue = (name, raw, label) => {
1315
+ const text = coerceText(raw, label);
1316
+ if (name === "id") {
1317
+ if (!isStableId(text)) throw queryInvalid(`"${label}" needs an entry ID`);
1318
+ return text;
1319
+ }
1320
+ return coerceOrFail(canonicalDatetime(text), label, "an ISO-8601 date-time with a time zone");
1321
+ };
1322
+ /** Turns the raw operand of `operator` into typed value(s); lists for `$in`/`$nin`, a boolean for `$null`. */
1323
+ const coerceOperand = (target, operator, raw, label, maxListLength) => {
1324
+ if (isNullOperator(operator)) return coerceBoolean(raw, label);
1325
+ const coerceOne = (value) => target.kind === "system" ? coerceSystemValue(target.name, value, label) : coerceFieldValue(target.field, value, label);
1326
+ if (isListOperator(operator)) {
1327
+ if (!Array.isArray(raw)) throw queryInvalid(`"${label}" ${operator} needs a list`);
1328
+ if (raw.length > maxListLength) throw queryInvalid(`"${label}" ${operator} accepts at most ${maxListLength} values`);
1329
+ return raw.map(coerceOne);
1330
+ }
1331
+ if (isTextMatchOperator(operator)) {
1332
+ const text = coerceText(raw, label);
1333
+ if (text.length === 0) throw queryInvalid(`"${label}" ${operator} needs a non-empty value`);
1334
+ return text;
1335
+ }
1336
+ return coerceOne(raw);
1337
+ };
1338
+ //#endregion
1339
+ //#region ../../apps/api/src/content/compiler/compile.ts
1340
+ const RANGE_SQL = {
1341
+ $lt: sql`<`,
1342
+ $lte: sql`<=`,
1343
+ $gt: sql`>`,
1344
+ $gte: sql`>=`,
1345
+ $eq: sql`=`,
1346
+ $ne: sql`<>`
1347
+ };
1348
+ const anyOf = (parts) => parts.length === 0 ? sql`false` : sql`(${sql.join(parts, sql` or `)})`;
1349
+ const equalityTarget = ({ field }) => {
1350
+ const cast = SCALAR_DATA_TYPES.has(field.type) ? valueCastFor(field.type) : null;
1351
+ const list = isListValued(field);
1352
+ return {
1353
+ fieldId: field.id,
1354
+ cast,
1355
+ list,
1356
+ numericString: NUMERIC_STRING_TYPES.has(field.type),
1357
+ indexed: cast !== null && !list && hasFieldIndex(field)
1358
+ };
1359
+ };
1360
+ /** System filter columns. `createdAt` is the entry's, copied on every head (`h.entry_created_at`). */
1361
+ const SYSTEM_COLUMNS = {
1362
+ id: "h.entry_id",
1363
+ createdAt: "h.entry_created_at",
1364
+ updatedAt: "h.updated_at"
1365
+ };
1366
+ /** Whether a filter names a column of `entries` (the head query's `e`), so its COUNT must join entries. */
1367
+ const filterReadsEntries = (node) => {
1368
+ switch (node.kind) {
1369
+ case "and":
1370
+ case "or": return node.nodes.some(filterReadsEntries);
1371
+ case "not": return filterReadsEntries(node.node);
1372
+ case "condition": return node.target.kind === "system" && SYSTEM_COLUMNS[node.target.name].startsWith("e.");
1373
+ }
1374
+ };
1375
+ const systemCondition = (name, operator, value, dialect) => {
1376
+ const column = sql.ref(SYSTEM_COLUMNS[name]);
1377
+ const element = name === "id" ? "uuid" : "timestamp";
1378
+ switch (operator) {
1379
+ case "$in": return sql`(${dialect.oneOf(column, value, element)})`;
1380
+ case "$nin": return sql`(not (${dialect.oneOf(column, value, element)}))`;
1381
+ default: {
1382
+ const parameter = name === "id" ? dialect.uuid(value) : dialect.timestamp(value);
1383
+ return sql`(${column} ${RANGE_SQL[operator] ?? sql`=`} ${parameter})`;
1384
+ }
1385
+ }
1386
+ };
1387
+ const fieldCondition = (target, operator, value, dialect) => {
1388
+ const { field } = target;
1389
+ const equals = (item) => fieldEqualsExpression(equalityTarget(target), item, dialect);
1390
+ switch (operator) {
1391
+ case "$eq": return equals(value);
1392
+ case "$ne": return sql`(not ${equals(value)})`;
1393
+ case "$in": return anyOf(value.map(equals));
1394
+ case "$nin": return sql`(not ${anyOf(value.map(equals))})`;
1395
+ case "$null":
1396
+ case "$notNull": {
1397
+ const missing = fieldMissingExpression(field.id, dialect);
1398
+ return operator === "$null" === value ? missing : sql`(not ${missing})`;
1399
+ }
1400
+ case "$lt":
1401
+ case "$lte":
1402
+ case "$gt":
1403
+ case "$gte": return sql`(${fieldValueExpression(field.id, field.type, dialect)} ${RANGE_SQL[operator]} ${castParameter(value, field.type, dialect)})`;
1404
+ case "$contains":
1405
+ case "$startsWith":
1406
+ case "$endsWith":
1407
+ case "$notContains": return textMatchExpression(field.id, operator, value, dialect);
1408
+ case "$containsi": return containsInsensitiveExpression(field.id, value, dialect);
1409
+ }
1410
+ };
1411
+ /** A filter AST as one boolean SQL expression. */
1412
+ const compileFilter = (node, dialect = contentDialect()) => {
1413
+ const compileChild = (child) => compileFilter(child, dialect);
1414
+ switch (node.kind) {
1415
+ case "and": return sql`(${sql.join(node.nodes.map(compileChild), sql` and `)})`;
1416
+ case "or": return sql`(${sql.join(node.nodes.map(compileChild), sql` or `)})`;
1417
+ case "not": return sql`(not ${compileChild(node.node)})`;
1418
+ case "condition": return node.target.kind === "system" ? systemCondition(node.target.name, node.operator, node.value, dialect) : fieldCondition(node.target, node.operator, node.value, dialect);
1419
+ }
1420
+ };
1421
+ /**
1422
+ * Admin list status of the served draft head `h`, against the published head of its locale (the same rule as
1423
+ * `statusOf`): no published head = draft; the same revision and no autosaved work = published; else modified.
1424
+ * Served by the entry_heads primary key.
1425
+ */
1426
+ const compileStatusCondition = (status) => {
1427
+ const published = sql`select 1 from entry_heads p
1428
+ where p.entry_id = h.entry_id and p.locale = h.locale and p.state = 'published'`;
1429
+ switch (status) {
1430
+ case "draft": return sql`not exists (${published})`;
1431
+ case "published": return sql`(h.autosaved_at is null and exists (${published} and p.revision_id = h.revision_id))`;
1432
+ case "modified": return sql`exists (${published} and (p.revision_id <> h.revision_id or h.autosaved_at is not null))`;
1433
+ }
1434
+ };
1435
+ /** Entries an admin user created (admin list `?author=`). */
1436
+ const compileAuthorCondition = (adminUserId, dialect = contentDialect()) => sql`e.created_by_admin_id = ${dialect.uuid(adminUserId)}`;
1437
+ /** Case-insensitive search of one text field. */
1438
+ const compileSearch = (fieldId, text, dialect = contentDialect()) => containsInsensitiveExpression(fieldId, text, dialect);
1439
+ const SNAPSHOT_CTE = "content_snapshot";
1440
+ /** The FROM item for a source, aliased `alias`, and the CTE it needs (snapshots only). */
1441
+ const sourceOf$1 = (source, modelId, siteId, dialect) => {
1442
+ if (source.kind === "heads") return {
1443
+ cte: null,
1444
+ table: sql.table("entry_heads")
1445
+ };
1446
+ return {
1447
+ cte: dialect.snapshotCte({
1448
+ name: SNAPSHOT_CTE,
1449
+ siteId,
1450
+ modelId: modelIdLiteral(modelId),
1451
+ seq: source.seq
1452
+ }),
1453
+ table: sql.id(SNAPSHOT_CTE)
1454
+ };
1455
+ };
1456
+ const localeConditions = (scope, table, source, dialect) => {
1457
+ if (scope.kind === "any") return [];
1458
+ if (scope.chain.length === 1) return [sql`h.locale = ${scope.chain[0]}`];
1459
+ const chain = [...scope.chain];
1460
+ const stateMatch = source.kind === "heads" ? sql` and h2.state = h.state` : sql``;
1461
+ return [dialect.oneOf(sql`h.locale`, chain, "text"), sql`not exists (select 1 from ${table} h2 where h2.entry_id = h.entry_id${stateMatch}
1462
+ and ${dialect.oneOf(sql`h2.locale`, chain, "text")}
1463
+ and ${dialect.localeRank(chain, sql`h2.locale`)} < ${dialect.localeRank(chain, sql`h.locale`)})`];
1464
+ };
1465
+ const whereOf = (plan, table, dialect) => {
1466
+ const conditions = [
1467
+ sql`h.site_id = ${dialect.uuid(plan.siteId)}`,
1468
+ sql`h.model_id = ${modelIdLiteral(plan.modelId)}`,
1469
+ ...plan.source.kind === "heads" ? [sql`h.state = ${plan.source.state}`] : [],
1470
+ ...localeConditions(plan.locales, table, plan.source, dialect),
1471
+ ...plan.conditions
1472
+ ];
1473
+ return sql.join(conditions, sql` and `);
1474
+ };
1475
+ /**
1476
+ * The COUNT's FROM. Live heads exist only for live entries (deleting an entry removes its heads in the same
1477
+ * transaction; entries soft-deleted for having no head have none; `headLiveEntry.int.test.ts` pins it), so a
1478
+ * count of heads whose conditions name no `e` column needs no join: it is read from the heads' indexes alone
1479
+ * (the model index, or a field index for an indexed filter). A snapshot keeps the join: the publication log
1480
+ * still lists entries deleted since.
1481
+ */
1482
+ const countFrom = (plan, table, where, joined) => plan.source.kind === "heads" && !(plan.conditionsReadEntries ?? plan.conditions.length > 0) ? sql`from ${table} h where ${where}` : joined;
1483
+ const HEAD_COLUMNS = sql`h.entry_id, h.locale, h.data, h.version, h.revision_id, h.updated_at,
1484
+ h.autosaved_at, e.created_at, e.updated_at as entry_updated_at, e.created_by_admin_id, e.owner_app_user_id`;
1485
+ /** The parts every statement over a plan's heads shares. */
1486
+ const headQueryParts = (plan, dialect) => {
1487
+ const { cte, table } = sourceOf$1(plan.source, plan.modelId, plan.siteId, dialect);
1488
+ const where = whereOf(plan, table, dialect);
1489
+ const from = sql`from ${table} h join entries e on e.id = h.entry_id and e.deleted_at is null where ${where}`;
1490
+ const orderBy = plan.orderBy.length > 0 ? sql` order by ${sql.join([...plan.orderBy])}` : sql``;
1491
+ const limit = plan.limit !== void 0 ? sql` limit ${plan.limit}` : sql``;
1492
+ const offset = plan.offset ? sql` offset ${plan.offset}` : sql``;
1493
+ return {
1494
+ prefix: cte ?? sql``,
1495
+ rows: sql`${from}${orderBy}${limit}${offset}`,
1496
+ countFrom: countFrom(plan, table, where, from)
1497
+ };
1498
+ };
1499
+ /** The SELECT for a plan: rows, plus the matching COUNT for pagination. */
1500
+ const compileHeadQuery = (plan, dialect = contentDialect()) => {
1501
+ const { prefix, rows, countFrom: counted } = headQueryParts(plan, dialect);
1502
+ return {
1503
+ rows: sql`${prefix}select ${HEAD_COLUMNS}
1504
+ ${rows}`,
1505
+ count: sql`${prefix}select count(*) as total ${counted}`
1506
+ };
1507
+ };
1508
+ /**
1509
+ * One delivery read as a single statement (plan delivery-perf): the page's heads with, on every row, the
1510
+ * matching total and the site's current publication sequence (`publication_state.last_seq`), so all three
1511
+ * come from one snapshot of the database on every dialect without a transaction. The uncorrelated
1512
+ * subqueries run once per statement. An empty page carries no row, so `meta` reads the total and the
1513
+ * sequence alone, for callers that read it in the same transaction as `rows`.
1514
+ */
1515
+ const compileHeadPage = (plan, options, dialect = contentDialect()) => {
1516
+ const { prefix, rows, countFrom: counted } = headQueryParts(plan, dialect);
1517
+ const total = options.total ? sql`(select count(*) ${counted})` : sql`null`;
1518
+ const seq = sql`(select ps.last_seq from publication_state ps where ps.site_id = ${dialect.uuid(plan.siteId)})`;
1519
+ return {
1520
+ rows: sql`${prefix}select ${HEAD_COLUMNS}, ${total} as page_total, ${seq} as page_seq
1521
+ ${rows}`,
1522
+ meta: sql`${prefix}select ${total} as page_total, ${seq} as page_seq`
1523
+ };
1524
+ };
1525
+ //#endregion
1526
+ //#region ../../apps/api/src/content/compiler/conditions.ts
1527
+ /**
1528
+ * Conditions on the head query's entry (alias `h`, compile.ts) for `HeadQueryPlan.conditions`, so callers
1529
+ * outside the compiler never write SQL against its aliases.
1530
+ */
1531
+ /** The head belongs to entry `id`. */
1532
+ const entryIdIs = (id, dialect = contentDialect()) => sql`h.entry_id = ${dialect.uuid(id)}`;
1533
+ /** The head belongs to one of `ids` (one bound parameter, whatever its length). */
1534
+ const entryIdIn = (ids, dialect = contentDialect()) => dialect.oneOf(sql`h.entry_id`, ids, "uuid");
1535
+ //#endregion
1536
+ //#region ../../apps/api/src/content/compiler/sort.ts
1537
+ /**
1538
+ * ORDER BY for a content query. Field sorts use the same expression as the field's index (so the index can
1539
+ * serve them); the entry ID is always the final tie-breaker so pages are stable. Without an explicit sort
1540
+ * the model's default sort applies when the caller may read that field, else newest first. `createdAt` is the
1541
+ * entry's creation time copied on the head (`h.entry_created_at`), so the default order and its tie-breaker
1542
+ * are both head columns, which `entry_heads_entry_order_idx` (site, model, state, `entry_created_at desc`,
1543
+ * `entry_id`) serves: a page is read in index order and the scan stops at the limit (ADR 0001, "Delivery perf
1544
+ * 2").
1545
+ */
1546
+ const SYSTEM_SORT_COLUMNS = {
1547
+ id: "h.entry_id",
1548
+ createdAt: "h.entry_created_at",
1549
+ updatedAt: "h.updated_at"
1550
+ };
1551
+ const termSql = (term, dialect) => {
1552
+ const nullable = term.target.kind === "field";
1553
+ const expression = term.target.kind === "system" ? sql.ref(SYSTEM_SORT_COLUMNS[term.target.name]) : fieldValueExpression(term.target.field.id, term.target.field.type, dialect);
1554
+ if (dialect.sortTerm) return dialect.sortTerm(expression, term.direction, nullable);
1555
+ return sql`${expression} ${dialect.sortDirection(term.direction, nullable)}`;
1556
+ };
1557
+ const defaultSortTerms = (model, isReadable) => {
1558
+ const preferred = model.display.defaultSort;
1559
+ const field = preferred ? model.fields.find((candidate) => candidate.id === preferred.fieldId) : void 0;
1560
+ if (preferred && field && !field.deprecated && isSortable(field) && isReadable(field)) return [{
1561
+ target: {
1562
+ kind: "field",
1563
+ field
1564
+ },
1565
+ direction: preferred.direction
1566
+ }];
1567
+ return [{
1568
+ target: {
1569
+ kind: "system",
1570
+ name: "createdAt"
1571
+ },
1572
+ direction: "desc"
1573
+ }];
1574
+ };
1575
+ const compileOrderBy = (terms, dialect = contentDialect()) => [...terms.map((term) => termSql(term, dialect)), sql`h.entry_id asc`];
1576
+ //#endregion
1577
+ //#region ../../apps/api/src/content/locales.ts
1578
+ /**
1579
+ * Localization rules (ADR 0004). Every locale's head holds the complete document; shared (non-localized)
1580
+ * fields are fanned out to every existing draft of the entry when saved; publishing is strictly per locale.
1581
+ */
1582
+ /** The locale itself, then its configured fallbacks, then the default locale (always last). */
1583
+ const fallbackChain = (snapshot, locale) => {
1584
+ const chain = [
1585
+ locale,
1586
+ ...snapshot.locales.find((candidate) => candidate.code === locale)?.fallbacks ?? [],
1587
+ snapshot.defaultLocale
1588
+ ];
1589
+ return chain.filter((code, index) => chain.indexOf(code) === index && snapshot.locales.some((l) => l.code === code));
1590
+ };
1591
+ /**
1592
+ * Which heads serve a read. Non-localized models keep their heads in whichever locale they were created in,
1593
+ * so they are read regardless of locale; localized models serve the requested locale or fall back.
1594
+ */
1595
+ const readScopeFor = (snapshot, model, requested, { fallback }) => {
1596
+ if (!model.localized) return { kind: "any" };
1597
+ const locale = requested ?? snapshot.defaultLocale;
1598
+ return {
1599
+ kind: "chain",
1600
+ chain: fallback ? fallbackChain(snapshot, locale) : [locale]
1601
+ };
1602
+ };
1603
+ //#endregion
1604
+ //#region ../../apps/api/src/db/index.ts
1605
+ const warnIdleConnectionError = (error) => process.emitWarning(`PostgreSQL connection lost while idle: ${error.message}`);
1606
+ /**
1607
+ * The pg pool as Kysely sees it: an exhausted pool's queue timeout becomes `PoolAcquireTimeoutError`, a
1608
+ * connection that cannot be opened in time `DatabaseUnavailableError`, and in strict mode a nested
1609
+ * acquisition fails at once.
1610
+ */
1611
+ const acquiringPool = (pool, poolMax, acquireTimeoutMs, strict) => ({
1612
+ options: pool.options,
1613
+ connect: async () => {
1614
+ if (strict) assertNotNested();
1615
+ try {
1616
+ return await pool.connect();
1617
+ } catch (error) {
1618
+ throw pgAcquireError(error, poolMax, acquireTimeoutMs);
1619
+ }
1620
+ },
1621
+ end: () => pool.end()
1622
+ });
1623
+ /** Kysely on PostgreSQL in strict mode: transactions run in a connection scope (`db/connectionScope.ts`). */
1624
+ var StrictPostgresKysely = class extends Kysely {
1625
+ transaction() {
1626
+ return withConnectionScope(super.transaction());
1627
+ }
1628
+ };
1629
+ const warnIdleMysqlConnectionError = (error) => process.emitWarning(`MySQL connection lost while idle: ${error.message}`);
1630
+ /**
1631
+ * The database handle for DATABASE_URL: PostgreSQL through a `pg` pool, MySQL (`mysql://…`) through a
1632
+ * `mysql2` pool, or SQLite (`sqlite:<path>`) through Shapio's `node:sqlite` driver, where `poolMax` is the
1633
+ * number of read connections (one connection writes).
1634
+ * Sets the process dialect (`db/dialect.ts`) that SQL builders read.
1635
+ */
1636
+ const createDb = ({ connectionString, poolMax, acquireTimeoutMs = DEFAULT_POOL_ACQUIRE_TIMEOUT_MS, applicationName = "shapio", onIdleConnectionError, strict = false }) => {
1637
+ if (dialectOfUrl(connectionString) === "sqlite") {
1638
+ setCurrentDialect("sqlite");
1639
+ return createSqliteDb({
1640
+ location: sqliteLocationOfUrl(connectionString),
1641
+ readers: poolMax,
1642
+ strict
1643
+ });
1644
+ }
1645
+ if (dialectOfUrl(connectionString) === "mysql") {
1646
+ setCurrentDialect("mysql");
1647
+ return createMysqlDb({
1648
+ url: connectionString,
1649
+ poolMax,
1650
+ acquireTimeoutMs,
1651
+ applicationName,
1652
+ onIdleConnectionError: onIdleConnectionError ?? warnIdleMysqlConnectionError,
1653
+ strict
1654
+ });
1655
+ }
1656
+ setCurrentDialect("postgres");
1657
+ const pool = new pg.Pool({
1658
+ connectionString,
1659
+ max: poolMax,
1660
+ connectionTimeoutMillis: acquireTimeoutMs,
1661
+ application_name: applicationName
1662
+ });
1663
+ pool.on("error", onIdleConnectionError ?? warnIdleConnectionError);
1664
+ pool.on("connect", (client) => {
1665
+ client.on("error", () => void 0);
1666
+ });
1667
+ const dialect = new PostgresDialect({
1668
+ pool: acquiringPool(pool, poolMax, acquireTimeoutMs, strict),
1669
+ controlClient: pg.Client
1670
+ });
1671
+ return strict ? new StrictPostgresKysely({ dialect }) : new Kysely({ dialect });
1672
+ };
1673
+ /**
1674
+ * The process-wide database handle used as the default executor by repositories.
1675
+ * `buildApp` sets it; tests and child processes each run in their own process, so one handle per process holds.
1676
+ */
1677
+ let db = void 0;
1678
+ const setDb = (instance) => {
1679
+ db = instance;
1680
+ };
1681
+ /** Whether this process has its database handle yet (a server in the same process set it first). */
1682
+ const hasDb = () => db !== void 0;
1683
+ /** Unsets the process handle if it is `instance` (its owner is closing it); leaves any other handle alone. */
1684
+ const clearDb = (instance) => {
1685
+ if (db === instance) db = void 0;
1686
+ };
1687
+ //#endregion
1688
+ //#region ../../apps/api/src/repositories/contentQueries.ts
1689
+ /**
1690
+ * Executes head queries compiled by `content/compiler` (the only place content SQL is written). Kept as a
1691
+ * repository so services never run SQL themselves.
1692
+ */
1693
+ const runHeadQuery = async (query, executor = db) => (await query.execute(executor)).rows;
1694
+ const toPageMeta = (row) => ({
1695
+ total: row.page_total === null ? null : Number(row.page_total),
1696
+ seq: row.page_seq === null ? null : Number(row.page_seq)
1697
+ });
1698
+ /**
1699
+ * Runs a head page compiled by `compileHeadPage`: the rows, and the total and sequence carried on them (null
1700
+ * when the page is empty; then `runHeadPageMeta` in the same transaction reads them).
1701
+ */
1702
+ const runHeadPage = async (query, executor = db) => {
1703
+ const result = (await query.execute(executor)).rows;
1704
+ const [first] = result;
1705
+ return {
1706
+ rows: result.map(({ page_total: _total, page_seq: _seq, ...row }) => row),
1707
+ meta: first ? toPageMeta(first) : null
1708
+ };
1709
+ };
1710
+ const runHeadPageMeta = async (query, executor = db) => {
1711
+ const [row] = (await query.execute(executor)).rows;
1712
+ return row ? toPageMeta(row) : {
1713
+ total: null,
1714
+ seq: null
1715
+ };
1716
+ };
1717
+ /** Runs `fn` in one REPEATABLE READ, read-only transaction: a response reads one consistent moment. */
1718
+ const withConsistentRead = (fn, database = db) => database.transaction().setIsolationLevel("repeatable read").setAccessMode("read only").execute(fn);
1719
+ //#endregion
1720
+ //#region ../../apps/api/src/repositories/mediaAssets.ts
1721
+ const live = (trx) => trx.selectFrom("media_assets").where("deleted_at", "is", null);
1722
+ /** The live assets of one site among `ids`. */
1723
+ const findLiveManyOnSite = (siteId, ids, trx = db) => ids.length === 0 ? Promise.resolve([]) : live(trx).selectAll().where("site_id", "=", siteId).where("id", "in", ids).execute();
1724
+ //#endregion
1725
+ //#region ../../apps/api/src/constants/media.ts
1726
+ /** Media library limits and timings (package G). Upload size and allowed types are configuration. */
1727
+ /** Shapio's route for stored objects: public ones at a stable URL, private ones with a signature. */
1728
+ const MEDIA_FILE_ROUTE_PREFIX = "/api/media/f/";
1729
+ /** How long a signed URL for a private asset works. */
1730
+ const MEDIA_SIGNED_URL_TTL_SECONDS = 3600;
1731
+ //#endregion
1732
+ //#region ../../apps/api/src/media/urls.ts
1733
+ /**
1734
+ * The URL a client should use for a stored object. Public: the CDN/bucket base when configured, otherwise
1735
+ * Shapio's stable route. Private: always an expiring signed URL; nothing else reaches private bytes.
1736
+ */
1737
+ const resolveObjectUrl = async (storage, urls, object, ttlSeconds = MEDIA_SIGNED_URL_TTL_SECONDS) => {
1738
+ const adapter = storage.get(object.driver);
1739
+ if (object.visibility === "public") return {
1740
+ url: adapter.publicUrl(object.key) ?? urls.absoluteUrl(`/api/media/f/${encodeKeyPath(object.key)}`),
1741
+ expiresAt: null
1742
+ };
1743
+ return {
1744
+ url: await adapter.signedGetUrl(object.key, {
1745
+ expiresInSeconds: ttlSeconds,
1746
+ filename: object.filename,
1747
+ contentType: object.mimeType
1748
+ }),
1749
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
1750
+ };
1751
+ };
1752
+ //#endregion
1753
+ //#region ../../apps/api/src/repositories/mediaVariants.ts
1754
+ const listForAssets = (assetIds, trx = db) => assetIds.length === 0 ? Promise.resolve([]) : trx.selectFrom("media_variants").selectAll().where("asset_id", "in", assetIds).orderBy("asset_id").orderBy("width").execute();
1755
+ //#endregion
1756
+ //#region ../../apps/api/src/services/mediaViews.ts
1757
+ const toVariantView = async (deps, asset, variant) => ({
1758
+ name: variant.name,
1759
+ width: variant.width,
1760
+ height: variant.height,
1761
+ format: variant.format,
1762
+ mimeType: variant.mime_type,
1763
+ sizeBytes: variant.size_bytes === null ? null : Number(variant.size_bytes),
1764
+ status: variant.status,
1765
+ url: variant.status === "ready" && variant.storage_key ? (await resolveObjectUrl(deps.storage, deps.urls, {
1766
+ driver: asset.storage_driver,
1767
+ key: variant.storage_key,
1768
+ visibility: asset.visibility,
1769
+ filename: asset.original_filename,
1770
+ mimeType: variant.mime_type
1771
+ })).url : null
1772
+ });
1773
+ const toAssetView = async (deps, asset, variants) => {
1774
+ const { url, expiresAt } = await resolveObjectUrl(deps.storage, deps.urls, {
1775
+ driver: asset.storage_driver,
1776
+ key: asset.storage_key,
1777
+ visibility: asset.visibility,
1778
+ filename: asset.original_filename,
1779
+ mimeType: asset.mime_type
1780
+ });
1781
+ return {
1782
+ id: asset.id,
1783
+ folderId: asset.folder_id,
1784
+ filename: asset.original_filename,
1785
+ mimeType: asset.mime_type,
1786
+ sizeBytes: Number(asset.size_bytes),
1787
+ width: asset.width,
1788
+ height: asset.height,
1789
+ checksumSha256: asset.checksum_sha256,
1790
+ alt: asset.alt,
1791
+ caption: asset.caption,
1792
+ focalPoint: asset.focal_x !== null && asset.focal_y !== null ? {
1793
+ x: asset.focal_x,
1794
+ y: asset.focal_y
1795
+ } : null,
1796
+ visibility: asset.visibility,
1797
+ status: asset.status,
1798
+ processingError: asset.processing_error,
1799
+ storageDriver: asset.storage_driver,
1800
+ url,
1801
+ urlExpiresAt: expiresAt,
1802
+ variants: await Promise.all(variants.map((variant) => toVariantView(deps, asset, variant))),
1803
+ createdBy: asset.created_by,
1804
+ createdAt: asset.created_at,
1805
+ updatedAt: asset.updated_at,
1806
+ version: asset.version
1807
+ };
1808
+ };
1809
+ /**
1810
+ * API views of assets with their variants (one query for all variants) and URLs: stable for public
1811
+ * assets, signed and expiring for private ones. Package E can use this to resolve media fields.
1812
+ * `executor`: the caller's open transaction, when there is one.
1813
+ */
1814
+ const toAssetViews = async (deps, assets, executor) => {
1815
+ const variants = await listForAssets(assets.map((asset) => asset.id), executor);
1816
+ const byAsset = Map.groupBy(variants, (variant) => variant.asset_id);
1817
+ return Promise.all(assets.map((asset) => toAssetView(deps, asset, byAsset.get(asset.id) ?? [])));
1818
+ };
1819
+ //#endregion
1820
+ //#region ../../apps/api/src/content/compiler/populate.ts
1821
+ const idsOf = (rows, fieldId) => {
1822
+ const ids = /* @__PURE__ */ new Set();
1823
+ for (const row of rows) {
1824
+ const value = row.data[fieldId];
1825
+ for (const id of Array.isArray(value) ? value : [value]) if (typeof id === "string") ids.add(id);
1826
+ }
1827
+ return [...ids];
1828
+ };
1829
+ /** relation field ID → target entry ID → projected target entry. */
1830
+ const populateRelations = async (env, rows, fields, tree) => {
1831
+ const populated = /* @__PURE__ */ new Map();
1832
+ for (const [fieldId, subtree] of tree) {
1833
+ const field = fields.find((candidate) => candidate.id === fieldId);
1834
+ if (!field || field.type !== "relation") continue;
1835
+ const byId = /* @__PURE__ */ new Map();
1836
+ populated.set(fieldId, byId);
1837
+ const target = resolveModelById(env.snapshot, field.settings.target);
1838
+ if (!target) continue;
1839
+ const policy = await env.policyFor(env, target.definition.id);
1840
+ const targetRows = await env.fetch(env, target, policy, idsOf(rows, fieldId));
1841
+ for (const { row, data } of await env.project(env, target, policy, targetRows, {
1842
+ fields: null,
1843
+ populate: subtree
1844
+ })) byId.set(row.entry_id, {
1845
+ ...env.system(env, row),
1846
+ ...data
1847
+ });
1848
+ }
1849
+ return populated;
1850
+ };
1851
+ //#endregion
1852
+ //#region ../../apps/api/src/content/media.ts
1853
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1854
+ const collect = (model, fields, data, add) => {
1855
+ for (const field of fields) {
1856
+ const value = data[field.id];
1857
+ if (value === void 0 || value === null) continue;
1858
+ switch (field.type) {
1859
+ case "media":
1860
+ (Array.isArray(value) ? value : [value]).forEach((id) => typeof id === "string" && add(id));
1861
+ break;
1862
+ case "richtext":
1863
+ if (isRecord$1(value) && isRecord$1(value.doc)) richTextMediaIds(value).forEach(add);
1864
+ break;
1865
+ case "component": {
1866
+ const component = model.components.get(field.settings.component)?.definition;
1867
+ for (const item of Array.isArray(value) ? value : [value]) if (component && isRecord$1(item)) collect(model, component.fields, item, add);
1868
+ break;
1869
+ }
1870
+ case "dynamiczone": for (const item of Array.isArray(value) ? value : []) {
1871
+ const component = isRecord$1(item) ? model.components.get(String(item[COMPONENT_KEY]))?.definition : void 0;
1872
+ if (component && isRecord$1(item)) collect(model, component.fields, item, add);
1873
+ }
1874
+ }
1875
+ }
1876
+ };
1877
+ /** Asset IDs used by the given top-level fields of the documents. */
1878
+ const mediaIdsOf = (model, documents, fields) => {
1879
+ const ids = /* @__PURE__ */ new Set();
1880
+ for (const data of documents) collect(model, fields, data, (id) => ids.add(id));
1881
+ return ids;
1882
+ };
1883
+ //#endregion
1884
+ //#region ../../apps/api/src/content/relations.ts
1885
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1886
+ const referenceIds = (value) => (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string");
1887
+ const walkFields = (fields, data, components, visit) => {
1888
+ for (const field of fields) {
1889
+ const value = data[field.id];
1890
+ if (value === void 0 || value === null) continue;
1891
+ if (field.type === "relation") visit({
1892
+ field,
1893
+ entryIds: referenceIds(value)
1894
+ });
1895
+ else if (field.type === "component") {
1896
+ const component = components.get(field.settings.component)?.definition;
1897
+ for (const item of Array.isArray(value) ? value : [value]) if (component && isRecord(item)) walkFields(component.fields, item, components, visit);
1898
+ } else if (field.type === "dynamiczone" && Array.isArray(value)) for (const item of value) {
1899
+ const component = isRecord(item) ? components.get(String(item[COMPONENT_KEY]))?.definition : void 0;
1900
+ if (component && isRecord(item)) walkFields(component.fields, item, components, visit);
1901
+ }
1902
+ }
1903
+ };
1904
+ /** Calls `visit` for every relation value in a document, in document order. */
1905
+ const walkRelations = (model, data, visit, topLevelFields = model.definition.fields) => walkFields(topLevelFields, data, model.components, visit);
1906
+ /** Every target a set of documents mentions, grouped by target model. */
1907
+ const targetsByModel = (model, documents, topLevelFields) => {
1908
+ const targets = /* @__PURE__ */ new Map();
1909
+ for (const data of documents) walkRelations(model, data, ({ field, entryIds }) => {
1910
+ const set = targets.get(field.settings.target) ?? /* @__PURE__ */ new Set();
1911
+ entryIds.forEach((id) => set.add(id));
1912
+ targets.set(field.settings.target, set);
1913
+ }, topLevelFields);
1914
+ return targets;
1915
+ };
1916
+ //#endregion
1917
+ //#region ../../apps/api/src/content/seo.ts
1918
+ const EXPECTED_TYPES = {
1919
+ title: /* @__PURE__ */ new Set(["string", "text"]),
1920
+ description: /* @__PURE__ */ new Set(["text", "string"]),
1921
+ image: /* @__PURE__ */ new Set(["media"]),
1922
+ canonical: /* @__PURE__ */ new Set(["url", "string"]),
1923
+ noindex: /* @__PURE__ */ new Set(["boolean"])
1924
+ };
1925
+ const keysOf = (model) => {
1926
+ const component = model.components.get(SEO_COMPONENT_ID)?.definition;
1927
+ const keys = {};
1928
+ for (const [key, id] of Object.entries(SEO_FIELD_IDS)) {
1929
+ const field = component?.fields.find((candidate) => candidate.id === id && !candidate.deprecated);
1930
+ const single = field?.type !== "media" || !field.settings.multiple;
1931
+ if (field && single && EXPECTED_TYPES[key].has(field.type)) keys[key] = field.apiKey;
1932
+ }
1933
+ return keys;
1934
+ };
1935
+ /** The entry's own title, only when the caller may read the title field (never a hidden value). */
1936
+ const fallbackTitleOf = (model, policy, row) => {
1937
+ const field = effectiveTitleField(model.definition);
1938
+ if (!field || !maskAllows(policy.readMask, field)) return null;
1939
+ const value = row.data[field.id];
1940
+ return typeof value === "string" ? value : null;
1941
+ };
1942
+ /** The locale whose defaults apply: the head's for localized models, the requested one otherwise. */
1943
+ const defaultsLocaleOf = (env, model, row) => model.definition.localized ? row.locale : env.locale ?? env.snapshot.defaultLocale;
1944
+ /** Whether the selected fields include an SEO field (only those are resolved). */
1945
+ const seoFieldsOf = (fields) => fields.filter(isSeoField);
1946
+ /** Replaces each SEO field's projected value with its resolved value. */
1947
+ const resolveSeoFields = (env, options, model, policy, fields, row, data) => {
1948
+ const seoFields = seoFieldsOf(fields);
1949
+ if (seoFields.length === 0) return data;
1950
+ const keys = keysOf(model);
1951
+ const context = {
1952
+ defaults: seoDefaultsForLocale(options.defaults, fallbackChain(env.snapshot, defaultsLocaleOf(env, model, row))),
1953
+ image: options.image,
1954
+ fallbackTitle: fallbackTitleOf(model, policy, row),
1955
+ keys
1956
+ };
1957
+ const output = { ...data };
1958
+ for (const field of seoFields) output[field.apiKey] = resolveSeo(data[field.apiKey], context);
1959
+ return output;
1960
+ };
1961
+ //#endregion
1962
+ //#region ../../apps/api/src/content/read.ts
1963
+ /** Evaluated through the read's executor: a read inside a transaction never asks the pool for a second connection. */
1964
+ const readPolicy = (env, modelId) => env.permissions.evaluate(env.actor, {
1965
+ action: "read",
1966
+ modelId
1967
+ }, env.executor);
1968
+ /**
1969
+ * Heads of the given entries that the policy lets the caller read, served per the locale chain (or per
1970
+ * `locales` when given: GraphQL's `fallback: false` and `localizations`).
1971
+ */
1972
+ const fetchHeadsByIds = async (env, model, policy, ids, locales) => {
1973
+ if (ids.length === 0 || !policy.allowed) return [];
1974
+ const rowFilter = compileRowFilter(policy.rowFilter, env.actor);
1975
+ const { rows } = compileHeadQuery({
1976
+ siteId: env.siteId,
1977
+ modelId: model.definition.id,
1978
+ source: env.source,
1979
+ locales: locales ?? readScopeFor(env.snapshot, model.definition, env.locale, { fallback: true }),
1980
+ conditions: [entryIdIn(ids), ...rowFilter ? [rowFilter] : []],
1981
+ orderBy: []
1982
+ });
1983
+ return runHeadQuery(rows, env.executor);
1984
+ };
1985
+ /**
1986
+ * Relation targets the caller may see in these rows: for delivery, only targets that are published (at the
1987
+ * same snapshot) and readable under their model's policy, so a published entry never leaks the ID of a
1988
+ * draft (brief §10). Admin reads see every ID (null). The heads read are returned for populate.
1989
+ */
1990
+ const relationTargets = async (env, model, rows, fields) => {
1991
+ const fetched = /* @__PURE__ */ new Map();
1992
+ if (env.audience === "admin") return {
1993
+ visible: null,
1994
+ fetched
1995
+ };
1996
+ const visible = /* @__PURE__ */ new Set();
1997
+ const targets = targetsByModel(model, rows.map((row) => row.data), fields);
1998
+ for (const [targetModelId, ids] of targets) {
1999
+ const target = resolveModelById(env.snapshot, targetModelId);
2000
+ if (!target) continue;
2001
+ const policy = await readPolicy(env, targetModelId);
2002
+ const found = await fetchHeadsByIds(env, target, policy, [...ids]);
2003
+ found.forEach((row) => visible.add(row.entry_id));
2004
+ fetched.set(targetModelId, {
2005
+ ids,
2006
+ rows: found
2007
+ });
2008
+ }
2009
+ return {
2010
+ visible,
2011
+ fetched
2012
+ };
2013
+ };
2014
+ /**
2015
+ * `fetchHeadsByIds` for populate, answered from the heads relation visibility already read when it asked for
2016
+ * every one of these IDs under the same policy and locale scope (populate's default), else from the database.
2017
+ */
2018
+ const fetchReusing = (fetched) => async (env, model, policy, ids) => {
2019
+ const known = fetched.get(model.definition.id);
2020
+ if (known && ids.every((id) => known.ids.has(id))) {
2021
+ const wanted = new Set(ids);
2022
+ return known.rows.filter((row) => wanted.has(row.entry_id));
2023
+ }
2024
+ return fetchHeadsByIds(env, model, policy, ids);
2025
+ };
2026
+ /**
2027
+ * Whether projecting these rows reads anything more (relation targets, populated entries, asset views). When
2028
+ * not, a delivery read is complete in the one statement that fetched the rows (plan delivery-perf).
2029
+ */
2030
+ const needsFollowUpReads = (env, model, rows, query, policy) => {
2031
+ if (query.populate.size > 0) return true;
2032
+ const fields = selectFields(model, policy.readMask, query.fields);
2033
+ const documents = rows.map((row) => row.data);
2034
+ return env.audience === "delivery" && targetsByModel(model, documents, fields).size > 0 || needsMediaReads(env, model, rows, fields);
2035
+ };
2036
+ /** Whether projecting these rows reads asset views (media fields, rich-text images, a default SEO image). */
2037
+ const needsMediaReads = (env, model, rows, fields) => env.media !== void 0 && (mediaIdsOf(model, rows.map((row) => row.data), fields).size > 0 || seoImageIdOf(env, fields) !== void 0);
2038
+ /** The site's default social image a `?seo=resolved` read needs (when the response carries an SEO field). */
2039
+ const seoImageIdOf = (env, fields) => env.seo?.imageId && seoFieldsOf(fields).length > 0 ? env.seo.imageId : void 0;
2040
+ /** A default SEO image is shown only while it is a public image (never through a signed URL). */
2041
+ const isPublicImage = (view) => view.visibility === "public" && view.mimeType.startsWith("image/");
2042
+ /**
2043
+ * Asset views for the media a response shows (media fields and rich-text images). Private assets get signed,
2044
+ * expiring URLs (package G); the read mask already decided whether the caller may see the field at all.
2045
+ * Delivery views carry only what a site needs, never storage keys or internal attribution.
2046
+ */
2047
+ const loadMediaViews = async (env, model, rows, fields) => {
2048
+ if (!env.media) return {
2049
+ byId: null,
2050
+ seoImage: null
2051
+ };
2052
+ const ids = mediaIdsOf(model, rows.map((row) => row.data), fields);
2053
+ const seoImageId = seoImageIdOf(env, fields);
2054
+ if (seoImageId !== void 0) ids.add(seoImageId);
2055
+ if (ids.size === 0) return {
2056
+ byId: /* @__PURE__ */ new Map(),
2057
+ seoImage: null
2058
+ };
2059
+ const assets = await findLiveManyOnSite(env.siteId, [...ids], env.executor);
2060
+ const views = await toAssetViews(env.media, assets, env.executor);
2061
+ const shaped = (view) => env.audience === "delivery" ? toDeliveryAsset(view) : view;
2062
+ const seoImage = views.find((view) => view.id === seoImageId && isPublicImage(view));
2063
+ return {
2064
+ byId: new Map(views.map((view) => [view.id, shaped(view)])),
2065
+ seoImage: seoImage ? shaped(seoImage) : null
2066
+ };
2067
+ };
2068
+ /** An asset as delivery shows it: what a site needs, never storage keys or internal attribution. */
2069
+ const toDeliveryAsset = (view) => ({
2070
+ id: view.id,
2071
+ filename: view.filename,
2072
+ mimeType: view.mimeType,
2073
+ sizeBytes: view.sizeBytes,
2074
+ width: view.width,
2075
+ height: view.height,
2076
+ alt: view.alt,
2077
+ caption: view.caption,
2078
+ focalPoint: view.focalPoint,
2079
+ url: view.url,
2080
+ urlExpiresAt: view.urlExpiresAt?.toISOString() ?? null,
2081
+ variants: view.variants.filter((variant) => variant.url !== null).map(({ name, width, height, format, mimeType, url }) => ({
2082
+ name,
2083
+ width,
2084
+ height,
2085
+ format,
2086
+ mimeType,
2087
+ url
2088
+ }))
2089
+ });
2090
+ /** System attributes first, then the readable, selected fields by API key. */
2091
+ const systemAttributes = (env, row) => ({
2092
+ id: row.entry_id,
2093
+ locale: row.locale,
2094
+ createdAt: row.created_at.toISOString(),
2095
+ updatedAt: row.updated_at.toISOString(),
2096
+ ...env.audience === "delivery" ? { publishedAt: row.updated_at.toISOString() } : {}
2097
+ });
2098
+ const projectRows = async (env, model, policy, rows, query) => {
2099
+ const fields = selectFields(model, policy.readMask, query.fields);
2100
+ const { visible: visibleTargets, fetched } = await relationTargets(env, model, rows, fields);
2101
+ const populateEnv = {
2102
+ ...env,
2103
+ project: projectRows,
2104
+ fetch: fetchReusing(fetched),
2105
+ policyFor: readPolicy,
2106
+ system: systemAttributes
2107
+ };
2108
+ const populated = await populateRelations(populateEnv, rows, fields, query.populate);
2109
+ const media = await loadMediaViews(env, model, rows, fields);
2110
+ return rows.map((row) => {
2111
+ const data = projectData(row.data, {
2112
+ model,
2113
+ fields,
2114
+ visibleTargets,
2115
+ populated,
2116
+ ...env.audience === "delivery" ? { richText: env.richText ?? "json" } : {},
2117
+ mediaAssets: media.byId
2118
+ });
2119
+ return {
2120
+ row,
2121
+ data: env.seo ? resolveSeoFields(env, {
2122
+ defaults: env.seo,
2123
+ image: media.seoImage
2124
+ }, model, policy, fields, row, data) : data
2125
+ };
2126
+ });
2127
+ };
2128
+ //#endregion
2129
+ //#region ../../apps/api/src/content/compiler/querystring.ts
2130
+ const SEGMENT = /^\[([^[\]]*)\]/;
2131
+ const MAX_DEPTH = 10;
2132
+ const MAX_KEY_LENGTH = 300;
2133
+ const splitKey = (key) => {
2134
+ const open = key.indexOf("[");
2135
+ if (open === -1) return [key];
2136
+ const segments = [key.slice(0, open)];
2137
+ let rest = key.slice(open);
2138
+ while (rest.length > 0) {
2139
+ const match = SEGMENT.exec(rest);
2140
+ if (!match) throw queryInvalid(`Malformed query parameter "${key}"`);
2141
+ segments.push(match[1]);
2142
+ rest = rest.slice(match[0].length);
2143
+ }
2144
+ return segments;
2145
+ };
2146
+ const isTree$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2147
+ const assign = (tree, segments, value, key) => {
2148
+ let node = tree;
2149
+ segments.forEach((segment, index) => {
2150
+ if (segment === "" || segment === "__proto__" || segment === "constructor" || segment === "prototype") throw queryInvalid(`Malformed query parameter "${key}"`);
2151
+ const last = index === segments.length - 1;
2152
+ const existing = Object.hasOwn(node, segment) ? node[segment] : void 0;
2153
+ if (last) {
2154
+ if (existing === void 0) node[segment] = value;
2155
+ else if (typeof existing === "string") node[segment] = [existing, value];
2156
+ else if (Array.isArray(existing)) existing.push(value);
2157
+ else throw queryInvalid(`Query parameter "${key}" conflicts with another parameter`);
2158
+ return;
2159
+ }
2160
+ if (existing === void 0) {
2161
+ const child = Object.create(null);
2162
+ node[segment] = child;
2163
+ node = child;
2164
+ } else if (isTree$1(existing)) node = existing;
2165
+ else throw queryInvalid(`Query parameter "${key}" conflicts with another parameter`);
2166
+ });
2167
+ };
2168
+ const parseQueryTree = (search) => {
2169
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
2170
+ const tree = Object.create(null);
2171
+ let count = 0;
2172
+ for (const [key, value] of params) {
2173
+ count += 1;
2174
+ if (count > QUERY_LIMITS.maxParameters) throw queryInvalid(`At most ${QUERY_LIMITS.maxParameters} query parameters are allowed`);
2175
+ if (key.length > MAX_KEY_LENGTH || value.length > QUERY_LIMITS.maxValueLength) throw queryInvalid("A query parameter is too long");
2176
+ const segments = splitKey(key);
2177
+ if (segments.length > MAX_DEPTH) throw queryInvalid(`Query parameter "${key}" is nested too deeply`);
2178
+ assign(tree, segments, value, key);
2179
+ }
2180
+ return tree;
2181
+ };
2182
+ /** A list value: repeated parameters, `a,b` comma lists, or `[0]`, `[1]`… keys. */
2183
+ const toList = (value, { commas = true } = {}) => {
2184
+ if (value === void 0) return [];
2185
+ if (Array.isArray(value)) return value;
2186
+ if (typeof value === "string") return commas ? value.split(",").filter((item) => item.length > 0) : [value];
2187
+ const keys = Object.keys(value);
2188
+ if (!keys.every((key) => /^\d+$/.test(key))) throw queryInvalid("Expected a list");
2189
+ return keys.sort((a, b) => Number(a) - Number(b)).map((key) => value[key]);
2190
+ };
2191
+ //#endregion
2192
+ //#region ../../apps/api/src/content/compiler/parse.ts
2193
+ const TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
2194
+ "filters",
2195
+ "sort",
2196
+ "page",
2197
+ "pageSize",
2198
+ "fields",
2199
+ "populate",
2200
+ "locale",
2201
+ "snapshot",
2202
+ "richText",
2203
+ "seo",
2204
+ "q",
2205
+ "status",
2206
+ "author"
2207
+ ]);
2208
+ const OPERATOR_SET = new Set(FILTER_OPERATORS);
2209
+ const isTree = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2210
+ const single = (value, name) => {
2211
+ if (value === void 0) return;
2212
+ if (typeof value !== "string") throw queryInvalid(`"${name}" must be given once`);
2213
+ return value;
2214
+ };
2215
+ const positiveInteger = (value, name, max) => {
2216
+ const text = single(value, name);
2217
+ if (text === void 0) return;
2218
+ if (!/^\d{1,15}$/.test(text) || Number(text) < 1 || Number(text) > max) throw queryInvalid(`"${name}" must be an integer between 1 and ${max}`);
2219
+ return Number(text);
2220
+ };
2221
+ const resolveTarget = (context, key) => {
2222
+ if (SYSTEM_ATTRIBUTES.includes(key)) return {
2223
+ kind: "system",
2224
+ name: key
2225
+ };
2226
+ const field = findFieldByApiKey(context.model.fields, key);
2227
+ if (!field) throw queryInvalid(`Unknown field "${key}"`, { field: key });
2228
+ if (!context.isReadable(field)) throw queryForbidden(`You may not filter, sort or search on "${key}"`, { field: key });
2229
+ return {
2230
+ kind: "field",
2231
+ field
2232
+ };
2233
+ };
2234
+ const conditionFor = (state, target, operator, raw, label) => {
2235
+ if (!OPERATOR_SET.has(operator)) throw queryInvalid(`Unknown operator "${operator}" on "${label}"`);
2236
+ const op = operator;
2237
+ state.conditions += 1;
2238
+ if (state.conditions > QUERY_LIMITS.maxConditions) throw queryInvalid(`At most ${QUERY_LIMITS.maxConditions} filter conditions are allowed`);
2239
+ assertOperatorAllowed(target, op, label);
2240
+ if (isTree(raw) && !isListOperator(op)) throw queryInvalid(`"${label}" ${op} needs a value`);
2241
+ const operand = isListOperator(op) ? toList(raw) : raw;
2242
+ if (!isListOperator(op) && Array.isArray(operand)) throw queryInvalid(`"${label}" ${op} needs a single value`);
2243
+ return {
2244
+ kind: "condition",
2245
+ target,
2246
+ operator: op,
2247
+ value: coerceOperand(target, op, operand, label, QUERY_LIMITS.maxInValues)
2248
+ };
2249
+ };
2250
+ const combine = (kind, nodes) => nodes.length === 1 ? nodes[0] : {
2251
+ kind,
2252
+ nodes
2253
+ };
2254
+ const parseFilterTree = (state, tree, depth) => {
2255
+ if (depth > QUERY_LIMITS.maxFilterDepth) throw queryInvalid("Filters are nested too deeply");
2256
+ const nodes = [];
2257
+ for (const [key, value] of Object.entries(tree)) if (key === "$and" || key === "$or") {
2258
+ const children = toList(value, { commas: false }).map((child) => {
2259
+ if (!isTree(child)) throw queryInvalid(`${key} needs a list of filter objects`);
2260
+ return parseFilterTree(state, child, depth + 1);
2261
+ });
2262
+ if (children.length === 0) throw queryInvalid(`${key} needs at least one filter`);
2263
+ nodes.push(combine(key === "$and" ? "and" : "or", children));
2264
+ } else if (key === "$not") {
2265
+ if (!isTree(value)) throw queryInvalid("$not needs a filter object");
2266
+ nodes.push({
2267
+ kind: "not",
2268
+ node: parseFilterTree(state, value, depth + 1)
2269
+ });
2270
+ } else if (key.startsWith("$")) throw queryInvalid(`Unknown filter keyword "${key}"`);
2271
+ else {
2272
+ const target = resolveTarget(state.context, key);
2273
+ if (!isTree(value)) {
2274
+ nodes.push(conditionFor(state, target, "$eq", value, key));
2275
+ continue;
2276
+ }
2277
+ const operators = Object.entries(value);
2278
+ if (operators.length === 0) throw queryInvalid(`"${key}" needs an operator`);
2279
+ nodes.push(combine("and", operators.map(([operator, operand]) => conditionFor(state, target, operator, operand, key))));
2280
+ }
2281
+ if (nodes.length === 0) throw queryInvalid("Empty filter");
2282
+ return combine("and", nodes);
2283
+ };
2284
+ const parseSort = (context, value) => {
2285
+ const items = toList(value);
2286
+ if (items.length > QUERY_LIMITS.maxSortTerms) throw queryInvalid(`At most ${QUERY_LIMITS.maxSortTerms} sort terms are allowed`);
2287
+ return items.map((item) => {
2288
+ if (typeof item !== "string") throw queryInvalid("sort needs \"field\" or \"field:asc|desc\" values");
2289
+ const [key = "", direction = "asc", extra] = item.split(":");
2290
+ if (extra !== void 0 || direction !== "asc" && direction !== "desc") throw queryInvalid(`Invalid sort "${item}"`);
2291
+ const target = resolveTarget(context, key);
2292
+ if (target.kind === "field" && !isSortable(target.field)) throw queryInvalid(`"${key}" is not sortable: mark it sortable first`);
2293
+ return {
2294
+ target,
2295
+ direction
2296
+ };
2297
+ });
2298
+ };
2299
+ const parseFields = (context, value) => {
2300
+ if (value === void 0) return null;
2301
+ return toList(value).map((item) => {
2302
+ if (typeof item !== "string") throw queryInvalid("fields needs a list of field names");
2303
+ const target = resolveTarget(context, item);
2304
+ if (target.kind === "system") throw queryInvalid(`"${item}" is always returned; list only model fields`);
2305
+ return target.field;
2306
+ });
2307
+ };
2308
+ const relationField = (context, model, key) => {
2309
+ const field = findFieldByApiKey(model.fields, key);
2310
+ if (!field || field.type !== "relation") throw queryInvalid(`"${key}" is not a relation of "${model.apiKey}" and cannot be populated`);
2311
+ if (model.id === context.model.id && !context.isReadable(field)) throw queryForbidden(`You may not populate "${key}"`, { field: key });
2312
+ return field;
2313
+ };
2314
+ const parsePopulate = (context, value) => {
2315
+ const root = /* @__PURE__ */ new Map();
2316
+ for (const item of toList(value)) {
2317
+ if (typeof item !== "string") throw queryInvalid("populate needs a list of relation paths");
2318
+ if (item === "*") {
2319
+ for (const field of context.model.fields) if (field.type === "relation" && !field.deprecated && context.isReadable(field)) root.set(field.id, root.get(field.id) ?? /* @__PURE__ */ new Map());
2320
+ continue;
2321
+ }
2322
+ const segments = item.split(".");
2323
+ if (segments.length > QUERY_LIMITS.maxPopulateDepth) throw queryInvalid(`populate is limited to ${QUERY_LIMITS.maxPopulateDepth} levels`);
2324
+ let model = context.model;
2325
+ let node = root;
2326
+ for (const segment of segments) {
2327
+ if (!model) throw queryInvalid(`Cannot populate "${item}"`);
2328
+ const field = relationField(context, model, segment);
2329
+ const next = node.get(field.id) ?? /* @__PURE__ */ new Map();
2330
+ node.set(field.id, next);
2331
+ node = next;
2332
+ model = field.type === "relation" ? context.resolveModel(field.settings.target) : void 0;
2333
+ }
2334
+ }
2335
+ return root;
2336
+ };
2337
+ const parseSearch = (context, value) => {
2338
+ const text = single(value, "q")?.trim();
2339
+ if (!text) return null;
2340
+ const field = effectiveTitleField(context.model);
2341
+ if (!field || !TEXT_TITLE_FIELD_TYPES.has(field.type)) throw queryInvalid(`"${context.model.apiKey}" has no text title field to search`);
2342
+ if (!context.isReadable(field)) throw queryForbidden("You may not search this model", { field: field.apiKey });
2343
+ return {
2344
+ field,
2345
+ text
2346
+ };
2347
+ };
2348
+ /** Parses a bracket querystring tree into a content query for one model. */
2349
+ const STATUS_SET = new Set(ENTRY_LIST_STATUSES);
2350
+ /** `status` and `author` (admin lists only). */
2351
+ const parseAdminFilters = (tree, context) => {
2352
+ const status = single(tree.status, "status");
2353
+ const author = single(tree.author, "author");
2354
+ if ((status !== void 0 || author !== void 0) && !context.allowAdminFilters) throw queryInvalid("\"status\" and \"author\" are only available on the admin API");
2355
+ if (status !== void 0 && !STATUS_SET.has(status)) throw queryInvalid(`"status" must be one of ${ENTRY_LIST_STATUSES.join(", ")}`);
2356
+ if (author !== void 0 && !isStableId(author)) throw queryInvalid("\"author\" must be an admin user ID");
2357
+ return {
2358
+ ...status !== void 0 ? { status } : {},
2359
+ ...author !== void 0 ? { author } : {}
2360
+ };
2361
+ };
2362
+ const RICH_TEXT_MODE_SET = new Set(RICH_TEXT_MODES);
2363
+ /** `?richText=json|html|both` on delivery and preview reads; the default there, nothing on admin reads. */
2364
+ const parseRichText = (tree, context) => {
2365
+ const value = single(tree.richText, "richText");
2366
+ if (!context.allowRichText) {
2367
+ if (value !== void 0) throw queryInvalid("richText is only available on the delivery and preview APIs");
2368
+ return {};
2369
+ }
2370
+ if (value !== void 0 && !RICH_TEXT_MODE_SET.has(value)) throw queryInvalid(`"richText" must be one of ${RICH_TEXT_MODES.join(", ")}`);
2371
+ return { richText: value ?? "json" };
2372
+ };
2373
+ const SEO_MODE_SET = new Set(SEO_MODES);
2374
+ /** `?seo=raw|resolved` on delivery and preview reads; `resolved` needs a readable SEO field on the model. */
2375
+ const parseSeo = (tree, context) => {
2376
+ const value = single(tree.seo, "seo");
2377
+ if (value === void 0) return {};
2378
+ if (!context.allowSeo) throw queryInvalid("seo is only available on the delivery and preview APIs");
2379
+ if (!SEO_MODE_SET.has(value)) throw queryInvalid(`"seo" must be one of ${SEO_MODES.join(", ")}`);
2380
+ if (value === "resolved" && !context.model.fields.some((field) => !field.deprecated && isSeoField(field) && context.isReadable(field))) throw queryInvalid(`"${context.model.apiKey}" has no SEO field to resolve`);
2381
+ return { seo: value };
2382
+ };
2383
+ const parseContentQuery = (tree, context) => {
2384
+ for (const key of Object.keys(tree)) if (!TOP_LEVEL_KEYS.has(key)) throw queryInvalid(`Unknown query parameter "${key}"`);
2385
+ const filters = tree.filters;
2386
+ if (filters !== void 0 && !isTree(filters)) throw queryInvalid("filters needs field conditions, e.g. filters[title][$eq]=Hello");
2387
+ const locale = single(tree.locale, "locale");
2388
+ if (locale !== void 0 && !context.locales.includes(locale)) throw queryInvalid(`Unknown locale "${locale}"`);
2389
+ const snapshot = positiveInteger(tree.snapshot, "snapshot", Number.MAX_SAFE_INTEGER);
2390
+ if (snapshot !== void 0 && !context.allowSnapshot) throw queryInvalid("snapshot is only available on the delivery API");
2391
+ return {
2392
+ filter: filters ? parseFilterTree({
2393
+ context,
2394
+ conditions: 0
2395
+ }, filters, 1) : null,
2396
+ search: parseSearch(context, tree.q),
2397
+ sort: parseSort(context, tree.sort),
2398
+ page: positiveInteger(tree.page, "page", 1e6) ?? 1,
2399
+ pageSize: positiveInteger(tree.pageSize, "pageSize", QUERY_LIMITS.maxPageSize) ?? QUERY_LIMITS.defaultPageSize,
2400
+ fields: parseFields(context, tree.fields),
2401
+ populate: parsePopulate(context, tree.populate),
2402
+ locale,
2403
+ snapshot,
2404
+ ...parseRichText(tree, context),
2405
+ ...parseSeo(tree, context),
2406
+ ...parseAdminFilters(tree, context)
2407
+ };
2408
+ };
2409
+ //#endregion
2410
+ //#region ../../apps/api/src/repositories/contentRevisions.ts
2411
+ /** Stored data of several revisions at once (titles and covers for the snapshot diff). */
2412
+ const findDataByIds = (ids, executor = db) => ids.length === 0 ? Promise.resolve([]) : executor.selectFrom("content_revisions").select(["id", "data"]).where("id", "in", [...ids]).execute();
2413
+ sql`uuid[]`, sql`text[]`;
2414
+ /**
2415
+ * MySQL: `cast(concat('[', group_concat(<json texts> separator ','), ']') as json)`, an ordered JSON array
2416
+ * aggregate (`JSON_ARRAYAGG` takes no ORDER BY); null for an empty group, like PostgreSQL's aggregates.
2417
+ * `items` is the inside of `group_concat`: JSON text per row and its ORDER BY.
2418
+ */
2419
+ const mysqlJsonArrayOf = (items) => sql`cast(concat('[', group_concat(${items} separator ','), ']') as json)`;
2420
+ sql`jsonb_build_object`, sql`jsonb_agg`, sql`'[]'::jsonb`, sql`json_build_object`, sql`json_agg`, sql`'[]'::json`;
2421
+ sql``, sql` desc`;
2422
+ sql` nulls first`, sql` nulls last`;
2423
+ //#endregion
2424
+ //#region ../../apps/api/src/services/contentReads.ts
2425
+ /** Filters, search and row filter of a parsed query as SQL conditions. */
2426
+ /** Whether `queryConditions` names an `entries` column (`HeadQueryPlan.conditionsReadEntries`). */
2427
+ const queryReadsEntries = (query, policy) => query.filter !== null && filterReadsEntries(query.filter) || query.author !== void 0 || policy.rowFilter !== null;
2428
+ const queryConditions = (context, query, policy) => {
2429
+ const rowFilter = compileRowFilter(policy.rowFilter, context.actor);
2430
+ return [
2431
+ ...query.filter ? [compileFilter(query.filter)] : [],
2432
+ ...query.search ? [compileSearch(query.search.field.id, query.search.text)] : [],
2433
+ ...query.status ? [compileStatusCondition(query.status)] : [],
2434
+ ...query.author ? [compileAuthorCondition(query.author)] : [],
2435
+ ...rowFilter ? [rowFilter] : []
2436
+ ];
2437
+ };
2438
+ const parseQueryFor = (context, model, policy, rawQuery, options) => parseContentQuery(parseQueryTree(rawQuery), {
2439
+ model: model.definition,
2440
+ locales: context.snapshot.locales.map((locale) => locale.code),
2441
+ isReadable: (field) => maskAllows(policy.readMask, field),
2442
+ allowSnapshot: options.allowSnapshot,
2443
+ allowAdminFilters: options.allowAdminFilters ?? false,
2444
+ allowRichText: options.allowRichText ?? false,
2445
+ allowSeo: options.allowSeo ?? false,
2446
+ resolveModel: (modelId) => resolveModelById(context.snapshot, modelId)?.definition
2447
+ });
2448
+ //#endregion
2449
+ //#region ../../apps/api/src/repositories/sites.ts
2450
+ const findById = (id, trx = db) => trx.selectFrom("sites").selectAll().where("id", "=", id).executeTakeFirst();
2451
+ const findByKey = (key, trx = db) => trx.selectFrom("sites").selectAll().where("key", "=", key).executeTakeFirst();
2452
+ const findPrimary = (trx = db) => trx.selectFrom("sites").selectAll().where("is_primary", "=", true).executeTakeFirstOrThrow();
2453
+ /** A site's SEO defaults (null until first saved) and the version an update must name. */
2454
+ const findSeoDefaults = (id, trx = db) => trx.selectFrom("sites").select([
2455
+ "id",
2456
+ "key",
2457
+ "name",
2458
+ "version",
2459
+ "seo_defaults"
2460
+ ]).where("id", "=", id).executeTakeFirst();
2461
+ //#endregion
2462
+ //#region ../../apps/api/src/services/siteSeo.ts
2463
+ const siteNotFound$1 = () => new AppError(404, "SITE_NOT_FOUND", "Site not found");
2464
+ /** Stored defaults, or the empty ones when none were saved (or the stored value no longer fits the schema). */
2465
+ const readDefaults = (stored) => stored !== null && Value.Check(SeoDefaultsSchema, stored) ? stored : EMPTY_SEO_DEFAULTS;
2466
+ /** The defaults a resolved delivery read merges in. */
2467
+ const loadSeoDefaults = async (siteId, executor) => readDefaults((await findSeoDefaults(siteId, executor))?.seo_defaults ?? null);
2468
+ /** The caller reads something on this site (the same rule as its content: deny by default). */
2469
+ const assertReadsSite = async (context) => {
2470
+ for (const { definition } of context.snapshot.definitions) {
2471
+ if (!isModelDefinition(definition)) continue;
2472
+ if ((await context.permissions.evaluate(context.actor, {
2473
+ action: "read",
2474
+ modelId: definition.id
2475
+ })).allowed) return;
2476
+ }
2477
+ throw context.actor.kind === "anonymous" ? new AppError(401, "UNAUTHENTICATED", "This site needs a delivery token") : new AppError(403, "FORBIDDEN", "You may not read content on this site");
2478
+ };
2479
+ const deliveryImage = async (context, imageId) => {
2480
+ if (imageId === null || !context.media) return null;
2481
+ const assets = await findLiveManyOnSite(context.site.id, [imageId], context.db);
2482
+ const [view] = await toAssetViews(context.media, assets, context.db);
2483
+ return view && isPublicImage(view) ? toDeliveryAsset(view) : null;
2484
+ };
2485
+ /** `GET /api/site`: the site's key, name and SEO defaults, its default image as a delivered asset. */
2486
+ const getDeliverySite = async (context) => {
2487
+ await assertReadsSite(context);
2488
+ const row = await findSeoDefaults(context.site.id, context.db);
2489
+ if (!row) throw siteNotFound$1();
2490
+ const { imageId, ...seo } = readDefaults(row.seo_defaults);
2491
+ return {
2492
+ key: row.key,
2493
+ name: row.name,
2494
+ seo: {
2495
+ ...seo,
2496
+ image: await deliveryImage(context, imageId)
2497
+ }
2498
+ };
2499
+ };
2500
+ //#endregion
2501
+ //#region ../../apps/api/src/services/contentDeliveryReads.ts
2502
+ /**
2503
+ * Delivery-shaped reads (delivery, preview, GraphQL root fields) at one consistent moment (plan
2504
+ * delivery-perf). The heads, the page total and the site's publication sequence come from one statement, so
2505
+ * a read that needs nothing else (no relation, populate or asset follow-up) runs without a transaction.
2506
+ * Anything that needs a follow-up statement runs in one REPEATABLE READ, read-only transaction, so every
2507
+ * statement of the response sees the moment `meta.snapshot` names, except a live published read whose only
2508
+ * follow-up is relation visibility: that runs on the pool (`readConsistently` explains why it is safe).
2509
+ */
2510
+ const deliveryEnvironment = (context, executor, source, locale, richText, seo) => ({
2511
+ executor,
2512
+ snapshot: context.snapshot,
2513
+ permissions: context.permissions,
2514
+ actor: context.actor,
2515
+ siteId: context.site.id,
2516
+ audience: "delivery",
2517
+ source,
2518
+ locale,
2519
+ ...context.media ? { media: context.media } : {},
2520
+ ...richText ? { richText } : {},
2521
+ ...seo ? { seo } : {}
2522
+ });
2523
+ const environmentOf = (read, executor) => deliveryEnvironment(read.context, executor, read.source, read.query.locale, read.query.richText, read.seo);
2524
+ /**
2525
+ * Selected top-level relation or media fields, populate, or a default SEO image to show: the read will need a
2526
+ * follow-up statement.
2527
+ */
2528
+ const followsUp = (read) => read.query.populate.size > 0 || selectFields(read.model, read.policy.readMask, read.query.fields).some((field) => field.type === "relation" || field.type === "media" || Boolean(read.seo?.imageId) && isSeoField(field));
2529
+ /**
2530
+ * A live read of published heads (no `?snapshot`, not a preview or draft read, no extra conditions) without
2531
+ * populate: its only possible follow-up is relation visibility (asset views are checked on the rows).
2532
+ */
2533
+ const isLiveVisibilityRead = (read) => read.source.kind === "heads" && read.source.state === "published" && read.query.populate.size === 0 && (read.conditions?.length ?? 0) === 0;
2534
+ /**
2535
+ * Loads the site's SEO defaults when the query asks for resolved SEO. Read before the heads, outside their
2536
+ * consistent moment: defaults are not part of a snapshot (`?snapshot=N&seo=resolved` uses today's).
2537
+ */
2538
+ const withSeoDefaults = async (read) => read.query.seo === "resolved" ? {
2539
+ ...read,
2540
+ seo: await loadSeoDefaults(read.context.site.id, read.context.db)
2541
+ } : read;
2542
+ const seqOf = (seq) => {
2543
+ if (seq === null) throw new Error("The site has no publication state");
2544
+ return seq;
2545
+ };
2546
+ /**
2547
+ * Runs the plan's statement on the pool when the read cannot need a follow-up, and keeps that result when
2548
+ * the rows indeed need none; otherwise reads again inside one REPEATABLE READ transaction. `finish` projects
2549
+ * the rows (through the same executor), so its own statements see the same moment. A live published read
2550
+ * whose only follow-up is relation visibility also stays on the pool (see below).
2551
+ */
2552
+ const readConsistently = async (read, plan, options, finish) => {
2553
+ const compiled = compileHeadPage(plan, options);
2554
+ const { db } = read.context;
2555
+ const live = isLiveVisibilityRead(read);
2556
+ if (!followsUp(read) || live) {
2557
+ const env = environmentOf(read, db);
2558
+ const { rows, meta } = await runHeadPage(compiled.rows, db);
2559
+ const fields = selectFields(read.model, read.policy.readMask, read.query.fields);
2560
+ if (meta && !needsFollowUpReads(env, read.model, rows, read.query, read.policy)) return finish({
2561
+ env,
2562
+ rows,
2563
+ total: meta.total,
2564
+ seq: seqOf(meta.seq)
2565
+ });
2566
+ if (meta && live && !needsMediaReads(env, read.model, rows, fields)) return finish({
2567
+ env,
2568
+ rows,
2569
+ total: meta.total,
2570
+ seq: seqOf(meta.seq)
2571
+ });
2572
+ }
2573
+ return withConsistentRead(async (trx) => {
2574
+ const env = environmentOf(read, trx);
2575
+ const page = await runHeadPage(compiled.rows, trx);
2576
+ const meta = page.meta ?? await runHeadPageMeta(compiled.meta, trx);
2577
+ return finish({
2578
+ env,
2579
+ rows: page.rows,
2580
+ total: meta.total,
2581
+ seq: seqOf(meta.seq)
2582
+ });
2583
+ }, db);
2584
+ };
2585
+ const localesOf = (read) => readScopeFor(read.context.snapshot, read.model.definition, read.query.locale, { fallback: read.fallback });
2586
+ const toEntries = async (read, env, rows) => (await projectRows(env, read.model, read.policy, rows, read.query)).map(({ row, data }) => ({
2587
+ ...systemAttributes(env, row),
2588
+ ...data
2589
+ }));
2590
+ /**
2591
+ * One page of entries in the delivery shape, with the total (`total: false` skips counting: a singleton) and
2592
+ * the publication sequence it was read at. `check` runs on the sequence before anything is projected.
2593
+ */
2594
+ const readEntryPage = async (unloaded, options = { total: true }) => {
2595
+ const read = await withSeoDefaults(unloaded);
2596
+ const { context, model, policy, query } = read;
2597
+ const { limit, offset } = toLimitOffset(query.page, query.pageSize);
2598
+ const sort = query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, (field) => maskAllows(policy.readMask, field));
2599
+ const plan = {
2600
+ siteId: context.site.id,
2601
+ modelId: model.definition.id,
2602
+ source: read.source,
2603
+ locales: localesOf(read),
2604
+ conditions: [...queryConditions(context, query, policy), ...read.conditions ?? []],
2605
+ conditionsReadEntries: queryReadsEntries(query, policy) || (read.conditions?.length ?? 0) > 0,
2606
+ orderBy: compileOrderBy(sort),
2607
+ limit,
2608
+ offset
2609
+ };
2610
+ return readConsistently(read, plan, { total: options.total }, async ({ env, rows, total, seq }) => {
2611
+ options.check?.(seq);
2612
+ return {
2613
+ entries: await toEntries(read, env, rows),
2614
+ limit,
2615
+ total: total ?? 0,
2616
+ seq
2617
+ };
2618
+ });
2619
+ };
2620
+ /** One entry by ID in the delivery shape (undefined when it is not there for this read), and the sequence. */
2621
+ const readOneEntry = async (unloaded, id, options = {}) => {
2622
+ const read = await withSeoDefaults(unloaded);
2623
+ const { context, model, policy, query } = read;
2624
+ const plan = {
2625
+ siteId: context.site.id,
2626
+ modelId: model.definition.id,
2627
+ source: read.source,
2628
+ locales: localesOf(read),
2629
+ conditions: [
2630
+ ...queryConditions(context, {
2631
+ ...query,
2632
+ filter: null,
2633
+ search: null
2634
+ }, policy),
2635
+ ...read.conditions ?? [],
2636
+ entryIdIs(id)
2637
+ ],
2638
+ orderBy: [],
2639
+ limit: 1
2640
+ };
2641
+ return readConsistently(read, plan, { total: false }, async ({ env, rows, seq }) => {
2642
+ options.check?.(seq);
2643
+ const [entry] = await toEntries(read, env, rows);
2644
+ return {
2645
+ entry,
2646
+ seq
2647
+ };
2648
+ });
2649
+ };
2650
+ //#endregion
2651
+ //#region ../../apps/api/src/services/contentDelivery.ts
2652
+ const readableModel = async (context, modelKey) => {
2653
+ const model = resolveModel(context.snapshot, modelKey);
2654
+ const policy = await context.permissions.evaluate(context.actor, {
2655
+ action: "read",
2656
+ modelId: model.definition.id
2657
+ });
2658
+ if (!policy.allowed) throw context.actor.kind === "anonymous" ? new AppError(401, "UNAUTHENTICATED", "This content needs a delivery token") : new AppError(403, "FORBIDDEN", `Your token may not read "${modelKey}"`);
2659
+ return {
2660
+ model,
2661
+ policy
2662
+ };
2663
+ };
2664
+ /** The heads a read serves: drafts (GraphQL previews), a pinned snapshot, or the published heads. */
2665
+ const sourceOf = (query, options) => {
2666
+ if (query.snapshot !== void 0 && options.drafts) throw queryInvalid("snapshot reads published content; it cannot be combined with drafts");
2667
+ return options.drafts ? {
2668
+ kind: "heads",
2669
+ state: "draft"
2670
+ } : query.snapshot !== void 0 ? {
2671
+ kind: "snapshot",
2672
+ seq: query.snapshot
2673
+ } : {
2674
+ kind: "heads",
2675
+ state: "published"
2676
+ };
2677
+ };
2678
+ /** A pinned snapshot must already exist: checked against the sequence the read itself saw. */
2679
+ const snapshotCheck = (query) => (current) => {
2680
+ if (query.snapshot !== void 0 && query.snapshot > current) throw snapshotInvalid(query.snapshot, current);
2681
+ };
2682
+ /**
2683
+ * Counts a successful REST read for field usage (plan developer-face §5): once per request, after the
2684
+ * projection, by principal. Admin users and draft reads are never counted.
2685
+ */
2686
+ const recordUsage = async (context, options, model, policy, query) => {
2687
+ const principalKey = usagePrincipalKey(context.actor);
2688
+ if (!options.usage || options.drafts || principalKey === null) return;
2689
+ const reads = await restFieldReads({
2690
+ snapshot: context.snapshot,
2691
+ model,
2692
+ policy,
2693
+ query,
2694
+ policyFor: (modelId) => context.permissions.evaluate(context.actor, {
2695
+ action: "read",
2696
+ modelId
2697
+ })
2698
+ });
2699
+ options.usage.recordRequest(context.site.id, principalKey, query.snapshot ?? null);
2700
+ options.usage.recordFieldReads(context.site.id, principalKey, model.definition.id, reads);
2701
+ };
2702
+ /** Records usage after the response when the caller can defer it (REST), else before returning. */
2703
+ const afterRead = async (options, task) => {
2704
+ if (options.defer) {
2705
+ options.defer(task);
2706
+ return;
2707
+ }
2708
+ await task();
2709
+ };
2710
+ const ITEM_PARAMETERS = /* @__PURE__ */ new Set([
2711
+ "fields",
2712
+ "populate",
2713
+ "locale",
2714
+ "snapshot",
2715
+ "richText",
2716
+ "seo"
2717
+ ]);
2718
+ /** Reading one entry takes only these query parameters; anything else is refused, not ignored. */
2719
+ const assertItemParameters = (rawQuery, allowed) => {
2720
+ const unsupported = [...new URLSearchParams(rawQuery).keys()].find((key) => !allowed.has(key.split("[")[0] ?? key));
2721
+ if (unsupported !== void 0) throw queryInvalid(`"${unsupported}" is not supported when reading one entry`);
2722
+ };
2723
+ /** `GET /api/content/:modelKey`: a page of a collection, or a singleton's one entry. */
2724
+ const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
2725
+ const { model, policy } = await readableModel(context, modelKey);
2726
+ const query = parseQueryFor(context, model, policy, rawQuery, {
2727
+ allowSnapshot: true,
2728
+ allowRichText: true,
2729
+ allowSeo: true
2730
+ });
2731
+ const locale = query.locale ?? context.snapshot.defaultLocale;
2732
+ const singleton = model.definition.kind === "singleton";
2733
+ const { entries, limit, total, seq } = await readEntryPage({
2734
+ context,
2735
+ source: sourceOf(query, options),
2736
+ model,
2737
+ policy,
2738
+ query,
2739
+ fallback: options.fallback ?? true
2740
+ }, {
2741
+ total: !singleton,
2742
+ check: snapshotCheck(query)
2743
+ });
2744
+ const snapshot = query.snapshot ?? seq;
2745
+ let result;
2746
+ if (singleton) {
2747
+ const [entry] = entries;
2748
+ if (!entry) throw entryNotFound(modelKey);
2749
+ result = {
2750
+ data: entry,
2751
+ meta: {
2752
+ locale,
2753
+ snapshot
2754
+ }
2755
+ };
2756
+ } else result = {
2757
+ data: entries,
2758
+ meta: {
2759
+ locale,
2760
+ snapshot,
2761
+ pagination: paginationMeta(query.page, limit, total)
2762
+ }
2763
+ };
2764
+ await afterRead(options, () => recordUsage(context, options, model, policy, query));
2765
+ return result;
2766
+ };
2767
+ /** `GET /api/content/:modelKey/:id`: one published entry (404 for drafts, unknown or unreadable entries). */
2768
+ const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
2769
+ assertItemParameters(rawQuery, ITEM_PARAMETERS);
2770
+ const { model, policy } = await readableModel(context, modelKey);
2771
+ const query = parseQueryFor(context, model, policy, rawQuery, {
2772
+ allowSnapshot: true,
2773
+ allowRichText: true,
2774
+ allowSeo: true
2775
+ });
2776
+ const { entry, seq } = await readOneEntry({
2777
+ context,
2778
+ source: sourceOf(query, options),
2779
+ model,
2780
+ policy,
2781
+ query,
2782
+ fallback: options.fallback ?? true
2783
+ }, id, { check: snapshotCheck(query) });
2784
+ if (!entry) throw entryNotFound(id);
2785
+ await afterRead(options, () => recordUsage(context, options, model, policy, query));
2786
+ return {
2787
+ data: entry,
2788
+ meta: {
2789
+ locale: query.locale ?? context.snapshot.defaultLocale,
2790
+ snapshot: query.snapshot ?? seq
2791
+ }
2792
+ };
2793
+ };
2794
+ //#endregion
2795
+ //#region ../../apps/api/src/repositories/publications.ts
2796
+ const currentSeq = async (siteId, executor = db) => {
2797
+ const row = await executor.selectFrom("publication_state").select("last_seq").where("site_id", "=", siteId).executeTakeFirstOrThrow();
2798
+ return Number(row.last_seq);
2799
+ };
2800
+ //#endregion
2801
+ //#region ../../apps/api/src/repositories/schemaVersions.ts
2802
+ /** The global schema version: the durable value every instance compares its cached snapshot against. */
2803
+ const getSchemaVersion = async (executor = db) => {
2804
+ return (await executor.selectFrom("system_versions").select("schema_version").executeTakeFirstOrThrow()).schema_version;
2805
+ };
2806
+ //#endregion
2807
+ //#region ../../apps/api/src/db/snapshotDiffQuery.ts
2808
+ const POSTGRES = {
2809
+ uuid: (value) => sql`${value}::uuid`,
2810
+ seq: (value) => sql`${value}::bigint`,
2811
+ inModels: (modelIds) => sql`pl.model_id = any(${[...modelIds]}::uuid[])`,
2812
+ distinct: (a, b) => sql`${a} is distinct from ${b}`,
2813
+ aggregate: (object) => sql`jsonb_agg(
2814
+ ${object}
2815
+ order by ch.locale
2816
+ )`,
2817
+ object: sql`jsonb_build_object`,
2818
+ change: sql`change`
2819
+ };
2820
+ const SQLITE = {
2821
+ uuid: (value) => sql`${value.toLowerCase()}`,
2822
+ seq: (value) => sql`${value}`,
2823
+ inModels: (modelIds) => sql`pl.model_id in (select value from json_each(${JSON.stringify(modelIds)}))`,
2824
+ distinct: (a, b) => sql`${a} is not ${b}`,
2825
+ aggregate: (object) => sql`json_group_array(
2826
+ ${object}
2827
+ order by ch.locale
2828
+ )`,
2829
+ object: sql`json_object`,
2830
+ change: sql`change`
2831
+ };
2832
+ /** MySQL: `<=>` is null-safe equality; JSON_ARRAYAGG takes no ORDER BY (`mysqlJsonArrayOf`). */
2833
+ const MYSQL = {
2834
+ uuid: (value) => sql`${value.toLowerCase()}`,
2835
+ seq: (value) => sql`${value}`,
2836
+ inModels: (modelIds) => modelIds.length === 0 ? sql`false` : sql`pl.model_id in (${sql.join([...modelIds])})`,
2837
+ distinct: (a, b) => sql`not (${a} <=> ${b})`,
2838
+ aggregate: (object) => mysqlJsonArrayOf(sql`${object} order by ch.locale`),
2839
+ object: sql`json_object`,
2840
+ change: sql`\`change\``
2841
+ };
2842
+ const modelCondition = (dialect, modelIds) => modelIds === null ? sql`true` : dialect.inModels(modelIds);
2843
+ const afterCondition = (dialect, after) => after === void 0 ? sql`true` : sql`pl.entry_id > ${dialect.uuid(after)}`;
2844
+ /** The revision of (entry, locale) live at `seq`, as a lateral subquery over `c`. */
2845
+ const liveAt = (dialect, siteId, seq) => sql`(
2846
+ select p.revision_id from publication_log p
2847
+ where p.site_id = ${dialect.uuid(siteId)} and p.entry_id = c.entry_id and p.locale = c.locale
2848
+ and p.from_seq <= ${dialect.seq(seq)} and (p.to_seq is null or p.to_seq > ${dialect.seq(seq)})
2849
+ order by p.from_seq desc
2850
+ limit 1
2851
+ )`;
2852
+ const selectSnapshotDiffRows = async (params, executor) => {
2853
+ const dialect = isMysql() ? MYSQL : isSqlite() ? SQLITE : POSTGRES;
2854
+ const { from, to } = params;
2855
+ const scope = sql`pl.site_id = ${dialect.uuid(params.siteId)} and ${modelCondition(dialect, params.modelIds)}
2856
+ and ${afterCondition(dialect, params.after)}`;
2857
+ return (await sql`
2858
+ with candidates as (
2859
+ select pl.entry_id, pl.locale from publication_log pl
2860
+ where pl.from_seq > ${dialect.seq(from)} and pl.from_seq <= ${dialect.seq(to)} and ${scope}
2861
+ union
2862
+ select pl.entry_id, pl.locale from publication_log pl
2863
+ where pl.to_seq > ${dialect.seq(from)} and pl.to_seq <= ${dialect.seq(to)} and ${scope}
2864
+ ),
2865
+ states as (
2866
+ select c.entry_id, c.locale, ${liveAt(dialect, params.siteId, from)} as from_revision_id,
2867
+ ${liveAt(dialect, params.siteId, to)} as to_revision_id
2868
+ from candidates c
2869
+ ),
2870
+ changes as (
2871
+ select s.*,
2872
+ case
2873
+ when s.from_revision_id is null then 'published'
2874
+ when s.to_revision_id is null then 'unpublished'
2875
+ else 'updated'
2876
+ end as ${dialect.change}
2877
+ from states s
2878
+ where ${dialect.distinct(sql`s.from_revision_id`, sql`s.to_revision_id`)}
2879
+ )
2880
+ select ch.entry_id, e.model_id,
2881
+ ${dialect.aggregate(sql`${dialect.object}(
2882
+ 'locale', ch.locale,
2883
+ 'change', ch.${dialect.change},
2884
+ 'fromRevisionId', ch.from_revision_id,
2885
+ 'toRevisionId', ch.to_revision_id
2886
+ )`)} as locales
2887
+ from changes ch
2888
+ join entries e on e.id = ch.entry_id
2889
+ where ${params.rowFilter ?? sql`true`}
2890
+ group by ch.entry_id, e.model_id
2891
+ order by ch.entry_id
2892
+ limit ${params.maxRows}
2893
+ `.execute(executor)).rows.map((row) => typeof row.locales === "string" ? {
2894
+ ...row,
2895
+ locales: JSON.parse(row.locales)
2896
+ } : row);
2897
+ };
2898
+ //#endregion
2899
+ //#region ../../apps/api/src/repositories/snapshotDiff.ts
2900
+ const listChanges = async (query, executor = db) => {
2901
+ if (query.to <= query.from || query.modelIds !== null && query.modelIds.length === 0) return {
2902
+ items: [],
2903
+ nextAfter: null
2904
+ };
2905
+ const rows = await selectSnapshotDiffRows({
2906
+ siteId: query.siteId,
2907
+ from: query.from,
2908
+ to: query.to,
2909
+ after: query.after,
2910
+ modelIds: query.modelIds,
2911
+ rowFilter: query.rowFilter ?? null,
2912
+ maxRows: query.limit + 1
2913
+ }, executor);
2914
+ const page = rows.slice(0, query.limit);
2915
+ return {
2916
+ items: page.map((row) => ({
2917
+ entryId: row.entry_id,
2918
+ modelId: row.model_id,
2919
+ locales: row.locales
2920
+ })),
2921
+ nextAfter: rows.length > query.limit ? page.at(-1)?.entry_id ?? null : null
2922
+ };
2923
+ };
2924
+ /**
2925
+ * The schema version recorded for each publication sequence number in the `publication_snapshots`
2926
+ * ledger; numbers without a ledger row (snapshot 0, rows from before the ledger) are absent.
2927
+ */
2928
+ const schemaVersionsAt = async (siteId, seqs, executor = db) => {
2929
+ if (seqs.length === 0) return /* @__PURE__ */ new Map();
2930
+ const rows = await executor.selectFrom("publication_snapshots").select(["seq", "schema_version"]).where("site_id", "=", siteId).where("seq", "in", seqs.map((seq) => String(seq))).execute();
2931
+ return new Map(rows.map((row) => [Number(row.seq), row.schema_version]));
2932
+ };
2933
+ /** When the site's snapshot `seq` was created, from the ledger (undefined without a ledger row). */
2934
+ const snapshotCreatedAt = async (siteId, seq, executor = db) => (await executor.selectFrom("publication_snapshots").select("created_at").where("site_id", "=", siteId).where("seq", "=", String(seq)).executeTakeFirst())?.created_at;
2935
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2936
+ /**
2937
+ * Models the caller may read. An authenticated caller (session, token, app user) with none (or an instance
2938
+ * with no models yet) gets empty results, never an error. Anonymous callers with nothing readable get 401,
2939
+ * as the delivery routes answer them when the `public` role grants nothing.
2940
+ */
2941
+ const readableModels = async (context) => {
2942
+ const models = context.snapshot.definitions.map((active) => active.definition).filter((definition) => isModelDefinition(definition));
2943
+ const readable = [];
2944
+ for (const definition of models) {
2945
+ const policy = await context.permissions.evaluate(context.actor, {
2946
+ action: "read",
2947
+ modelId: definition.id
2948
+ });
2949
+ if (policy.allowed) readable.push({
2950
+ definition,
2951
+ rowFilter: policy.rowFilter,
2952
+ readMask: policy.readMask
2953
+ });
2954
+ }
2955
+ if (readable.length === 0 && context.actor.kind === "anonymous") throw new AppError(401, "UNAUTHENTICATED", "Snapshots need a delivery token");
2956
+ return readable;
2957
+ };
2958
+ /** The revision an entry is described from: the default locale's when it changed, else the first. */
2959
+ const revisionOf = (item, defaultLocale) => {
2960
+ const locale = item.locales.find((candidate) => candidate.locale === defaultLocale) ?? item.locales[0];
2961
+ return locale ? locale.toRevisionId ?? locale.fromRevisionId : null;
2962
+ };
2963
+ /**
2964
+ * Title and cover per changed entry, read from one revision each (one query for the page). Fields the
2965
+ * caller's read mask hides are left out.
2966
+ */
2967
+ const describeEntries = async (items, models, defaultLocale, executor) => {
2968
+ const revisionIds = [...new Set(items.flatMap((item) => revisionOf(item, defaultLocale) ?? []))];
2969
+ const rows = await findDataByIds(revisionIds, executor);
2970
+ const dataById = new Map(rows.map((row) => [row.id, row.data]));
2971
+ return (item) => {
2972
+ const model = models.get(item.modelId);
2973
+ const revisionId = revisionOf(item, defaultLocale);
2974
+ const data = revisionId ? dataById.get(revisionId) : void 0;
2975
+ if (!model || !data) return {
2976
+ title: null,
2977
+ coverMediaId: null
2978
+ };
2979
+ const titleField = effectiveTitleField(model.definition);
2980
+ const cover = effectiveLayout(model.definition).cover;
2981
+ const title = titleField && maskAllows(model.readMask, titleField) ? data[titleField.id] : void 0;
2982
+ const coverId = cover && maskAllows(model.readMask, cover) ? data[cover.id] : void 0;
2983
+ return {
2984
+ title: typeof title === "string" && title.trim() !== "" ? title : typeof title === "number" ? String(title) : null,
2985
+ coverMediaId: typeof coverId === "string" ? coverId : null
2986
+ };
2987
+ };
2988
+ };
2989
+ const boundedLimit = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
2990
+ /** `GET /api/snapshots/changes`, GraphQL `_changes`: one page of entries changed between `from` and `to`. */
2991
+ const listSnapshotChanges = async (context, query) => {
2992
+ if (query.after !== void 0 && !UUID.test(query.after)) throw queryInvalid("\"after\" must be a cursor returned by a previous page");
2993
+ const readable = await readableModels(context);
2994
+ const byId = new Map(readable.map((model) => [model.definition.id, model]));
2995
+ return withConsistentRead(async (trx) => {
2996
+ const current = await currentSeq(context.site.id, trx);
2997
+ const to = query.to ?? current;
2998
+ if (to > current) throw snapshotInvalid(to, current);
2999
+ if (query.from < 0 || query.from > to) throw queryInvalid("\"from\" must be between 0 and \"to\"");
3000
+ const page = await listChanges({
3001
+ siteId: context.site.id,
3002
+ from: query.from,
3003
+ to,
3004
+ after: query.after,
3005
+ limit: boundedLimit(query.limit),
3006
+ modelIds: [...byId.keys()],
3007
+ rowFilter: compileModelRowFilters(readable.map((model) => ({
3008
+ modelId: model.definition.id,
3009
+ rowFilter: model.rowFilter
3010
+ })), context.actor)
3011
+ }, trx);
3012
+ const versions = await schemaVersionsAt(context.site.id, [query.from, to], trx);
3013
+ const describe = await describeEntries(page.items, byId, context.snapshot.defaultLocale, trx);
3014
+ return {
3015
+ from: query.from,
3016
+ to,
3017
+ schemaVersions: {
3018
+ from: versions.get(query.from) ?? null,
3019
+ to: versions.get(to) ?? null
3020
+ },
3021
+ items: page.items.flatMap((item) => {
3022
+ const model = byId.get(item.modelId)?.definition;
3023
+ return model ? [{
3024
+ id: item.entryId,
3025
+ modelId: item.modelId,
3026
+ modelKey: model.apiKey,
3027
+ routeKey: routeKeyOf(model),
3028
+ ...describe(item),
3029
+ locales: item.locales.map(({ locale, change, fromRevisionId, toRevisionId }) => ({
3030
+ locale,
3031
+ change,
3032
+ revisionId: toRevisionId ?? fromRevisionId
3033
+ }))
3034
+ }] : [];
3035
+ }),
3036
+ nextCursor: page.nextAfter
3037
+ };
3038
+ }, context.db);
3039
+ };
3040
+ /** `GET /api/snapshots/current`, GraphQL `_snapshot`. */
3041
+ const currentSnapshot = async (context) => {
3042
+ await readableModels(context);
3043
+ return withConsistentRead(async (trx) => {
3044
+ const snapshot = await currentSeq(context.site.id, trx);
3045
+ const publishedAt = await snapshotCreatedAt(context.site.id, snapshot, trx);
3046
+ return {
3047
+ snapshot,
3048
+ schemaVersion: await getSchemaVersion(trx),
3049
+ publishedAt: publishedAt ?? null
3050
+ };
3051
+ }, context.db);
3052
+ };
3053
+ //#endregion
3054
+ //#region ../../apps/api/src/helpers/tokens.ts
3055
+ /**
3056
+ * What we store for a token: its SHA-256, hex. Tokens are high-entropy random values, so a fast hash is
3057
+ * enough (no salt or stretching needed); a leaked table does not reveal usable tokens.
3058
+ */
3059
+ const hashToken = (token) => createHash("sha256").update(token, "utf8").digest("hex");
3060
+ //#endregion
3061
+ //#region ../../apps/api/src/permissions/memo.ts
3062
+ /**
3063
+ * One request's evaluator, memoized per (principal, action, model): a request touches the same models many
3064
+ * times (REST: the read, relation visibility, populate, usage; GraphQL: lists, relation batches,
3065
+ * localizations), and evaluates each policy once (ADR 0005). Calls for another principal bypass the memo.
3066
+ * A policy still being evaluated is shared only on the executor that evaluates it: one evaluated inside a
3067
+ * read transaction is never awaited from another transaction. Once settled, every executor reuses it.
3068
+ */
3069
+ const memoizePermissions = (base, principal) => {
3070
+ const settled = /* @__PURE__ */ new Map();
3071
+ const pooled = /* @__PURE__ */ new Map();
3072
+ const byTransaction = /* @__PURE__ */ new WeakMap();
3073
+ const pendingFor = (executor) => {
3074
+ if (!executor) return pooled;
3075
+ let pending = byTransaction.get(executor);
3076
+ if (!pending) {
3077
+ pending = /* @__PURE__ */ new Map();
3078
+ byTransaction.set(executor, pending);
3079
+ }
3080
+ return pending;
3081
+ };
3082
+ return {
3083
+ evaluate: (caller, request, executor) => {
3084
+ if (caller !== principal) return base.evaluate(caller, request, executor);
3085
+ const key = `${request.action}:${request.modelId}`;
3086
+ const done = settled.get(key);
3087
+ if (done) return Promise.resolve(done);
3088
+ const pending = pendingFor(executor);
3089
+ let policy = pending.get(key);
3090
+ if (!policy) {
3091
+ policy = base.evaluate(caller, request, executor);
3092
+ pending.set(key, policy);
3093
+ policy.then((value) => settled.set(key, value), () => void 0);
3094
+ }
3095
+ return policy;
3096
+ },
3097
+ canPerform: (caller, action, executor) => base.canPerform(caller, action, executor),
3098
+ canPerformOnSite: (caller, action, siteId, executor) => base.canPerformOnSite(caller, action, siteId, executor)
3099
+ };
3100
+ };
3101
+ //#endregion
3102
+ //#region ../../apps/api/src/permissions/scoped.ts
3103
+ const isLazy = (versions) => "read" in versions;
3104
+ /**
3105
+ * The evaluator at the scope's versions. Inside a transaction before a lazy scope read its versions, the
3106
+ * unbound evaluator checks the versions itself through the transaction, so no pooled connection is asked
3107
+ * for while the transaction holds one.
3108
+ */
3109
+ const boundEvaluator = async (base, versions, executor) => {
3110
+ if (!isLazy(versions)) return base.atVersions?.(versions) ?? base;
3111
+ if (executor && !versions.isRead()) return base;
3112
+ return base.atVersions?.(await versions.read()) ?? base;
3113
+ };
3114
+ /**
3115
+ * One scope's evaluator (an HTTP request, an in-process delivery call): checked against the scope's durable
3116
+ * versions (ADR 0005: a revoke applies from the next scope, not mid-scope) and memoized per (principal, action,
3117
+ * model) for the scope (`memo.ts`).
3118
+ */
3119
+ const scopePermissions = (base, principal, versions) => memoizePermissions({
3120
+ evaluate: async (caller, policyRequest, executor) => (await boundEvaluator(base, versions, executor)).evaluate(caller, policyRequest, executor),
3121
+ canPerform: async (caller, action, executor) => (await boundEvaluator(base, versions, executor)).canPerform(caller, action, executor),
3122
+ canPerformOnSite: async (caller, action, siteId, executor) => (await boundEvaluator(base, versions, executor)).canPerformOnSite(caller, action, siteId, executor)
3123
+ }, principal);
3124
+ //#endregion
3125
+ //#region ../../apps/api/src/permissions/sites.ts
3126
+ /**
3127
+ * Narrowing principals to one site (sites plan §H). Roles are instance-wide; assignments say where a role
3128
+ * applies. On a site, an admin holds the roles assigned on that site plus the roles assigned on every site.
3129
+ * Network actions only ever see the roles assigned on every site.
3130
+ */
3131
+ const unique = (ids) => [...new Set(ids)].sort();
3132
+ /** Roles assigned on every site. */
3133
+ const networkRolesOf = (assignments) => unique(assignments.filter((assignment) => assignment.siteId === null).map((assignment) => assignment.roleId));
3134
+ /** Roles that apply on `siteId`: those assigned there and those assigned on every site. */
3135
+ const siteRolesOf = (assignments, siteId) => unique(assignments.filter((assignment) => assignment.siteId === null || assignment.siteId === siteId).map((assignment) => assignment.roleId));
3136
+ /** The admin principal for one site, or for a network route (`siteId` null: network roles only). */
3137
+ const narrowToSite = (identity, siteId) => ({
3138
+ kind: "admin",
3139
+ adminUserId: identity.adminUserId,
3140
+ sessionId: identity.sessionId,
3141
+ assignments: identity.assignments,
3142
+ siteId,
3143
+ roleIds: siteId === null ? networkRolesOf(identity.assignments) : siteRolesOf(identity.assignments, siteId),
3144
+ networkRoleIds: networkRolesOf(identity.assignments)
3145
+ });
3146
+ /** The identity behind an admin principal (to narrow it again to another site). */
3147
+ const identityOf = (principal) => ({
3148
+ adminUserId: principal.adminUserId,
3149
+ sessionId: principal.sessionId,
3150
+ assignments: principal.assignments
3151
+ });
3152
+ /** A credential (token, app-user session, link or code) used on another site than its own. */
3153
+ const siteMismatch = () => new AppError(403, "SITE_MISMATCH", "This credential belongs to another site than the one requested");
3154
+ /**
3155
+ * Re-narrows a principal to the request's site. Admins get that site's roles; anonymous callers get the site
3156
+ * (its `public` bindings apply). Site credentials (a site token, an app user) act on their own site only:
3157
+ * another site is refused (403 `SITE_MISMATCH`), whatever resolved it. A network token's role applies on
3158
+ * every site.
3159
+ */
3160
+ const principalForSite = (principal, siteId) => {
3161
+ switch (principal.kind) {
3162
+ case "admin": return narrowToSite(identityOf(principal), siteId);
3163
+ case "anonymous": return {
3164
+ kind: "anonymous",
3165
+ siteId
3166
+ };
3167
+ case "appUser":
3168
+ if (principal.siteId !== siteId) throw siteMismatch();
3169
+ return principal;
3170
+ case "token":
3171
+ if (principal.siteId !== null && principal.siteId !== siteId) throw siteMismatch();
3172
+ return principal;
3173
+ case "system": return principal;
3174
+ }
3175
+ };
3176
+ /** The roles a token holds for network actions: only a network token's (site tokens never reach the network). */
3177
+ const tokenNetworkRoleIds = (token) => token.siteId === null ? [token.roleId] : [];
3178
+ //#endregion
3179
+ //#region ../../apps/api/src/constants/delivery.ts
3180
+ /** `system_settings` key of the delivery descriptor the server writes at start (services/deliveryDescriptor.ts). */
3181
+ const DELIVERY_DESCRIPTOR_SETTING = "delivery_descriptor";
3182
+ //#endregion
3183
+ //#region ../../apps/api/src/repositories/requestState.ts
3184
+ /** The site a lookup names, joined to the one `system_versions` row (at most one row matches). */
3185
+ const siteJoin = (join, lookup) => {
3186
+ switch (lookup.by) {
3187
+ case "id": return join.on("sites.id", "=", lookup.id);
3188
+ case "key": return join.on("sites.key", "=", lookup.key);
3189
+ case "primary": return join.on("sites.is_primary", "=", true);
3190
+ }
3191
+ };
3192
+ /**
3193
+ * The request state for an in-process delivery call, in the same one statement: the versions and the site,
3194
+ * plus the server's release and delivery descriptor, and (for a token already resolved) whether it is still
3195
+ * live, so a call is this statement and its read.
3196
+ */
3197
+ const readForDelivery = async (input, trx = db) => {
3198
+ const { tokenId } = input;
3199
+ const row = await trx.selectFrom("system_versions").leftJoin("sites", (join) => siteJoin(join, input.site)).leftJoin("system_settings", (join) => join.on("system_settings.key", "=", DELIVERY_DESCRIPTOR_SETTING)).select([
3200
+ "system_versions.schema_version",
3201
+ "system_versions.permissions_version",
3202
+ "system_versions.release",
3203
+ "sites.id as site_id",
3204
+ "sites.key as site_key",
3205
+ "system_settings.value as descriptor"
3206
+ ]).$if(tokenId !== void 0, (query) => query.leftJoin("api_tokens", (join) => join.on("api_tokens.id", "=", tokenId ?? "")).select([
3207
+ "api_tokens.id as token_id",
3208
+ "api_tokens.revoked_at as token_revoked_at",
3209
+ "api_tokens.expires_at as token_expires_at"
3210
+ ])).executeTakeFirstOrThrow();
3211
+ return {
3212
+ schemaVersion: row.schema_version,
3213
+ permissionsVersion: row.permissions_version,
3214
+ site: row.site_id !== null && row.site_key !== null ? {
3215
+ id: row.site_id,
3216
+ key: row.site_key
3217
+ } : void 0,
3218
+ release: row.release,
3219
+ descriptor: row.descriptor,
3220
+ token: row.token_id === void 0 || row.token_id === null ? null : {
3221
+ revokedAt: row.token_revoked_at ?? null,
3222
+ expiresAt: row.token_expires_at ?? null
3223
+ }
3224
+ };
3225
+ };
3226
+ //#endregion
3227
+ //#region ../../apps/api/src/repositories/adminRoles.ts
3228
+ const PERMISSION_COLUMNS$1 = [
3229
+ "role_id",
3230
+ "action",
3231
+ "model_id",
3232
+ "condition",
3233
+ "field_ids"
3234
+ ];
3235
+ /** Every grant of every role; the permission cache loads them all at once (the table is small). */
3236
+ const listAllPermissions$1 = (trx = db) => trx.selectFrom("admin_role_permissions").select(PERMISSION_COLUMNS$1).execute();
3237
+ //#endregion
3238
+ //#region ../../apps/api/src/repositories/permissionsVersion.ts
3239
+ /** The durable permissions version; caches compare against it (build plan §3.4). */
3240
+ const getPermissionsVersion = async (trx = db) => {
3241
+ return (await trx.selectFrom("system_versions").select("permissions_version").executeTakeFirstOrThrow()).permissions_version;
3242
+ };
3243
+ //#endregion
3244
+ //#region ../../apps/api/src/permissions/seedRoles.ts
3245
+ const SYSTEM_ROLE_KEYS = {
3246
+ owner: "owner",
3247
+ admin: "admin",
3248
+ editor: "editor",
3249
+ readOnly: "read-only"
3250
+ };
3251
+ SYSTEM_ROLE_KEYS.owner, SYSTEM_ROLE_KEYS.admin, SYSTEM_ROLE_KEYS.editor, SYSTEM_ROLE_KEYS.readOnly;
3252
+ //#endregion
3253
+ //#region ../../apps/api/src/repositories/apiTokens.ts
3254
+ const LIST_COLUMNS = [
3255
+ "api_tokens.id",
3256
+ "api_tokens.name",
3257
+ "api_tokens.token_prefix",
3258
+ "api_tokens.role_id",
3259
+ "api_tokens.created_by",
3260
+ "api_tokens.expires_at",
3261
+ "api_tokens.last_used_at",
3262
+ "api_tokens.revoked_at",
3263
+ "api_tokens.created_at",
3264
+ "api_tokens.site_id",
3265
+ "admin_roles.kind as role_kind"
3266
+ ];
3267
+ const withRole = (trx) => trx.selectFrom("api_tokens").innerJoin("admin_roles", "admin_roles.id", "api_tokens.role_id").select(LIST_COLUMNS);
3268
+ /** A usable token for this hash: not revoked. Expiry is checked by the caller. */
3269
+ const findLiveByHash = (tokenHash, trx = db) => withRole(trx).where("api_tokens.token_hash", "=", tokenHash).where("api_tokens.revoked_at", "is", null).executeTakeFirst();
3270
+ const touchLastUsed = (id, now, staleBefore, trx = db) => trx.updateTable("api_tokens").set({ last_used_at: now }).where("id", "=", id).where((eb) => eb.or([eb("last_used_at", "is", null), eb("last_used_at", "<", staleBefore)])).execute();
3271
+ //#endregion
3272
+ //#region ../../apps/api/src/services/apiTokens.ts
3273
+ const scopeOf = (roleKind) => roleKind === "delivery" ? "delivery" : "admin";
3274
+ /** Whether a token with this expiry has expired at `now`. */
3275
+ const isTokenExpired = (expiresAt, now) => expiresAt !== null && expiresAt.getTime() <= now.getTime();
3276
+ /** The principal for a bearer API token, or undefined when unknown, revoked or expired. */
3277
+ const resolveApiToken = async (value, now = /* @__PURE__ */ new Date(), { recordUse = true, executor = db } = {}) => {
3278
+ const row = await findLiveByHash(hashToken(value), executor);
3279
+ if (!row || isTokenExpired(row.expires_at, now)) return;
3280
+ const staleBefore = /* @__PURE__ */ new Date(now.getTime() - LAST_SEEN_WRITE_INTERVAL_MS);
3281
+ if (recordUse && (row.last_used_at === null || row.last_used_at.getTime() < staleBefore.getTime())) await touchLastUsed(row.id, now, staleBefore, executor);
3282
+ return {
3283
+ kind: "token",
3284
+ tokenId: row.id,
3285
+ scope: scopeOf(row.role_kind),
3286
+ roleId: row.role_id,
3287
+ siteId: row.site_id
3288
+ };
3289
+ };
3290
+ //#endregion
3291
+ //#region ../../apps/api/src/content/hooks.ts
3292
+ /**
3293
+ * Lifecycle hook points (ADR 0009). The content services call every hook point inside the owning
3294
+ * transaction; a hook that throws aborts the write. This registry is the engine only: the extension
3295
+ * runtime (extensions/hooks.ts) registers project hooks on it, running `before*` hooks right here in the
3296
+ * transaction and turning `after*` hook points into an outbox event, so project `after*` hooks run after
3297
+ * commit as jobs and can never roll the write back.
3298
+ */
3299
+ const BEFORE_EVENTS = [
3300
+ "beforeCreate",
3301
+ "beforeUpdate",
3302
+ "beforePublish",
3303
+ "beforeDelete"
3304
+ ];
3305
+ const AFTER_EVENTS = [
3306
+ "afterCreate",
3307
+ "afterUpdate",
3308
+ "afterPublish",
3309
+ "afterDelete"
3310
+ ];
3311
+ [...BEFORE_EVENTS, ...AFTER_EVENTS];
3312
+ const createContentHooks = () => {
3313
+ const hooks = /* @__PURE__ */ new Map();
3314
+ return {
3315
+ on: (event, hook) => {
3316
+ hooks.set(event, [...hooks.get(event) ?? [], hook]);
3317
+ return () => hooks.set(event, (hooks.get(event) ?? []).filter((candidate) => candidate !== hook));
3318
+ },
3319
+ run: async (event, context) => {
3320
+ for (const hook of hooks.get(event) ?? []) await hook(context);
3321
+ }
3322
+ };
3323
+ };
3324
+ //#endregion
3325
+ //#region ../../apps/api/src/services/contentContext.ts
3326
+ /**
3327
+ * The content service context for one call, the same however the call arrived: the site's view of the pinned
3328
+ * schema (plan site-schema: shared definitions and the site's own), scoped to the site.
3329
+ */
3330
+ const buildContentContext = ({ network, hooks, media, requestId, ip, ...rest }) => ({
3331
+ ...rest,
3332
+ snapshot: network.forSite(rest.site.id),
3333
+ hooks: hooks ?? createContentHooks(),
3334
+ ...media ? { media } : {},
3335
+ ...requestId !== void 0 ? { requestId } : {},
3336
+ ...ip !== void 0 ? { ip } : {}
3337
+ });
3338
+ //#endregion
3339
+ //#region ../../apps/api/src/repositories/schemaModels.ts
3340
+ /** The active revision of every live (not deleted) model and component. */
3341
+ const findActiveDefinitions = (executor = db) => executor.selectFrom("model_active_versions as active").innerJoin("models", "models.id", "active.model_id").innerJoin("schema_revisions as revision", "revision.id", "active.revision_id").where("models.deleted_at", "is", null).select([
3342
+ "models.id as modelId",
3343
+ "models.kind as kind",
3344
+ "models.site_id as siteId",
3345
+ "active.version as version",
3346
+ "active.revision_id as revisionId",
3347
+ "active.activated_at as activatedAt",
3348
+ "revision.hash as hash",
3349
+ "revision.definition as definition"
3350
+ ]).orderBy("models.created_at").orderBy("models.id").execute();
3351
+ lockKeyFromId("shapio:schema-activation");
3352
+ //#endregion
3353
+ //#region ../../apps/api/src/services/sites.ts
3354
+ const toSiteRef = (row) => ({
3355
+ id: row.id,
3356
+ key: row.key
3357
+ });
3358
+ const siteNotFound = () => new AppError(404, "SITE_NOT_FOUND", "Site not found");
3359
+ /** The site a lookup names, read on its own (a request whose state was read without its site). */
3360
+ const findSite = async (lookup) => {
3361
+ switch (lookup.by) {
3362
+ case "id": return findById(lookup.id).then((row) => row && toSiteRef(row));
3363
+ case "key": return findByKey(lookup.key).then((row) => row && toSiteRef(row));
3364
+ case "primary": return toSiteRef(await findPrimary());
3365
+ }
3366
+ };
3367
+ /**
3368
+ * Which site a request is about (sites plan §H): the credential's site, else the site the request names,
3369
+ * else the primary site. A request that names a different site than its credential is refused
3370
+ * (403 `SITE_MISMATCH`), never redirected; an unknown key is 404 `SITE_NOT_FOUND`.
3371
+ * `read`: where the site is read; a request passes its once-per-request state read
3372
+ * (`repositories/requestState.ts`), which reads the site with the versions in one statement.
3373
+ */
3374
+ const resolveSite = async ({ credentialSiteId, requestedKey }, read = findSite) => {
3375
+ if (credentialSiteId !== void 0) {
3376
+ const site = await read({
3377
+ by: "id",
3378
+ id: credentialSiteId
3379
+ });
3380
+ if (!site) throw siteNotFound();
3381
+ if (requestedKey !== void 0 && requestedKey !== site.key) throw new AppError(403, "SITE_MISMATCH", `This credential belongs to site "${site.key}", not "${requestedKey}"`);
3382
+ return site;
3383
+ }
3384
+ if (requestedKey !== void 0) {
3385
+ const site = await read({
3386
+ by: "key",
3387
+ key: requestedKey
3388
+ });
3389
+ if (!site) throw new AppError(404, "SITE_NOT_FOUND", `No site has the key "${requestedKey}"`);
3390
+ return site;
3391
+ }
3392
+ const primary = await read({ by: "primary" });
3393
+ if (!primary) throw new Error("No primary site");
3394
+ return primary;
3395
+ };
3396
+ //#endregion
3397
+ //#region ../../apps/api/src/delivery/scope.ts
3398
+ const ANONYMOUS = Object.freeze({
3399
+ kind: "anonymous",
3400
+ siteId: null
3401
+ });
3402
+ const invalidToken = () => new AppError(401, "INVALID_TOKEN", "The API token is invalid, expired or revoked");
3403
+ /**
3404
+ * Runs a statement of the call's preamble, mapping a failure on a database that predates this release (a
3405
+ * statement names a column or table it lacks) to a version skew rather than an SQL error.
3406
+ */
3407
+ const guardingSkew = async (runtime, statement) => {
3408
+ try {
3409
+ return await statement();
3410
+ } catch (error) {
3411
+ if (error instanceof AppError) throw error;
3412
+ const { getPendingMigrations } = await import("./migrator-CIKnDfZy.js");
3413
+ if ((await getPendingMigrations(runtime.db)).length > 0) throw new ShapioVersionSkewError$1(null, SHAPIO_VERSION$1, { cause: error });
3414
+ throw error;
3415
+ }
3416
+ };
3417
+ /** The one state statement: versions, site, release, descriptor and the token's liveness. */
3418
+ const readState = (runtime, site, tokenId) => guardingSkew(runtime, () => readForDelivery({
3419
+ site,
3420
+ ...tokenId !== void 0 ? { tokenId } : {}
3421
+ }, runtime.db));
3422
+ /**
3423
+ * The caller: anonymous, or a delivery token resolved once per runtime (read-only: in-process reads never
3424
+ * record a token's use) and checked for liveness on every call by the state read. Admin-scope tokens are
3425
+ * refused: in-process reads are delivery reads only (rule 7).
3426
+ */
3427
+ const callerOf = async (runtime, token) => {
3428
+ if (token === void 0) return { principal: ANONYMOUS };
3429
+ const tokenKey = hashToken(token);
3430
+ let principal = runtime.tokens.get(tokenKey);
3431
+ if (!principal) {
3432
+ const resolved = await guardingSkew(runtime, () => resolveApiToken(token, /* @__PURE__ */ new Date(), {
3433
+ recordUse: false,
3434
+ executor: runtime.db
3435
+ }));
3436
+ if (!resolved) throw invalidToken();
3437
+ if (resolved.scope !== "delivery") throw new AppError(403, "DELIVERY_TOKEN_REQUIRED", "In-process reads take a delivery token (or none); this is an admin API token");
3438
+ runtime.tokens.set(tokenKey, resolved);
3439
+ principal = resolved;
3440
+ }
3441
+ return {
3442
+ principal,
3443
+ tokenKey,
3444
+ tokenId: principal.tokenId
3445
+ };
3446
+ };
3447
+ /**
3448
+ * Opens one in-process call the way the HTTP API opens a request: one statement reads the schema and
3449
+ * permissions versions, the site, the server's release and descriptor, and the token's liveness; the call
3450
+ * then sees the schema and the permissions at those versions (a change committed later applies from the next
3451
+ * call, so a model changed in the admin shows on the next call without a restart: rule 1).
3452
+ */
3453
+ const openCallScope = async (runtime, { token, site: requestedKey }) => {
3454
+ const caller = await callerOf(runtime, token);
3455
+ let state;
3456
+ const site = await resolveSite({
3457
+ credentialSiteId: caller.principal.kind === "token" ? caller.principal.siteId ?? void 0 : void 0,
3458
+ requestedKey
3459
+ }, async (lookup) => {
3460
+ state = await readState(runtime, lookup, caller.tokenId);
3461
+ if (state.release !== SHAPIO_VERSION$1) throw new ShapioVersionSkewError$1(state.release, SHAPIO_VERSION$1);
3462
+ return state.site;
3463
+ });
3464
+ if (!state) throw new Error("The site was resolved without reading the request state");
3465
+ if (caller.tokenKey !== void 0) {
3466
+ const live = state.token;
3467
+ if (!live || live.revokedAt !== null || isTokenExpired(live.expiresAt, /* @__PURE__ */ new Date())) {
3468
+ runtime.tokens.delete(caller.tokenKey);
3469
+ throw invalidToken();
3470
+ }
3471
+ }
3472
+ if (state.descriptor === null) throw new AppError(503, "SERVER_NOT_STARTED", "Start the Shapio server of this release once");
3473
+ const versions = {
3474
+ schemaVersion: state.schemaVersion,
3475
+ permissionsVersion: state.permissionsVersion
3476
+ };
3477
+ const principal = principalForSite(caller.principal, site.id);
3478
+ const [network, media] = await Promise.all([runtime.registry.getSnapshot(void 0, versions), runtime.mediaFor(state.descriptor)]);
3479
+ return buildContentContext({
3480
+ db: runtime.db,
3481
+ network,
3482
+ permissions: scopePermissions(runtime.permissions, principal, versions),
3483
+ actor: principal,
3484
+ site,
3485
+ media
3486
+ });
3487
+ };
3488
+ //#endregion
3489
+ //#region ../../apps/api/src/delivery/request.ts
3490
+ const decodeSegment = (segment) => {
3491
+ try {
3492
+ return decodeURIComponent(segment);
3493
+ } catch {
3494
+ return;
3495
+ }
3496
+ };
3497
+ const routeOf = (segments) => {
3498
+ const [api, group, first, second, ...rest] = segments;
3499
+ if (api !== "api" || rest.length > 0) return;
3500
+ if (group === "content" && first) {
3501
+ const routeKey = decodeSegment(first);
3502
+ if (routeKey === void 0) return;
3503
+ if (second === void 0) return {
3504
+ kind: "list",
3505
+ routeKey
3506
+ };
3507
+ const id = decodeSegment(second);
3508
+ return id ? {
3509
+ kind: "item",
3510
+ routeKey,
3511
+ id
3512
+ } : void 0;
3513
+ }
3514
+ if (group === "site" && first === void 0) return { kind: "site" };
3515
+ if (group === "snapshots" && second === void 0) {
3516
+ if (first === "current") return { kind: "snapshotCurrent" };
3517
+ if (first === "changes") return { kind: "snapshotChanges" };
3518
+ }
3519
+ };
3520
+ /** Matches a delivery read; undefined for any other method or path. */
3521
+ const matchDeliveryRequest = (method, path) => {
3522
+ if (method.toUpperCase() !== "GET") return;
3523
+ const index = path.indexOf("?");
3524
+ const pathname = index === -1 ? path : path.slice(0, index);
3525
+ const rawQuery = index === -1 ? "" : path.slice(index + 1);
3526
+ if (!pathname.startsWith("/")) return;
3527
+ const route = routeOf(pathname.slice(1).split("/"));
3528
+ if (!route) return;
3529
+ const site = new URLSearchParams(rawQuery).get("site") || void 0;
3530
+ return {
3531
+ route,
3532
+ query: withoutSiteParameter(rawQuery),
3533
+ site
3534
+ };
3535
+ };
3536
+ const notAvailable = (method, path) => new ShapioApiError(404, { error: {
3537
+ code: "NOT_AVAILABLE_IN_PROCESS",
3538
+ message: `${method.toUpperCase()} ${path.split("?")[0] ?? path} is not available in process; only delivery reads are`
3539
+ } });
3540
+ /** The site a call reads: the path's `?site=` or the credentials' site; both must agree. */
3541
+ const siteOf = (pathSite, credentialsSite) => {
3542
+ if (pathSite !== void 0 && credentialsSite !== void 0 && pathSite !== credentialsSite) throw new AppError(403, "SITE_MISMATCH", "The site option and ?site= name different sites");
3543
+ return pathSite ?? credentialsSite;
3544
+ };
3545
+ /** `?from&to&after&limit`, coerced and checked against the HTTP route's own schema. */
3546
+ const snapshotChangesQuery = (rawQuery) => {
3547
+ const value = Value.Convert(SnapshotChangesQuerySchema, Object.fromEntries(new URLSearchParams(rawQuery)));
3548
+ if (Value.Check(SnapshotChangesQuerySchema, value)) return value;
3549
+ const validation = Value.Errors(SnapshotChangesQuerySchema, value).map(({ instancePath, message, params }) => ({
3550
+ instancePath,
3551
+ message,
3552
+ params
3553
+ }));
3554
+ const [first] = validation;
3555
+ throw Object.assign(/* @__PURE__ */ new Error(`querystring${first?.instancePath ?? ""} ${first?.message ?? "is invalid"}`), {
3556
+ statusCode: 400,
3557
+ validation
3558
+ });
3559
+ };
3560
+ /**
3561
+ * Values exactly as the HTTP API sends them: a JSON round trip, as the server serializes them. Dates become ISO
3562
+ * strings, `undefined` members disappear, and so do symbol-keyed members (a delivered rich-text value carries
3563
+ * its HTML renderer for GraphQL behind a symbol), so a caller gets plain data that holds nothing else.
3564
+ */
3565
+ const asJson = (value) => JSON.parse(JSON.stringify(value));
3566
+ const dispatch = async (context, { route, query }) => {
3567
+ switch (route.kind) {
3568
+ case "list": return listDelivery(context, apiKeyOfRoute(context.snapshot, route.routeKey), query);
3569
+ case "item": return getDelivery(context, apiKeyOfRoute(context.snapshot, route.routeKey), route.id, query);
3570
+ case "site": return getDeliverySite(context);
3571
+ case "snapshotCurrent": return currentSnapshot(context);
3572
+ case "snapshotChanges": return listSnapshotChanges(context, snapshotChangesQuery(query));
3573
+ }
3574
+ };
3575
+ /**
3576
+ * A `RequestFn` that answers delivery reads in this process, as `credentials` (a delivery token or none, and
3577
+ * a site). With `onVersionSkew: 'http'`, a call refused for a release mismatch is sent to the server instead.
3578
+ */
3579
+ const createInProcessRequest = (runtime, credentials) => {
3580
+ let fallback;
3581
+ let warnedSkew = false;
3582
+ const fallbackRequest = () => {
3583
+ if (runtime.onVersionSkew !== "http" || runtime.fallbackUrl === void 0) return;
3584
+ fallback ??= createClient({
3585
+ baseUrl: runtime.fallbackUrl,
3586
+ ...credentials.token !== void 0 ? { token: credentials.token } : {},
3587
+ ...credentials.site !== void 0 ? { site: credentials.site } : {}
3588
+ }).request;
3589
+ return fallback;
3590
+ };
3591
+ return async (path, options = {}) => {
3592
+ const method = options.method ?? "GET";
3593
+ const matched = matchDeliveryRequest(method, path);
3594
+ if (!matched) throw notAvailable(method, path);
3595
+ try {
3596
+ const site = siteOf(matched.site, credentials.site);
3597
+ const context = await openCallScope(runtime, {
3598
+ ...credentials.token !== void 0 ? { token: credentials.token } : {},
3599
+ ...site !== void 0 ? { site } : {}
3600
+ });
3601
+ const result = asJson(await dispatch(context, matched));
3602
+ warnedSkew = false;
3603
+ return result;
3604
+ } catch (error) {
3605
+ const http = error instanceof ShapioVersionSkewError$1 ? fallbackRequest() : void 0;
3606
+ if (http && error instanceof ShapioVersionSkewError$1) {
3607
+ if (!warnedSkew) {
3608
+ warnedSkew = true;
3609
+ runtime.log.warn({
3610
+ serverRelease: error.serverRelease,
3611
+ libraryRelease: error.libraryRelease
3612
+ }, "release mismatch; reading over HTTP until the releases match");
3613
+ }
3614
+ return http(path, options);
3615
+ }
3616
+ throw toShapioApiError(error, runtime.log);
3617
+ }
3618
+ };
3619
+ };
3620
+ //#endregion
3621
+ //#region ../../apps/api/src/helpers/publicUrl.ts
3622
+ var InvalidUrlPathError = class extends Error {
3623
+ constructor(path) {
3624
+ super(`Expected an app-relative path starting with a single "/", got ${JSON.stringify(path)}`);
3625
+ this.name = "InvalidUrlPathError";
3626
+ }
3627
+ };
3628
+ const assertRelativePath = (path) => {
3629
+ if (!path.startsWith("/") || path.startsWith("//")) throw new InvalidUrlPathError(path);
3630
+ };
3631
+ const createUrlBuilder = ({ publicUrl, basePath }) => {
3632
+ const origin = publicUrl.replace(/\/+$/, "");
3633
+ const withBasePath = (path) => {
3634
+ assertRelativePath(path);
3635
+ return `${basePath}${path}`;
3636
+ };
3637
+ return {
3638
+ publicUrl: origin,
3639
+ basePath,
3640
+ withBasePath,
3641
+ absoluteUrl: (path) => `${origin}${withBasePath(path)}`,
3642
+ originUrl: (rawUrl) => {
3643
+ const safe = rawUrl.startsWith("/") ? rawUrl.replace(/^\/+/, "/") : "/";
3644
+ return `${origin}${safe}`;
3645
+ }
3646
+ };
3647
+ };
3648
+ //#endregion
3649
+ //#region ../../apps/api/src/media/signing.ts
3650
+ const sign = (secret, purpose, subject, expiresAt) => createHmac("sha256", secret).update(`${purpose}\n${subject}\n${expiresAt}`).digest("base64url");
3651
+ /** `expiresAt` is in Unix seconds. */
3652
+ const signMediaGet = (secret, key, expiresAt) => sign(secret, "media-get", key, expiresAt);
3653
+ //#endregion
3654
+ //#region ../../apps/api/src/media/localAdapter.ts
3655
+ const isNotFound = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
3656
+ /**
3657
+ * Files under MEDIA_PATH, served by Shapio itself: public objects at a stable URL, private ones only with
3658
+ * an HMAC-signed, expiring URL. Writes go to a temporary file and are renamed into place, so a reader never
3659
+ * sees half a file.
3660
+ */
3661
+ const createLocalAdapter = ({ root, urls, signingSecret, publicBaseUrl }) => {
3662
+ const pathOf = (key) => {
3663
+ const path = resolve(root, key);
3664
+ if (!isValidStorageKey(key) || relative(root, path).startsWith("..") || !path.startsWith(root + sep)) throw new InvalidStorageKeyError(key);
3665
+ return path;
3666
+ };
3667
+ const statOrThrow = async (key) => {
3668
+ try {
3669
+ return await stat(pathOf(key));
3670
+ } catch (error) {
3671
+ throw isNotFound(error) ? new ObjectNotFoundError(key, { cause: error }) : error;
3672
+ }
3673
+ };
3674
+ /** Removes now-empty directories up to the asset folder; a non-empty one stops the walk. */
3675
+ const pruneEmptyParents = async (path) => {
3676
+ let directory = dirname(path);
3677
+ while (directory.startsWith(root + sep) && relative(root, directory).split(sep).length > 1) {
3678
+ try {
3679
+ await rmdir(directory);
3680
+ } catch {
3681
+ return;
3682
+ }
3683
+ directory = dirname(directory);
3684
+ }
3685
+ };
3686
+ return {
3687
+ driver: "local",
3688
+ put: async (key, body) => {
3689
+ const path = pathOf(key);
3690
+ await mkdir(dirname(path), { recursive: true });
3691
+ const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`;
3692
+ try {
3693
+ if (Buffer.isBuffer(body)) await writeFile(temporary, body);
3694
+ else await pipeline(body, createWriteStream(temporary));
3695
+ await rename(temporary, path);
3696
+ } catch (error) {
3697
+ await rm(temporary, { force: true });
3698
+ throw error;
3699
+ }
3700
+ },
3701
+ getStream: async (key, range) => {
3702
+ await statOrThrow(key);
3703
+ return createReadStream(pathOf(key), range ? {
3704
+ start: range.start,
3705
+ end: range.end
3706
+ } : {});
3707
+ },
3708
+ head: async (key) => {
3709
+ try {
3710
+ return {
3711
+ size: (await statOrThrow(key)).size,
3712
+ contentType: void 0
3713
+ };
3714
+ } catch (error) {
3715
+ if (error instanceof ObjectNotFoundError) return;
3716
+ throw error;
3717
+ }
3718
+ },
3719
+ exists: async (key) => {
3720
+ try {
3721
+ await statOrThrow(key);
3722
+ return true;
3723
+ } catch (error) {
3724
+ if (error instanceof ObjectNotFoundError) return false;
3725
+ throw error;
3726
+ }
3727
+ },
3728
+ delete: async (key) => {
3729
+ const path = pathOf(key);
3730
+ await rm(path, { force: true });
3731
+ await pruneEmptyParents(path);
3732
+ },
3733
+ signedGetUrl: async (key, { expiresInSeconds }) => {
3734
+ if (!signingSecret) throw new Error("The local media adapter was created without a signing secret");
3735
+ pathOf(key);
3736
+ const expiresAt = Math.floor(Date.now() / 1e3) + expiresInSeconds;
3737
+ const signature = signMediaGet(signingSecret, key, expiresAt);
3738
+ return urls.absoluteUrl(`${MEDIA_FILE_ROUTE_PREFIX}${encodeKeyPath(key)}?expires=${expiresAt}&signature=${signature}`);
3739
+ },
3740
+ publicUrl: (key) => publicBaseUrl ? `${publicBaseUrl}/${encodeKeyPath(key)}` : void 0
3741
+ };
3742
+ };
3743
+ //#endregion
3744
+ //#region ../../apps/api/src/media/storage.ts
3745
+ var StorageNotConfiguredError = class extends Error {
3746
+ constructor(driver) {
3747
+ super(driver === "s3" ? "Media is stored on S3 but STORAGE_S3_BUCKET is not set" : `Storage driver ${driver} is not configured`);
3748
+ this.name = "StorageNotConfiguredError";
3749
+ }
3750
+ };
3751
+ /**
3752
+ * Every adapter this instance can reach. Local disk is always available; S3 whenever its bucket is set,
3753
+ * whatever STORAGE_DRIVER says, so assets keep being served while `shapio media migrate` moves them. The S3
3754
+ * adapter (and the AWS SDK) is loaded only then.
3755
+ */
3756
+ const createMediaStorage = async (config, deps) => {
3757
+ const adapters = /* @__PURE__ */ new Map();
3758
+ adapters.set("local", createLocalAdapter({
3759
+ root: config.mediaPath,
3760
+ urls: deps.urls,
3761
+ signingSecret: deps.signingSecret,
3762
+ publicBaseUrl: config.publicBaseUrl
3763
+ }));
3764
+ if (config.s3) {
3765
+ const { createS3Adapter } = await import("./s3Adapter-BqCKtI3Y.js");
3766
+ adapters.set("s3", createS3Adapter({
3767
+ config: config.s3,
3768
+ publicBaseUrl: config.publicBaseUrl
3769
+ }));
3770
+ }
3771
+ const get = (driver) => {
3772
+ const adapter = adapters.get(driver);
3773
+ if (!adapter) throw new StorageNotConfiguredError(driver);
3774
+ return adapter;
3775
+ };
3776
+ return {
3777
+ active: get(config.driver),
3778
+ get,
3779
+ has: (driver) => adapters.has(driver)
3780
+ };
3781
+ };
3782
+ //#endregion
3783
+ //#region ../../apps/api/src/repositories/appRoles.ts
3784
+ const PERMISSION_COLUMNS = [
3785
+ "role_id",
3786
+ "action",
3787
+ "model_id",
3788
+ "condition",
3789
+ "field_ids"
3790
+ ];
3791
+ /** Every grant of every app role; the permission cache loads them with the admin grants (small table). */
3792
+ const listAllPermissions = (trx = db) => trx.selectFrom("app_role_permissions").select(PERMISSION_COLUMNS).execute();
3793
+ //#endregion
3794
+ //#region ../../apps/api/src/repositories/siteAppRoles.ts
3795
+ /** Every binding on every site (the permission cache holds them all, like the grants). */
3796
+ const listAll = (trx = db) => trx.selectFrom("site_app_roles").select([
3797
+ "site_id",
3798
+ "audience",
3799
+ "role_id"
3800
+ ]).execute();
3801
+ //#endregion
3802
+ //#region ../../apps/api/src/permissions/cache.ts
3803
+ const bindingKey = (siteId, audience) => `${siteId}:${audience}`;
3804
+ const groupBindings = (rows) => {
3805
+ const bindings = /* @__PURE__ */ new Map();
3806
+ for (const row of rows) {
3807
+ const key = bindingKey(row.site_id, row.audience);
3808
+ bindings.set(key, [...bindings.get(key) ?? [], row.role_id]);
3809
+ }
3810
+ return bindings;
3811
+ };
3812
+ const KNOWN_ACTIONS = /* @__PURE__ */ new Set([...CONTENT_ACTIONS, ...GLOBAL_ACTIONS]);
3813
+ const KNOWN_CONDITIONS = /* @__PURE__ */ new Set(["ownedByPrincipal"]);
3814
+ /**
3815
+ * A stored grant as this build understands it. Rows written by a newer Shapio may name an action or a row
3816
+ * condition this build does not know; they grant nothing here (fail closed). An unknown condition must never
3817
+ * read as "no condition", which would widen the grant to every row.
3818
+ */
3819
+ const toGrant = (row) => {
3820
+ if (!KNOWN_ACTIONS.has(row.action)) return;
3821
+ if (row.condition !== null && !KNOWN_CONDITIONS.has(row.condition)) return;
3822
+ return {
3823
+ roleId: row.role_id,
3824
+ action: row.action,
3825
+ modelId: row.model_id,
3826
+ condition: row.condition,
3827
+ fieldIds: row.field_ids
3828
+ };
3829
+ };
3830
+ const groupByRole = (grants) => {
3831
+ const byRole = /* @__PURE__ */ new Map();
3832
+ for (const grant of grants) {
3833
+ const list = byRole.get(grant.roleId) ?? [];
3834
+ list.push(grant);
3835
+ byRole.set(grant.roleId, list);
3836
+ }
3837
+ return byRole;
3838
+ };
3839
+ /**
3840
+ * Every role's grants, held in memory and reloaded when `system_versions.permissions_version` moves.
3841
+ * The version is checked on every lookup: read here, or read once by the request (`versions`, see
3842
+ * `KnownVersions`). That durable check is what makes a permission change visible to every instance from the
3843
+ * next request (NOTIFY would only be an optimisation). Lookups on the pool share one reload; a
3844
+ * lookup inside a transaction reads (and, when stale, reloads) through that transaction alone, never
3845
+ * through a reload running on another request's transaction.
3846
+ */
3847
+ const createPermissionCache = (database) => {
3848
+ let snapshot;
3849
+ let reloading;
3850
+ const reload = async (version, executor) => {
3851
+ const grants = [...await listAllPermissions$1(executor), ...await listAllPermissions(executor)].map(toGrant).filter((grant) => grant !== void 0);
3852
+ const bindings = groupBindings(await listAll(executor));
3853
+ return {
3854
+ version,
3855
+ byRole: groupByRole(grants),
3856
+ bindings
3857
+ };
3858
+ };
3859
+ const sharedReload = (version) => reloading ??= reload(version, database).finally(() => {
3860
+ reloading = void 0;
3861
+ });
3862
+ const current = async (executor, versions) => {
3863
+ const version = versions?.permissionsVersion ?? await getPermissionsVersion(executor ?? database);
3864
+ if (snapshot && snapshot.version === version) return snapshot;
3865
+ const loaded = await (executor ? reload(version, executor) : sharedReload(version));
3866
+ if (!snapshot || loaded.version >= snapshot.version) snapshot = loaded;
3867
+ return loaded;
3868
+ };
3869
+ return {
3870
+ getGrants: async (roleIds, executor, versions) => {
3871
+ if (roleIds.length === 0) return [];
3872
+ const { byRole } = await current(executor, versions);
3873
+ return roleIds.flatMap((roleId) => byRole.get(roleId) ?? []);
3874
+ },
3875
+ getSiteAppRoleIds: async (siteId, audience, executor, versions) => {
3876
+ const { bindings } = await current(executor, versions);
3877
+ return bindings.get(bindingKey(siteId, audience)) ?? [];
3878
+ }
3879
+ };
3880
+ };
3881
+ //#endregion
3882
+ //#region ../../apps/api/src/permissions/appRoles.ts
3883
+ /**
3884
+ * What app roles may grant: content actions only. Schema management and every instance-level action stay
3885
+ * with admin roles, whatever an app role row says.
3886
+ */
3887
+ const APP_CONTENT_ACTIONS = [
3888
+ "read",
3889
+ "create",
3890
+ "update",
3891
+ "delete",
3892
+ "publish"
3893
+ ];
3894
+ //#endregion
3895
+ //#region ../../apps/api/src/permissions/policy.ts
3896
+ const EMPTY_MASK = {
3897
+ mode: "only",
3898
+ fieldIds: []
3899
+ };
3900
+ const ALLOW_ALL_POLICY = Object.freeze({
3901
+ allowed: true,
3902
+ rowFilter: null,
3903
+ readMask: { mode: "all" },
3904
+ writeMask: { mode: "all" }
3905
+ });
3906
+ const WRITE_ACTIONS = /* @__PURE__ */ new Set(["create", "update"]);
3907
+ const grantsFor = (grants, action, modelId) => grants.filter((grant) => grant.action === action && (grant.modelId === null || grant.modelId === modelId));
3908
+ /** Rows: the union of what each grant allows. Any unconditional grant means no restriction. */
3909
+ const combineRowFilters = (grants) => {
3910
+ if (grants.some((grant) => grant.condition === null)) return null;
3911
+ const filters = [...new Set(grants.map((grant) => grant.condition))].filter((kind) => kind !== null).map((kind) => ({ kind }));
3912
+ if (filters.length === 1 && filters[0]) return filters[0];
3913
+ return {
3914
+ kind: "or",
3915
+ filters
3916
+ };
3917
+ };
3918
+ /**
3919
+ * Fields: the union of what each grant allows. For delivery audiences a wildcard grant expands to the
3920
+ * model's public fields, so `public: false` fields stay hidden unless a grant names them. Delivery masks
3921
+ * are always explicit lists, so a field added after evaluation is never exposed by accident.
3922
+ */
3923
+ const combineFieldMasks = (grants, audience, fields) => {
3924
+ if (grants.length === 0) return EMPTY_MASK;
3925
+ const hasWildcard = grants.some((grant) => grant.fieldIds === null);
3926
+ if (audience === "admin" && hasWildcard) return { mode: "all" };
3927
+ const named = new Set(grants.flatMap((grant) => grant.fieldIds ?? []));
3928
+ if (audience === "delivery") return {
3929
+ mode: "only",
3930
+ fieldIds: (fields ?? []).filter((field) => named.has(field.id) || hasWildcard && field.public).map((field) => field.id)
3931
+ };
3932
+ return {
3933
+ mode: "only",
3934
+ fieldIds: [...named].sort()
3935
+ };
3936
+ };
3937
+ /**
3938
+ * The policy for one (action, model) request from a principal's grants. Pure: no I/O, so it is unit- and
3939
+ * property-tested directly. Deny by default: no matching grant, no access.
3940
+ */
3941
+ const buildPolicy = (grants, request, audience, fields) => {
3942
+ if (audience === "delivery" && fields === void 0) return DENIED_POLICY;
3943
+ const matching = grantsFor(grants, request.action, request.modelId);
3944
+ if (matching.length === 0) return DENIED_POLICY;
3945
+ const readGrants = request.action === "read" ? matching : grantsFor(grants, "read", request.modelId);
3946
+ return {
3947
+ allowed: true,
3948
+ rowFilter: combineRowFilters(matching),
3949
+ readMask: combineFieldMasks(readGrants, audience, fields),
3950
+ writeMask: WRITE_ACTIONS.has(request.action) ? combineFieldMasks(matching, audience, fields) : EMPTY_MASK
3951
+ };
3952
+ };
3953
+ /** Global actions are granted only by a grant on that action with no model. */
3954
+ const allowsGlobalAction = (grants, action) => grants.some((grant) => grant.action === action && grant.modelId === null);
3955
+ //#endregion
3956
+ //#region ../../apps/api/src/permissions/evaluator.ts
3957
+ const NETWORK_ACTION_SET = new Set(NETWORK_ACTIONS);
3958
+ const READ_ONLY = /* @__PURE__ */ new Set(["read"]);
3959
+ const APP_ACTIONS = new Set(APP_CONTENT_ACTIONS);
3960
+ /**
3961
+ * Which roles a principal holds and how its masks are computed. App users hold the custom app roles assigned
3962
+ * to them plus the roles their site binds to `authenticated`; anonymous callers hold the roles the request's
3963
+ * site binds to `public` (`site_app_roles`: a site that binds nothing grants nothing, and an anonymous caller
3964
+ * outside a site holds no role). Both are delivery audiences: they see `public: false` fields only where a
3965
+ * grant names them.
3966
+ */
3967
+ const rolesOf = async (principal, grants, executor, versions) => {
3968
+ switch (principal.kind) {
3969
+ case "system": return "all";
3970
+ case "admin": return {
3971
+ roleIds: principal.roleIds,
3972
+ networkRoleIds: principal.networkRoleIds,
3973
+ audience: "admin",
3974
+ actions: "all"
3975
+ };
3976
+ case "token": return principal.scope === "admin" ? {
3977
+ roleIds: [principal.roleId],
3978
+ networkRoleIds: tokenNetworkRoleIds(principal),
3979
+ audience: "admin",
3980
+ actions: "all"
3981
+ } : {
3982
+ roleIds: [principal.roleId],
3983
+ networkRoleIds: [],
3984
+ audience: "delivery",
3985
+ actions: READ_ONLY
3986
+ };
3987
+ case "appUser": return {
3988
+ roleIds: [...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor, versions), ...principal.roleIds],
3989
+ networkRoleIds: [],
3990
+ audience: "delivery",
3991
+ actions: APP_ACTIONS
3992
+ };
3993
+ case "anonymous": return {
3994
+ roleIds: principal.siteId === null ? [] : await grants.getSiteAppRoleIds(principal.siteId, "public", executor, versions),
3995
+ networkRoleIds: [],
3996
+ audience: "delivery",
3997
+ actions: APP_ACTIONS
3998
+ };
3999
+ }
4000
+ };
4001
+ /**
4002
+ * Whether the principal acts on `siteId`: an admin narrowed to it, a site token of it, or a network token
4003
+ * (whose role applies on every site). Never an app user or anonymous caller (schema actions are admin-only).
4004
+ */
4005
+ const actsOnSite = (principal, siteId) => {
4006
+ switch (principal.kind) {
4007
+ case "admin": return principal.siteId === siteId;
4008
+ case "token": return principal.scope === "admin" && (principal.siteId === null || principal.siteId === siteId);
4009
+ default: return false;
4010
+ }
4011
+ };
4012
+ /**
4013
+ * The one permission evaluator (ADR 0005): principal → roles → grants → Policy, for REST and GraphQL.
4014
+ * Deny by default at every step. `atVersions` gives the same evaluator for one request, whose caches are
4015
+ * checked against the versions the request read once instead of read again on every call.
4016
+ */
4017
+ const createPermissionEvaluator = ({ grants, fields }) => {
4018
+ const at = (versions) => ({
4019
+ evaluate: async (principal, request, executor) => {
4020
+ const roles = await rolesOf(principal, grants, executor, versions);
4021
+ if (roles === "all") return ALLOW_ALL_POLICY;
4022
+ if (roles.actions !== "all" && !roles.actions.has(request.action)) return DENIED_POLICY;
4023
+ let roleIds = roles.roleIds;
4024
+ if (NETWORK_CONTENT_ACTIONS.has(request.action)) {
4025
+ const modelSite = await fields.getModelSite(request.modelId, executor, versions);
4026
+ roleIds = modelSite !== null && actsOnSite(principal, modelSite) ? roles.roleIds : roles.networkRoleIds;
4027
+ }
4028
+ const held = await grants.getGrants(roleIds, executor, versions);
4029
+ if (held.length === 0) return DENIED_POLICY;
4030
+ const modelFields = roles.audience === "delivery" ? await fields.getModelFields(request.modelId, executor, versions) : void 0;
4031
+ return buildPolicy(held, request, roles.audience, modelFields);
4032
+ },
4033
+ canPerform: async (principal, action, executor) => {
4034
+ const roles = await rolesOf(principal, grants, executor, versions);
4035
+ if (roles === "all") return true;
4036
+ if (roles.audience === "delivery") return false;
4037
+ const roleIds = NETWORK_ACTION_SET.has(action) ? roles.networkRoleIds : roles.roleIds;
4038
+ return allowsGlobalAction(await grants.getGrants(roleIds, executor, versions), action);
4039
+ },
4040
+ canPerformOnSite: async (principal, action, siteId, executor) => {
4041
+ const roles = await rolesOf(principal, grants, executor, versions);
4042
+ if (roles === "all") return true;
4043
+ if (roles.audience === "delivery") return false;
4044
+ const counted = !NETWORK_ACTION_SET.has(action) || SITE_GRANTABLE_ACTIONS.has(action) && actsOnSite(principal, siteId) ? roles.roleIds : roles.networkRoleIds;
4045
+ return allowsGlobalAction(await grants.getGrants(counted, executor, versions), action);
4046
+ },
4047
+ atVersions: at
4048
+ });
4049
+ return at(void 0);
4050
+ };
4051
+ //#endregion
4052
+ //#region ../../apps/api/src/repositories/systemSettings.ts
4053
+ const findValue = async (key, trx = db) => (await trx.selectFrom("system_settings").select("value").where("key", "=", key).executeTakeFirst())?.value;
4054
+ //#endregion
4055
+ //#region ../../apps/api/src/schema/fieldVisibility.ts
4056
+ /**
4057
+ * The permission evaluator's view of field visibility (`public` flags), served from the active schema.
4058
+ * Uses the durable version check on every call (or the version the request read once, `versions`), so a
4059
+ * field made non-public is hidden on every instance from the next request, whether or not it got the
4060
+ * notification. Deprecated fields are reported as non-public: they are hidden from the API unless a role
4061
+ * names them explicitly. Components are not delivery models.
4062
+ */
4063
+ const createSchemaFieldVisibility = (registry) => {
4064
+ const activeModel = async (modelId, executor, versions) => {
4065
+ const active = (await registry.getSnapshot(executor, versions)).byId.get(modelId);
4066
+ return active && active.definition.kind !== "component" ? active.definition : void 0;
4067
+ };
4068
+ return {
4069
+ getModelFields: async (modelId, executor, versions) => (await activeModel(modelId, executor, versions))?.fields.map((field) => ({
4070
+ id: field.id,
4071
+ public: field.public && !field.deprecated
4072
+ })),
4073
+ hasModel: async (modelId, executor, versions) => await activeModel(modelId, executor, versions) !== void 0,
4074
+ getModelSite: async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).scopeOf(modelId) ?? null
4075
+ };
4076
+ };
4077
+ //#endregion
4078
+ //#region ../../apps/api/src/repositories/locales.ts
4079
+ const COLUMNS = [
4080
+ "code",
4081
+ "label",
4082
+ "is_default",
4083
+ "fallbacks",
4084
+ "created_at",
4085
+ "updated_at"
4086
+ ];
4087
+ const list = (executor = db) => executor.selectFrom("locales").select(COLUMNS).orderBy("is_default", "desc").orderBy("code").execute();
4088
+ //#endregion
4089
+ //#region ../../apps/api/src/schema/snapshot.ts
4090
+ /**
4091
+ * On a collision the model whose API ID is the route key keeps it (a singleton keeps the URL it always
4092
+ * had); otherwise the first definition does. Deterministic, so every instance serves the same model.
4093
+ */
4094
+ const indexByRouteKey = (definitions) => {
4095
+ const modelsByRouteKey = /* @__PURE__ */ new Map();
4096
+ const routeKeyCollisions = [];
4097
+ for (const active of definitions) {
4098
+ if (isComponentDefinition(active.definition)) continue;
4099
+ const routeKey = routeKeyOf(active.definition);
4100
+ const existing = modelsByRouteKey.get(routeKey);
4101
+ if (!existing) {
4102
+ modelsByRouteKey.set(routeKey, active);
4103
+ continue;
4104
+ }
4105
+ const [served, hidden] = active.definition.apiKey === routeKey ? [active, existing] : [existing, active];
4106
+ modelsByRouteKey.set(routeKey, served);
4107
+ routeKeyCollisions.push({
4108
+ routeKey,
4109
+ servedApiKey: served.definition.apiKey,
4110
+ hiddenApiKey: hidden.definition.apiKey
4111
+ });
4112
+ }
4113
+ return {
4114
+ modelsByRouteKey,
4115
+ routeKeyCollisions
4116
+ };
4117
+ };
4118
+ const byIdPart = (version, definitions, locales) => {
4119
+ const byId = new Map(definitions.map((active) => [active.definition.id, active]));
4120
+ const all = definitions.map((active) => active.definition);
4121
+ const dependents = /* @__PURE__ */ new Map();
4122
+ return {
4123
+ version,
4124
+ definitions,
4125
+ byId,
4126
+ scopeOf: (id) => byId.get(id)?.siteId,
4127
+ locales,
4128
+ defaultLocale: locales.find((locale) => locale.isDefault)?.code ?? "en",
4129
+ dependentModelIds: (componentId) => {
4130
+ let ids = dependents.get(componentId);
4131
+ if (!ids) {
4132
+ ids = findDependentModels(all, componentId).map((definition) => definition.id);
4133
+ dependents.set(componentId, ids);
4134
+ }
4135
+ return ids;
4136
+ }
4137
+ };
4138
+ };
4139
+ const buildView = (network, siteId, definitions) => {
4140
+ const modelsByApiKey = /* @__PURE__ */ new Map();
4141
+ const componentsByApiKey = /* @__PURE__ */ new Map();
4142
+ for (const active of definitions) (isComponentDefinition(active.definition) ? componentsByApiKey : modelsByApiKey).set(active.definition.apiKey, active);
4143
+ const { modelsByRouteKey, routeKeyCollisions } = indexByRouteKey(definitions);
4144
+ return Object.freeze({
4145
+ ...byIdPart(network.version, definitions, network.locales),
4146
+ siteId,
4147
+ modelsByApiKey,
4148
+ modelsByRouteKey,
4149
+ routeKeyCollisions,
4150
+ componentsByApiKey,
4151
+ network
4152
+ });
4153
+ };
4154
+ /** The full schema at one version; views are built on first use and kept for the snapshot's lifetime. */
4155
+ const buildNetworkSchema = (version, definitions, locales) => {
4156
+ const views = /* @__PURE__ */ new Map();
4157
+ const viewOf = (siteId) => {
4158
+ let view = views.get(siteId);
4159
+ if (!view) {
4160
+ view = buildView(network, siteId, definitions.filter((active) => active.siteId === null || active.siteId === siteId));
4161
+ views.set(siteId, view);
4162
+ }
4163
+ return view;
4164
+ };
4165
+ const network = Object.freeze({
4166
+ ...byIdPart(version, definitions, locales),
4167
+ forSite: (siteId) => viewOf(siteId),
4168
+ shared: () => viewOf(null)
4169
+ });
4170
+ return network;
4171
+ };
4172
+ /** The sites whose views hold a definition of `definitions` (null: the shared definitions). */
4173
+ const siteIdsOf = (definitions) => [...new Set(definitions.flatMap((active) => active.siteId === null ? [] : [active.siteId]))];
4174
+ //#endregion
4175
+ //#region ../../apps/api/src/schema/storedDefinition.ts
4176
+ /**
4177
+ * A stored definition with a hash that matches it: the stored hash, or, when a plural API ID was filled,
4178
+ * the hash of the filled definition (the hash a pull of it records, so pull → apply shows no change).
4179
+ */
4180
+ const readStoredRevision = async (stored, hash) => {
4181
+ const { definition, filled } = withDerivedPlural(stored);
4182
+ return {
4183
+ definition,
4184
+ hash: filled ? await hashDefinition(definition) : hash
4185
+ };
4186
+ };
4187
+ //#endregion
4188
+ //#region ../../apps/api/src/schema/loadSnapshot.ts
4189
+ const toLocaleDefinition = (row) => ({
4190
+ code: row.code,
4191
+ label: row.label,
4192
+ isDefault: row.is_default,
4193
+ fallbacks: row.fallbacks
4194
+ });
4195
+ const toActiveDefinition = async (row) => ({
4196
+ ...await readStoredRevision(row.definition, row.hash),
4197
+ siteId: row.siteId,
4198
+ version: row.version,
4199
+ revisionId: row.revisionId,
4200
+ activatedAt: row.activatedAt
4201
+ });
4202
+ const toActiveDefinitions = (rows) => Promise.all(rows.map(toActiveDefinition));
4203
+ /** The schema as the executor sees it (inside an activation transaction: the state right after the flip). */
4204
+ const readSnapshot = async (executor) => {
4205
+ const version = await getSchemaVersion(executor);
4206
+ const rows = await findActiveDefinitions(executor);
4207
+ const locales = await list(executor);
4208
+ return buildNetworkSchema(version, await toActiveDefinitions(rows), locales.map(toLocaleDefinition));
4209
+ };
4210
+ /**
4211
+ * Reads the global version, every active definition and the locales in one REPEATABLE READ transaction,
4212
+ * so the snapshot is exactly the state at that version even if an activation commits meanwhile.
4213
+ */
4214
+ const loadSnapshot = (db) => db.transaction().setIsolationLevel("repeatable read").setAccessMode("read only").execute((trx) => readSnapshot(trx));
4215
+ //#endregion
4216
+ //#region ../../apps/api/src/schema/registry.ts
4217
+ const createSchemaRegistry = ({ db, log }) => {
4218
+ let cached;
4219
+ let loading;
4220
+ let closed = false;
4221
+ /** Reloads started by `hint`, which nobody else awaits. */
4222
+ const background = /* @__PURE__ */ new Set();
4223
+ const listeners = /* @__PURE__ */ new Set();
4224
+ const publish = (snapshot) => {
4225
+ if (cached && cached.version >= snapshot.version) return cached;
4226
+ cached = snapshot;
4227
+ const views = [snapshot.shared(), ...siteIdsOf(snapshot.definitions).map((id) => snapshot.forSite(id))];
4228
+ for (const view of views) for (const collision of view.routeKeyCollisions) log.error({
4229
+ ...collision,
4230
+ siteId: view.siteId,
4231
+ schemaVersion: snapshot.version
4232
+ }, "two models share a delivery route key; set a plural API ID on the hidden one");
4233
+ for (const listener of listeners) try {
4234
+ listener(snapshot);
4235
+ } catch (error) {
4236
+ log.error({
4237
+ err: error,
4238
+ schemaVersion: snapshot.version
4239
+ }, "schema change listener failed");
4240
+ }
4241
+ return snapshot;
4242
+ };
4243
+ /** Single-flight: concurrent callers share one load; a load that turns out too old is repeated. */
4244
+ const loadAtLeast = async (version) => {
4245
+ for (;;) {
4246
+ loading ??= loadSnapshot(db).finally(() => {
4247
+ loading = void 0;
4248
+ });
4249
+ const snapshot = publish(await loading);
4250
+ if (snapshot.version >= version) return snapshot;
4251
+ }
4252
+ };
4253
+ return {
4254
+ getSnapshot: async (executor, versions) => {
4255
+ const version = versions?.schemaVersion ?? await getSchemaVersion(executor ?? db);
4256
+ if (cached && cached.version >= version) return cached;
4257
+ return executor ? readSnapshot(executor) : loadAtLeast(version);
4258
+ },
4259
+ peek: () => cached,
4260
+ hint: (version) => {
4261
+ if (closed || version !== void 0 && cached && cached.version >= version) return;
4262
+ const reload = loadAtLeast(version ?? 0).then(() => void 0, (error) => {
4263
+ log.warn({ err: error }, "schema snapshot refresh failed; the next request will retry");
4264
+ });
4265
+ background.add(reload);
4266
+ reload.finally(() => background.delete(reload));
4267
+ },
4268
+ onChange: (listener) => {
4269
+ listeners.add(listener);
4270
+ return () => listeners.delete(listener);
4271
+ },
4272
+ close: async () => {
4273
+ closed = true;
4274
+ await Promise.all(background);
4275
+ }
4276
+ };
4277
+ };
4278
+ //#endregion
4279
+ //#region ../../apps/api/src/services/deliveryDescriptor.ts
4280
+ /**
4281
+ * What an in-process delivery reader (plan next-in-process §2) needs from the server's configuration to answer
4282
+ * exactly as the server does, without its environment: where Shapio is served (absolute media URLs) and where
4283
+ * media lives. Never a secret: S3 credentials and the signing secret are the reader's own options.
4284
+ */
4285
+ const DeliveryDescriptorSchema = Type.Object({
4286
+ format: Type.Literal(1),
4287
+ publicUrl: Type.String(),
4288
+ basePath: Type.String(),
4289
+ media: Type.Object({
4290
+ publicBaseUrl: Type.Union([Type.String(), Type.Null()]),
4291
+ s3: Type.Union([Type.Object({
4292
+ bucket: Type.String(),
4293
+ region: Type.Union([Type.String(), Type.Null()]),
4294
+ endpoint: Type.Union([Type.String(), Type.Null()]),
4295
+ forcePathStyle: Type.Boolean()
4296
+ }), Type.Null()])
4297
+ })
4298
+ });
4299
+ var InvalidDeliveryDescriptorError = class extends Error {
4300
+ constructor(options) {
4301
+ super("The delivery descriptor stored by the Shapio server is not readable by this release", options);
4302
+ this.name = "InvalidDeliveryDescriptorError";
4303
+ }
4304
+ };
4305
+ /** Parses the stored descriptor; throws when it is not in a format this release reads. */
4306
+ const parseDeliveryDescriptor = (stored) => {
4307
+ let value;
4308
+ try {
4309
+ value = JSON.parse(stored);
4310
+ } catch (error) {
4311
+ throw new InvalidDeliveryDescriptorError({ cause: error });
4312
+ }
4313
+ if (!Value.Check(DeliveryDescriptorSchema, value)) throw new InvalidDeliveryDescriptorError();
4314
+ return value;
4315
+ };
4316
+ //#endregion
4317
+ //#region ../../apps/api/src/delivery/runtime.ts
4318
+ /** One runtime per database per process, kept on `globalThis` so a re-evaluated module (dev reloads, a
4319
+ * bundler's second copy) never opens a second pool. */
4320
+ const RUNTIMES = Symbol.for("shapio.delivery");
4321
+ const runtimes = () => {
4322
+ const holder = globalThis;
4323
+ holder[RUNTIMES] ??= /* @__PURE__ */ new Map();
4324
+ return holder[RUNTIMES];
4325
+ };
4326
+ const DEFAULT_POOL_MAX = 4;
4327
+ /** Local media is never read from disk here; the adapter only builds URLs. Any file access fails loudly. */
4328
+ const NO_LOCAL_MEDIA_ROOT = resolve("/", ".shapio-in-process-has-no-media");
4329
+ const storageConfigOf = (descriptor, credentials) => {
4330
+ const { s3 } = descriptor.media;
4331
+ return {
4332
+ driver: "local",
4333
+ mediaPath: NO_LOCAL_MEDIA_ROOT,
4334
+ s3: s3 ? {
4335
+ bucket: s3.bucket,
4336
+ region: s3.region ?? void 0,
4337
+ endpoint: s3.endpoint ?? void 0,
4338
+ forcePathStyle: s3.forcePathStyle,
4339
+ accessKeyId: credentials?.accessKeyId,
4340
+ secretAccessKey: credentials?.secretAccessKey
4341
+ } : void 0,
4342
+ publicBaseUrl: descriptor.media.publicBaseUrl ?? void 0,
4343
+ maxUploadBytes: 0,
4344
+ allowedTypes: []
4345
+ };
4346
+ };
4347
+ const assertSupported = (options) => {
4348
+ const dialect = dialectOfUrl(options.databaseUrl);
4349
+ if (dialect === "sqlite") throw new DeliveryRuntimeError$1("In-process delivery needs PostgreSQL or MySQL: a SQLite database is served by one process only. Read over HTTP with @shapio/client's createClient instead");
4350
+ if (dialect === void 0) throw new DeliveryRuntimeError$1("databaseUrl must be a postgres:// or mysql:// URL");
4351
+ if (options.onVersionSkew === "http" && !options.fallbackUrl) throw new DeliveryRuntimeError$1("onVersionSkew: 'http' needs fallbackUrl, the Shapio server's URL");
4352
+ };
4353
+ const openRuntime = (options) => {
4354
+ const log = options.logger ?? pino({ level: "warn" });
4355
+ const db = createDb({
4356
+ connectionString: options.databaseUrl,
4357
+ poolMax: options.poolMax ?? DEFAULT_POOL_MAX,
4358
+ ...options.acquireTimeoutMs !== void 0 ? { acquireTimeoutMs: options.acquireTimeoutMs } : {},
4359
+ applicationName: "shapio-in-process",
4360
+ onIdleConnectionError: (error) => log.warn({ err: error }, "database connection lost; it will be replaced on the next query")
4361
+ });
4362
+ const ownsProcessHandle = !hasDb();
4363
+ if (ownsProcessHandle) setDb(db);
4364
+ const registryLog = log.child({ component: "schema-registry" });
4365
+ const registry = createSchemaRegistry({
4366
+ db,
4367
+ log: registryLog
4368
+ });
4369
+ const permissions = createPermissionEvaluator({
4370
+ grants: createPermissionCache(db),
4371
+ fields: createSchemaFieldVisibility(registry)
4372
+ });
4373
+ let media;
4374
+ let users = 0;
4375
+ let closing;
4376
+ const buildMedia = async (stored) => {
4377
+ const descriptor = parseDeliveryDescriptor(stored);
4378
+ const urls = createUrlBuilder(descriptor);
4379
+ const signingSecret = options.signingSecret ?? await findValue("signing_secret", db);
4380
+ return {
4381
+ storage: await createMediaStorage(storageConfigOf(descriptor, options.s3Credentials), {
4382
+ urls,
4383
+ ...signingSecret !== void 0 ? { signingSecret } : {}
4384
+ }),
4385
+ urls
4386
+ };
4387
+ };
4388
+ const runtime = {
4389
+ databaseUrl: options.databaseUrl,
4390
+ db,
4391
+ registry,
4392
+ permissions,
4393
+ log,
4394
+ onVersionSkew: options.onVersionSkew ?? "throw",
4395
+ fallbackUrl: options.fallbackUrl,
4396
+ tokens: /* @__PURE__ */ new Map(),
4397
+ mediaFor: (descriptor) => {
4398
+ if (media?.descriptor !== descriptor) {
4399
+ const dependencies = buildMedia(descriptor);
4400
+ media = {
4401
+ descriptor,
4402
+ dependencies
4403
+ };
4404
+ dependencies.catch(() => {
4405
+ if (media?.dependencies === dependencies) media = void 0;
4406
+ });
4407
+ }
4408
+ return media.dependencies;
4409
+ },
4410
+ retain: () => {
4411
+ users += 1;
4412
+ let released = false;
4413
+ return async () => {
4414
+ if (released) return;
4415
+ released = true;
4416
+ users -= 1;
4417
+ if (users === 0) await runtime.close();
4418
+ };
4419
+ },
4420
+ close: () => {
4421
+ closing ??= (async () => {
4422
+ if (runtimes().get(options.databaseUrl) === runtime) runtimes().delete(options.databaseUrl);
4423
+ await registry.close();
4424
+ await db.destroy();
4425
+ if (ownsProcessHandle) clearDb(db);
4426
+ })();
4427
+ return closing;
4428
+ }
4429
+ };
4430
+ return runtime;
4431
+ };
4432
+ /**
4433
+ * The in-process delivery runtime for a database: created once per process and shared (the same URL returns
4434
+ * the same runtime; the first caller's options apply). A process reads one Shapio database: another URL is
4435
+ * refused. Nothing is read until the first call, so creating it never fails on a database that is down.
4436
+ */
4437
+ const createDeliveryRuntime = (options) => {
4438
+ assertSupported(options);
4439
+ const all = runtimes();
4440
+ const existing = all.get(options.databaseUrl);
4441
+ if (existing) return existing;
4442
+ const [other] = all.keys();
4443
+ if (other !== void 0) throw new DeliveryRuntimeError$1(`This process already reads ${describeDatabaseTarget(other)} in process; one Shapio database per process`);
4444
+ const created = openRuntime(options);
4445
+ all.set(options.databaseUrl, created);
4446
+ return created;
4447
+ };
4448
+ //#endregion
4449
+ //#region src/index.ts
4450
+ /** The Shapio release this package reads; the server must run the same one. */
4451
+ const SHAPIO_VERSION = SHAPIO_VERSION$1;
4452
+ /** Thrown (or 503 `VERSION_SKEW` caught as `ShapioApiError`) when the server runs another release. */
4453
+ const ShapioVersionSkewError = ShapioVersionSkewError$1;
4454
+ /** Thrown by `createLocalClient` for options it cannot serve (SQLite, an unknown URL, a second database). */
4455
+ const DeliveryRuntimeError = DeliveryRuntimeError$1;
4456
+ /**
4457
+ * Shapio's delivery API as function calls in this process: the `delivery`, `site` and `snapshots` groups of
4458
+ * `@shapio/client`, with the same paths, permissions, response shapes and `ShapioApiError`s, answered from the
4459
+ * database without an HTTP hop. Reads as `token` (a delivery API token) or, without one, as an anonymous
4460
+ * caller (the site's `public` role). Model changes made in the admin show on the next call.
4461
+ *
4462
+ * Needs PostgreSQL or MySQL, the Node.js runtime, and the same Shapio release as the server (`SHAPIO_VERSION`);
4463
+ * see `onVersionSkew`. Nothing is read until the first call.
4464
+ */
4465
+ const createLocalClient = ({ token, site, ...runtimeOptions }) => {
4466
+ assertNodeRuntime();
4467
+ const runtime = createDeliveryRuntime(runtimeOptions);
4468
+ const release = runtime.retain();
4469
+ const request = createInProcessRequest(runtime, {
4470
+ ...token !== void 0 ? { token } : {},
4471
+ ...site !== void 0 ? { site } : {}
4472
+ });
4473
+ return {
4474
+ ...createDeliveryClient(request, site !== void 0 ? { site } : {}),
4475
+ close: release
4476
+ };
4477
+ };
4478
+ //#endregion
4479
+ export { DeliveryRuntimeError, SHAPIO_VERSION, ShapioVersionSkewError, createLocalClient };
4480
+
4481
+ //# sourceMappingURL=index.js.map