@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/LICENSE +201 -0
- package/dist/advisoryLocks-COlJQc1r.js +4181 -0
- package/dist/advisoryLocks-COlJQc1r.js.map +1 -0
- package/dist/errors-SK0PwPKC.js +26 -0
- package/dist/errors-SK0PwPKC.js.map +1 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +4481 -0
- package/dist/index.js.map +1 -0
- package/dist/migrator-CIKnDfZy.js +4152 -0
- package/dist/migrator-CIKnDfZy.js.map +1 -0
- package/dist/next.d.ts +10 -0
- package/dist/next.js +40 -0
- package/dist/next.js.map +1 -0
- package/dist/nodeRuntime-DzuiHUhY.js +12 -0
- package/dist/nodeRuntime-DzuiHUhY.js.map +1 -0
- package/dist/s3Adapter-BqCKtI3Y.js +143 -0
- package/dist/s3Adapter-BqCKtI3Y.js.map +1 -0
- package/package.json +77 -0
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
|