@shapio/local 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{advisoryLocks-ajVNBhfa.js → advisoryLocks-DGbxw3_E.js} +13 -13
- package/dist/advisoryLocks-DGbxw3_E.js.map +1 -0
- package/dist/errors-nQvt9bs7.js.map +1 -1
- package/dist/index.js +648 -231
- package/dist/index.js.map +1 -1
- package/dist/{migrator-CO5sXosL.js → migrator-BfVtncOo.js} +269 -148
- package/dist/migrator-BfVtncOo.js.map +1 -0
- package/package.json +4 -4
- package/dist/advisoryLocks-ajVNBhfa.js.map +0 -1
- package/dist/migrator-CO5sXosL.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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-
|
|
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-DGbxw3_E.js";
|
|
2
2
|
import { a as visibilityOfKey, i as isValidStorageKey, n as ObjectNotFoundError, o as withVisibility, r as encodeKeyPath, t as InvalidStorageKeyError } from "./errors-nQvt9bs7.js";
|
|
3
3
|
import { t as assertNodeRuntime } from "./nodeRuntime-DzuiHUhY.js";
|
|
4
4
|
import { ShapioApiError, createClient, createDeliveryClient } from "@shapio/client";
|
|
5
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,
|
|
6
|
+
import { BIGINTEGER_PATTERN, DATE_PATTERN, DECIMAL_PATTERN, EMPTY_SEO_DEFAULTS, MAX_INHERITANCE_DEPTH, MAX_ROUTE_KEY_LENGTH, SCALAR_DATA_TYPES, SEO_COMPONENT_ID, SEO_FIELD_IDS, SeoDefaultsSchema, TEXT_TITLE_FIELD_TYPES, TIME_PATTERN, createDefinitionIndex, effectiveLayout, effectiveTitleField, embeddedComponentIds, findDependentModels, hashDefinition, isComponentDefinition, isModelDefinition, isSeoField, isStableId, renderRichTextHtml, resolveSeo, richTextMediaIds, routePathOf, seoDefaultsForLocale, splitRoutePath, withDerivedPlural } from "@shapio/schema";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
import { Kysely, PostgresDialect, sql } from "kysely";
|
|
9
9
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
@@ -15,7 +15,7 @@ import { mkdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
|
15
15
|
import { pipeline } from "node:stream/promises";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region ../../apps/api/src/constants/version.ts
|
|
18
|
-
const SHAPIO_VERSION$1 = "0.5.
|
|
18
|
+
const SHAPIO_VERSION$1 = "0.5.10";
|
|
19
19
|
//#endregion
|
|
20
20
|
//#region ../../apps/api/src/helpers/errorResponse.ts
|
|
21
21
|
const STATUS_CODES = {
|
|
@@ -127,6 +127,14 @@ const toShapioApiError = (error, log) => {
|
|
|
127
127
|
const SITE_QUERY_PARAMETER = "site";
|
|
128
128
|
//#endregion
|
|
129
129
|
//#region ../../apps/api/src/content/model.ts
|
|
130
|
+
/** Where a model is served: its nested route key and its ancestors' API IDs, root first. */
|
|
131
|
+
const modelRouteOf = (snapshot, definition) => {
|
|
132
|
+
const ancestors = snapshot.ancestors(definition.id).map((id) => snapshot.byId.get(id)?.definition).filter((ancestor) => ancestor !== void 0 && isModelDefinition(ancestor));
|
|
133
|
+
return {
|
|
134
|
+
key: routePathOf(definition, ancestors),
|
|
135
|
+
ancestors: ancestors.map((ancestor) => ancestor.apiKey)
|
|
136
|
+
};
|
|
137
|
+
};
|
|
130
138
|
const modelNotFound = (key) => new AppError(404, "MODEL_NOT_FOUND", `No content model "${key}"`, { modelKey: key });
|
|
131
139
|
const collectComponents = (snapshot, model) => {
|
|
132
140
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -143,12 +151,24 @@ const collectComponents = (snapshot, model) => {
|
|
|
143
151
|
}
|
|
144
152
|
return found;
|
|
145
153
|
};
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
154
|
+
const ancestorsOf = (snapshot, definition) => new Map(definition.ancestorIds.flatMap((id) => {
|
|
155
|
+
const ancestor = snapshot.byId.get(id);
|
|
156
|
+
return ancestor ? [[id, {
|
|
157
|
+
version: ancestor.version,
|
|
158
|
+
revisionId: ancestor.revisionId
|
|
159
|
+
}]] : [];
|
|
160
|
+
}));
|
|
161
|
+
const toContentModel = (snapshot, active) => {
|
|
162
|
+
const definition = snapshot.resolve(active.definition.id);
|
|
163
|
+
return {
|
|
164
|
+
definition,
|
|
165
|
+
version: active.version,
|
|
166
|
+
revisionId: active.revisionId,
|
|
167
|
+
ancestors: ancestorsOf(snapshot, definition),
|
|
168
|
+
components: collectComponents(snapshot, definition),
|
|
169
|
+
route: modelRouteOf(snapshot, definition)
|
|
170
|
+
};
|
|
171
|
+
};
|
|
152
172
|
const isModel = (active) => active !== void 0 && !isComponentDefinition(active.definition);
|
|
153
173
|
/**
|
|
154
174
|
* Resolves `/api/…/content/:modelKey` through the registry at request time (CONTRIBUTING.md rule 2). Only a
|
|
@@ -176,6 +196,27 @@ const resolveModelById = (snapshot, modelId) => {
|
|
|
176
196
|
return isModel(active) ? toContentModel(snapshot, active) : void 0;
|
|
177
197
|
};
|
|
178
198
|
const findFieldByApiKey = (fields, apiKey) => fields.find((field) => field.apiKey === apiKey && !field.deprecated);
|
|
199
|
+
Math.max(100, MAX_ROUTE_KEY_LENGTH + 2 * (MAX_INHERITANCE_DEPTH - 1));
|
|
200
|
+
const decodeSegment = (segment) => {
|
|
201
|
+
try {
|
|
202
|
+
return decodeURIComponent(segment);
|
|
203
|
+
} catch {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Splits the decoded segments after a content prefix into the route key and the rest (`splitRoutePath`),
|
|
209
|
+
* with how many segments the key took. Undefined when a segment does not decode or none is a key.
|
|
210
|
+
*/
|
|
211
|
+
const splitContentPath = (rawSegments) => {
|
|
212
|
+
const decoded = rawSegments.map(decodeSegment);
|
|
213
|
+
if (decoded.some((segment) => segment === void 0)) return;
|
|
214
|
+
const split = splitRoutePath(decoded);
|
|
215
|
+
return split ? {
|
|
216
|
+
...split,
|
|
217
|
+
consumed: decoded.length - split.rest.length
|
|
218
|
+
} : void 0;
|
|
219
|
+
};
|
|
179
220
|
//#endregion
|
|
180
221
|
//#region ../../apps/api/src/helpers/siteQuery.ts
|
|
181
222
|
const isSiteSegment = (segment) => {
|
|
@@ -500,25 +541,70 @@ const snapshotInvalid = (requested, current) => new AppError(400, "SNAPSHOT_INVA
|
|
|
500
541
|
current
|
|
501
542
|
});
|
|
502
543
|
//#endregion
|
|
503
|
-
//#region ../../apps/api/src/
|
|
544
|
+
//#region ../../apps/api/src/content/family.ts
|
|
504
545
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
546
|
+
* Families (plan polymorphic-types): a model and every model that extends it, directly or not. Reading a
|
|
547
|
+
* route reads its model's family; relations to a model accept any member; an entry may change kind within
|
|
548
|
+
* its family. Members share their inherited fields' IDs, their localization and their publishing flags.
|
|
508
549
|
*/
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
/**
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
550
|
+
/** The members of a model's family that are in the schema view: the model first, then its descendants. */
|
|
551
|
+
const familyModels = (snapshot, model) => snapshot.family(model.definition.id).flatMap((id) => id === model.definition.id ? [model] : resolveModelById(snapshot, id) ?? []);
|
|
552
|
+
/** The family's model IDs (the model first). */
|
|
553
|
+
const familyIdsOf = (snapshot, modelId) => {
|
|
554
|
+
const family = snapshot.family(modelId);
|
|
555
|
+
return family.length > 0 ? family : [modelId];
|
|
556
|
+
};
|
|
557
|
+
/**
|
|
558
|
+
* The members of the model's family the caller may read, each with its own policy, the model first when it is
|
|
559
|
+
* readable. A member the caller may not read is left out (never a 403: the caller asked for the family).
|
|
560
|
+
*/
|
|
561
|
+
const readableMembers = async (snapshot, model, policyFor) => {
|
|
562
|
+
const models = familyModels(snapshot, model);
|
|
563
|
+
const policies = await Promise.all(models.map((member) => policyFor(member.definition.id)));
|
|
564
|
+
return models.flatMap((member, index) => {
|
|
565
|
+
const policy = policies[index];
|
|
566
|
+
return policy.allowed ? [{
|
|
567
|
+
model: member,
|
|
568
|
+
policy
|
|
569
|
+
}] : [];
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
/** The member a row belongs to, by its `model_id`. */
|
|
573
|
+
const memberOf = (members, modelId) => members.find((member) => member.model.definition.id === modelId);
|
|
574
|
+
/** Rows grouped by their member, in first-seen order; rows of a model not among the members are dropped. */
|
|
575
|
+
const groupByMember = (members, rows) => {
|
|
576
|
+
const groups = /* @__PURE__ */ new Map();
|
|
577
|
+
for (const row of rows) {
|
|
578
|
+
let group = groups.get(row.model_id);
|
|
579
|
+
if (!group) {
|
|
580
|
+
const member = memberOf(members, row.model_id);
|
|
581
|
+
if (!member) continue;
|
|
582
|
+
group = {
|
|
583
|
+
member,
|
|
584
|
+
rows: []
|
|
585
|
+
};
|
|
586
|
+
groups.set(row.model_id, group);
|
|
587
|
+
}
|
|
588
|
+
group.rows.push(row);
|
|
518
589
|
}
|
|
590
|
+
return [...groups.values()];
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* The kinds a family list covers, in tree order (the route's model, then each kind before its own kinds):
|
|
594
|
+
* the members the caller may read. Undefined for a model without kinds, so its responses do not change.
|
|
595
|
+
*/
|
|
596
|
+
const kindsMetaOf = (snapshot, route, members) => {
|
|
597
|
+
if (snapshot.childrenOf(route.definition.id).length === 0) return;
|
|
598
|
+
return members.map(({ model }) => {
|
|
599
|
+
const parentId = model.definition.extends;
|
|
600
|
+
return {
|
|
601
|
+
apiKey: model.definition.apiKey,
|
|
602
|
+
label: model.definition.label,
|
|
603
|
+
parent: parentId === void 0 ? null : snapshot.byId.get(parentId)?.definition.apiKey ?? null,
|
|
604
|
+
routeKey: model.route?.key ?? modelRouteOf(snapshot, model.definition).key
|
|
605
|
+
};
|
|
606
|
+
});
|
|
519
607
|
};
|
|
520
|
-
/** A populated relation's target field: `<relation field ID>.<target field ID>`. */
|
|
521
|
-
const relationFieldPath = (relationFieldId, targetFieldId) => `${relationFieldId}.${targetFieldId}`;
|
|
522
608
|
//#endregion
|
|
523
609
|
//#region ../../apps/api/src/content/validator/scalars.ts
|
|
524
610
|
const INPUT_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
@@ -570,6 +656,36 @@ const canonicalBigInteger = (value) => {
|
|
|
570
656
|
* keeps values until an explicit cleanup); deprecated fields' values are kept but not validated.
|
|
571
657
|
*/
|
|
572
658
|
const COMPONENT_KEY = "__component";
|
|
659
|
+
/**
|
|
660
|
+
* The API ID of an entry's own model on every delivered entry (plan polymorphic-types). A system attribute:
|
|
661
|
+
* never a field (API IDs may not start with `__`), so a write naming it fails as an unknown field.
|
|
662
|
+
*/
|
|
663
|
+
const MODEL_KEY = "__model";
|
|
664
|
+
/**
|
|
665
|
+
* The singular API IDs of an entry's kind lineage on every delivered entry, root first and its own kind last
|
|
666
|
+
* (plan polymorphic-types). A system attribute like `MODEL_KEY`: never a field, never written.
|
|
667
|
+
*/
|
|
668
|
+
const KINDS_KEY = "__kinds";
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region ../../apps/api/src/usage/keys.ts
|
|
671
|
+
/**
|
|
672
|
+
* How delivery reads are attributed (plan developer-face §5). Delivery tokens are consumers, one key per
|
|
673
|
+
* token; app users are counted together, and so are anonymous callers. Admin reads (admin users and
|
|
674
|
+
* admin-scope API tokens), system work and previews are not consumers and are never counted.
|
|
675
|
+
*/
|
|
676
|
+
const APP_USERS_PRINCIPAL_KEY = "app_users";
|
|
677
|
+
const ANONYMOUS_PRINCIPAL_KEY = "anonymous";
|
|
678
|
+
/** `token:<id>`, `app_users`, `anonymous`, or null for principals whose reads are not counted. */
|
|
679
|
+
const usagePrincipalKey = (principal) => {
|
|
680
|
+
switch (principal.kind) {
|
|
681
|
+
case "token": return principal.scope === "delivery" ? `token:${principal.tokenId}` : null;
|
|
682
|
+
case "appUser": return APP_USERS_PRINCIPAL_KEY;
|
|
683
|
+
case "anonymous": return ANONYMOUS_PRINCIPAL_KEY;
|
|
684
|
+
default: return null;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
/** A populated relation's target field: `<relation field ID>.<target field ID>`. */
|
|
688
|
+
const relationFieldPath = (relationFieldId, targetFieldId) => `${relationFieldId}.${targetFieldId}`;
|
|
573
689
|
//#endregion
|
|
574
690
|
//#region ../../apps/api/src/permissions/policy.ts
|
|
575
691
|
const EMPTY_MASK = {
|
|
@@ -586,7 +702,11 @@ const ALLOW_ALL_POLICY = Object.freeze({
|
|
|
586
702
|
rowWriteMasks: null
|
|
587
703
|
});
|
|
588
704
|
const WRITE_ACTIONS = /* @__PURE__ */ new Set(["create", "update"]);
|
|
589
|
-
|
|
705
|
+
/**
|
|
706
|
+
* Grants of `action` that apply to a model: on every model, on the model itself, or on one of the models it
|
|
707
|
+
* extends (`ancestors`), so a grant on Products covers Digital products (plan polymorphic-types D8).
|
|
708
|
+
*/
|
|
709
|
+
const grantsFor = (grants, action, modelId, ancestors) => grants.filter((grant) => grant.action === action && (grant.modelId === null || grant.modelId === modelId || ancestors.includes(grant.modelId)));
|
|
590
710
|
/** Rows: the union of what each grant allows. Any unconditional grant means no restriction. */
|
|
591
711
|
const combineRowFilters = (grants) => {
|
|
592
712
|
if (grants.some((grant) => grant.condition === null)) return null;
|
|
@@ -664,11 +784,11 @@ const combineRowFieldMasks = (grants, audience, fields) => {
|
|
|
664
784
|
* The policy for one (action, model) request from a principal's grants. Pure: no I/O, so it is unit- and
|
|
665
785
|
* property-tested directly. Deny by default: no matching grant, no access.
|
|
666
786
|
*/
|
|
667
|
-
const buildPolicy = (grants, request, audience, fields) => {
|
|
787
|
+
const buildPolicy = (grants, request, audience, fields, ancestors = []) => {
|
|
668
788
|
if (audience === "delivery" && fields === void 0) return DENIED_POLICY;
|
|
669
|
-
const matching = grantsFor(grants, request.action, request.modelId);
|
|
789
|
+
const matching = grantsFor(grants, request.action, request.modelId, ancestors);
|
|
670
790
|
if (matching.length === 0) return DENIED_POLICY;
|
|
671
|
-
const readGrants = request.action === "read" ? matching : grantsFor(grants, "read", request.modelId);
|
|
791
|
+
const readGrants = request.action === "read" ? matching : grantsFor(grants, "read", request.modelId, ancestors);
|
|
672
792
|
const writes = WRITE_ACTIONS.has(request.action);
|
|
673
793
|
return {
|
|
674
794
|
allowed: true,
|
|
@@ -752,6 +872,13 @@ const CONTENT_HEADS_COLUMNS = {
|
|
|
752
872
|
state: "state"
|
|
753
873
|
};
|
|
754
874
|
//#endregion
|
|
875
|
+
//#region ../../apps/api/src/content/compiler/dialect/modelMatch.ts
|
|
876
|
+
/**
|
|
877
|
+
* `column` names one of the models: `= <literal>` for one model (the form partial indexes match), `in (…)`
|
|
878
|
+
* for a family (plan polymorphic-types). Model IDs arrive as literals already checked by `expressions.ts`.
|
|
879
|
+
*/
|
|
880
|
+
const modelIdMatches = (column, modelIds) => modelIds.length === 1 ? sql`${column} = ${modelIds[0]}` : sql`${column} in (${sql.join([...modelIds])})`;
|
|
881
|
+
//#endregion
|
|
755
882
|
//#region ../../apps/api/src/content/compiler/dialect/mysql.ts
|
|
756
883
|
/**
|
|
757
884
|
* MySQL content SQL (ADR 0001, "MySQL"). `data` is a JSON column; there is no containment index and no
|
|
@@ -815,15 +942,15 @@ const COLUMN_TYPES = {
|
|
|
815
942
|
};
|
|
816
943
|
const addIndexColumn = ({ name, table, expression, cast, numericString }) => sql`alter table ${sql.table(table)} add column ${sql.id(fieldIndexColumnName(name))} ${numericString ? DECIMAL : COLUMN_TYPES[cast]}
|
|
817
944
|
generated always as (${expression}) virtual invisible, algorithm=inplace, lock=none`;
|
|
818
|
-
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
819
|
-
const snapshotRows$2 = ({ siteId,
|
|
945
|
+
/** Published revisions of a model (or a family) as of a publication sequence (`snapshotCte`, relation targets). */
|
|
946
|
+
const snapshotRows$2 = ({ siteId, modelIds, seq }) => sql`
|
|
820
947
|
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
821
948
|
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
822
949
|
en.created_at as entry_created_at
|
|
823
950
|
from publication_log pl
|
|
824
951
|
join content_revisions r on r.id = pl.revision_id
|
|
825
952
|
join entries en on en.id = pl.entry_id
|
|
826
|
-
where pl.site_id = ${siteId} and pl.model_id
|
|
953
|
+
where pl.site_id = ${siteId} and ${modelIdMatches(sql`pl.model_id`, modelIds)}
|
|
827
954
|
and pl.from_seq <= ${seq}
|
|
828
955
|
and (pl.to_seq is null or pl.to_seq > ${seq})`;
|
|
829
956
|
/**
|
|
@@ -949,15 +1076,15 @@ const castParameter$2 = (value, cast) => {
|
|
|
949
1076
|
* so nothing from the request is ever spliced into the SQL text.
|
|
950
1077
|
*/
|
|
951
1078
|
const containment = (document) => sql`(${DATA$1} @> ${JSON.stringify(document)}::jsonb)`;
|
|
952
|
-
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
953
|
-
const snapshotRows$1 = ({ siteId,
|
|
1079
|
+
/** Published revisions of a model (or a family) as of a publication sequence (`snapshotCte`, relation targets). */
|
|
1080
|
+
const snapshotRows$1 = ({ siteId, modelIds, seq }) => sql`
|
|
954
1081
|
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published'::text as state, r.data, 0 as version,
|
|
955
1082
|
r.id as revision_id, pl.published_at as updated_at, null::timestamptz as autosaved_at,
|
|
956
1083
|
en.created_at as entry_created_at
|
|
957
1084
|
from publication_log pl
|
|
958
1085
|
join content_revisions r on r.id = pl.revision_id
|
|
959
1086
|
join entries en on en.id = pl.entry_id
|
|
960
|
-
where pl.site_id = ${siteId}::uuid and pl.model_id
|
|
1087
|
+
where pl.site_id = ${siteId}::uuid and ${modelIdMatches(sql`pl.model_id`, modelIds)}
|
|
961
1088
|
and pl.from_seq <= ${seq}::bigint
|
|
962
1089
|
and (pl.to_seq is null or pl.to_seq > ${seq}::bigint)`;
|
|
963
1090
|
/** A UUID-shaped text cast to uuid; anything else is no ID (a cast would fail the whole statement). */
|
|
@@ -1043,15 +1170,15 @@ const castParameter$1 = (value, cast) => {
|
|
|
1043
1170
|
default: return sql`${value}`;
|
|
1044
1171
|
}
|
|
1045
1172
|
};
|
|
1046
|
-
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
1047
|
-
const snapshotRows = ({ siteId,
|
|
1173
|
+
/** Published revisions of a model (or a family) as of a publication sequence (`snapshotCte`, relation targets). */
|
|
1174
|
+
const snapshotRows = ({ siteId, modelIds, seq }) => sql`
|
|
1048
1175
|
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
1049
1176
|
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
1050
1177
|
en.created_at as entry_created_at
|
|
1051
1178
|
from publication_log pl
|
|
1052
1179
|
join content_revisions r on r.id = pl.revision_id
|
|
1053
1180
|
join entries en on en.id = pl.entry_id
|
|
1054
|
-
where pl.site_id = ${siteId} and pl.model_id
|
|
1181
|
+
where pl.site_id = ${siteId} and ${modelIdMatches(sql`pl.model_id`, modelIds)}
|
|
1055
1182
|
and pl.from_seq <= ${seq}
|
|
1056
1183
|
and (pl.to_seq is null or pl.to_seq > ${seq})`;
|
|
1057
1184
|
/** One row per stored ID: `json_each` for a list, a one-row select for a single ID (UUIDs are text). */
|
|
@@ -1624,17 +1751,30 @@ const showsReference = ({ field }, shown, dialect) => {
|
|
|
1624
1751
|
where ${ids.matches(sql`m.id`)} and m.site_id = ${dialect.uuid(shown.siteId)} and m.deleted_at is null))`;
|
|
1625
1752
|
const table = shown.source.kind === "heads" ? sql.table("entry_heads") : sql`(${dialect.snapshotRows({
|
|
1626
1753
|
siteId: shown.siteId,
|
|
1627
|
-
|
|
1754
|
+
modelIds: shown.members.map((member) => modelIdLiteral(member.modelId)),
|
|
1628
1755
|
seq: shown.source.seq
|
|
1629
1756
|
})})`;
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1757
|
+
const members = shown.members.map((member) => {
|
|
1758
|
+
const rowFilter = compileRowFilter(member.rowFilter, shown.actor, dialect);
|
|
1759
|
+
return {
|
|
1760
|
+
modelId: member.modelId,
|
|
1761
|
+
conditions: rowFilter ? [rowFilter] : []
|
|
1762
|
+
};
|
|
1763
|
+
});
|
|
1764
|
+
const [single] = members;
|
|
1765
|
+
const matches = ids.matches(sql`h.entry_id`);
|
|
1766
|
+
const where = whereOf(members.length === 1 && single ? {
|
|
1632
1767
|
siteId: shown.siteId,
|
|
1633
|
-
|
|
1768
|
+
model: modelIs(single.modelId),
|
|
1634
1769
|
source: shown.source,
|
|
1635
1770
|
locales: shown.locales,
|
|
1636
|
-
conditions: [
|
|
1637
|
-
|
|
1771
|
+
conditions: [matches, ...single.conditions]
|
|
1772
|
+
} : {
|
|
1773
|
+
siteId: shown.siteId,
|
|
1774
|
+
model: anyMember(members),
|
|
1775
|
+
source: shown.source,
|
|
1776
|
+
locales: shown.locales,
|
|
1777
|
+
conditions: [matches]
|
|
1638
1778
|
}, table, dialect);
|
|
1639
1779
|
return sql`exists (select 1 from ${ids.from} where exists (select 1 from ${table} h
|
|
1640
1780
|
join entries e on e.id = h.entry_id and e.deleted_at is null where ${where}))`;
|
|
@@ -1697,7 +1837,7 @@ const compileAuthorCondition = (adminUserId, dialect = contentDialect()) => sql`
|
|
|
1697
1837
|
const compileSearch = (fieldId, text, dialect = contentDialect()) => containsInsensitiveExpression(fieldId, text, dialect);
|
|
1698
1838
|
const SNAPSHOT_CTE = "content_snapshot";
|
|
1699
1839
|
/** The FROM item for a source, aliased `alias`, and the CTE it needs (snapshots only). */
|
|
1700
|
-
const sourceOf$1 = (source,
|
|
1840
|
+
const sourceOf$1 = (source, modelIds, siteId, dialect) => {
|
|
1701
1841
|
if (source.kind === "heads") return {
|
|
1702
1842
|
cte: null,
|
|
1703
1843
|
table: sql.table("entry_heads")
|
|
@@ -1706,7 +1846,7 @@ const sourceOf$1 = (source, modelId, siteId, dialect) => {
|
|
|
1706
1846
|
cte: dialect.snapshotCte({
|
|
1707
1847
|
name: SNAPSHOT_CTE,
|
|
1708
1848
|
siteId,
|
|
1709
|
-
|
|
1849
|
+
modelIds: modelIds.map(modelIdLiteral),
|
|
1710
1850
|
seq: source.seq
|
|
1711
1851
|
}),
|
|
1712
1852
|
table: sql.id(SNAPSHOT_CTE)
|
|
@@ -1721,13 +1861,16 @@ const localeConditions = (scope, table, source, dialect) => {
|
|
|
1721
1861
|
and ${dialect.oneOf(sql`h2.locale`, chain, "text")}
|
|
1722
1862
|
and ${dialect.localeRank(chain, sql`h2.locale`)} < ${dialect.localeRank(chain, sql`h.locale`)})`];
|
|
1723
1863
|
};
|
|
1724
|
-
const
|
|
1864
|
+
const modelIs = (modelId) => sql`h.model_id = ${modelIdLiteral(modelId)}`;
|
|
1865
|
+
/** Heads of any of the members, each under its own conditions (a relation's target family). */
|
|
1866
|
+
const anyMember = (members) => sql`(${sql.join(members.map((member) => member.conditions.length === 0 ? modelIs(member.modelId) : sql`(${modelIs(member.modelId)} and ${sql.join([...member.conditions], sql` and `)})`), sql` or `)})`;
|
|
1867
|
+
const whereOf = (scope, table, dialect) => {
|
|
1725
1868
|
const conditions = [
|
|
1726
|
-
sql`h.site_id = ${dialect.uuid(
|
|
1727
|
-
|
|
1728
|
-
...
|
|
1729
|
-
...localeConditions(
|
|
1730
|
-
...
|
|
1869
|
+
sql`h.site_id = ${dialect.uuid(scope.siteId)}`,
|
|
1870
|
+
scope.model,
|
|
1871
|
+
...scope.source.kind === "heads" ? [sql`h.state = ${scope.source.state}`] : [],
|
|
1872
|
+
...localeConditions(scope.locales, table, scope.source, dialect),
|
|
1873
|
+
...scope.conditions
|
|
1731
1874
|
];
|
|
1732
1875
|
return sql.join(conditions, sql` and `);
|
|
1733
1876
|
};
|
|
@@ -1738,30 +1881,94 @@ const whereOf = (plan, table, dialect) => {
|
|
|
1738
1881
|
* (the model index, or a field index for an indexed filter). A snapshot keeps the join: the publication log
|
|
1739
1882
|
* still lists entries deleted since.
|
|
1740
1883
|
*/
|
|
1741
|
-
const countFrom = (plan, table, where, joined) => plan.source.kind === "heads" && !(plan.conditionsReadEntries ?? plan.conditions.length > 0) ? sql`from ${table} h where ${where}` : joined;
|
|
1742
|
-
const HEAD_COLUMNS = sql`h.entry_id, h.locale, h.data, h.version, h.revision_id, h.updated_at,
|
|
1884
|
+
const countFrom = (plan, member, table, where, joined) => plan.source.kind === "heads" && !(plan.conditionsReadEntries ?? plan.conditions.length > 0) && (member.conditions?.length ?? 0) === 0 ? sql`from ${table} h where ${where}` : joined;
|
|
1885
|
+
const HEAD_COLUMNS = sql`h.entry_id, h.model_id, h.locale, h.data, h.version, h.revision_id, h.updated_at,
|
|
1743
1886
|
h.autosaved_at, e.created_at, e.updated_at as entry_updated_at, e.created_by_admin_id, e.owner_app_user_id`;
|
|
1744
|
-
/** The
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
1887
|
+
/** The same columns read back from the union of a family read's arms (aliased `u`). */
|
|
1888
|
+
const UNION_COLUMNS = sql`u.entry_id, u.model_id, u.locale, u.data, u.version, u.revision_id, u.updated_at,
|
|
1889
|
+
u.autosaved_at, u.created_at, u.entry_updated_at, u.created_by_admin_id, u.owner_app_user_id`;
|
|
1890
|
+
const renderTerm = (expression, term, dialect) => dialect.sortTerm ? dialect.sortTerm(expression, term.direction, term.nullable) : sql`${expression} ${dialect.sortDirection(term.direction, term.nullable)}`;
|
|
1891
|
+
const orderByOf = (terms) => terms.length > 0 ? sql` order by ${sql.join([...terms])}` : sql``;
|
|
1892
|
+
/** The FROM, WHERE and COUNT FROM of one member's heads. */
|
|
1893
|
+
const memberParts = (plan, member, table, dialect) => {
|
|
1894
|
+
const where = whereOf({
|
|
1895
|
+
siteId: plan.siteId,
|
|
1896
|
+
model: modelIs(member.modelId),
|
|
1897
|
+
source: plan.source,
|
|
1898
|
+
locales: plan.locales,
|
|
1899
|
+
conditions: [...plan.conditions, ...member.conditions ?? []]
|
|
1900
|
+
}, table, dialect);
|
|
1748
1901
|
const from = sql`from ${table} h join entries e on e.id = h.entry_id and e.deleted_at is null where ${where}`;
|
|
1749
|
-
|
|
1902
|
+
return {
|
|
1903
|
+
from,
|
|
1904
|
+
counted: countFrom(plan, member, table, where, from)
|
|
1905
|
+
};
|
|
1906
|
+
};
|
|
1907
|
+
/** One model: the statement as it has always been. */
|
|
1908
|
+
const singleParts = (plan, member, table, dialect) => {
|
|
1909
|
+
const { from, counted } = memberParts(plan, member, table, dialect);
|
|
1910
|
+
const orderBy = orderByOf(plan.orderBy.map((term) => renderTerm(term.expression, term, dialect)));
|
|
1750
1911
|
const limit = plan.limit !== void 0 ? sql` limit ${plan.limit}` : sql``;
|
|
1751
1912
|
const offset = plan.offset ? sql` offset ${plan.offset}` : sql``;
|
|
1752
1913
|
return {
|
|
1753
|
-
|
|
1914
|
+
columns: HEAD_COLUMNS,
|
|
1754
1915
|
rows: sql`${from}${orderBy}${limit}${offset}`,
|
|
1755
|
-
|
|
1916
|
+
total: sql`(select count(*) ${counted})`,
|
|
1917
|
+
count: sql`select count(*) as total ${counted}`
|
|
1918
|
+
};
|
|
1919
|
+
};
|
|
1920
|
+
/**
|
|
1921
|
+
* A family read (plan polymorphic-types): one arm per member, each `h.model_id = '<literal>'` with that
|
|
1922
|
+
* member's conditions, so each keeps its partial expression indexes and entry-order index. Each arm is a
|
|
1923
|
+
* derived table that projects the sort expressions as `s0…sn`, sorts and stops at `offset + limit` itself
|
|
1924
|
+
* (SQLite allows ORDER BY and LIMIT in a compound member only inside a derived table); the union is ordered
|
|
1925
|
+
* by the aliases and paged on the outside. The total is the sum of the arms' counts.
|
|
1926
|
+
*/
|
|
1927
|
+
const unionParts = (plan, table, dialect) => {
|
|
1928
|
+
const pageEnd = plan.limit !== void 0 ? plan.limit + (plan.offset ?? 0) : void 0;
|
|
1929
|
+
const aliases = plan.orderBy.map((_term, index) => `s${index}`);
|
|
1930
|
+
const projected = plan.orderBy.map((term, index) => sql`${term.expression} as ${sql.id(aliases[index])}`);
|
|
1931
|
+
const arms = plan.members.map((member, index) => {
|
|
1932
|
+
const { from, counted } = memberParts(plan, member, table, dialect);
|
|
1933
|
+
const columns = projected.length > 0 ? sql`${HEAD_COLUMNS}, ${sql.join(projected)}` : HEAD_COLUMNS;
|
|
1934
|
+
const orderBy = pageEnd !== void 0 ? orderByOf(plan.orderBy.map((term) => renderTerm(term.expression, term, dialect))) : sql``;
|
|
1935
|
+
const limit = pageEnd !== void 0 ? sql` limit ${pageEnd}` : sql``;
|
|
1936
|
+
return {
|
|
1937
|
+
rows: sql`select * from (select ${columns}
|
|
1938
|
+
${from}${orderBy}${limit}) ${sql.id(`a${index}`)}`,
|
|
1939
|
+
total: sql`(select count(*) ${counted})`
|
|
1940
|
+
};
|
|
1941
|
+
});
|
|
1942
|
+
const orderBy = orderByOf(plan.orderBy.map((term, index) => renderTerm(sql.ref(`u.${aliases[index]}`), term, dialect)));
|
|
1943
|
+
const limit = plan.limit !== void 0 ? sql` limit ${plan.limit}` : sql``;
|
|
1944
|
+
const offset = plan.offset ? sql` offset ${plan.offset}` : sql``;
|
|
1945
|
+
const total = sql`(${sql.join(arms.map((arm) => arm.total), sql` + `)})`;
|
|
1946
|
+
return {
|
|
1947
|
+
columns: UNION_COLUMNS,
|
|
1948
|
+
rows: sql`from (${sql.join(arms.map((arm) => arm.rows), sql`
|
|
1949
|
+
union all `)}) u${orderBy}${limit}${offset}`,
|
|
1950
|
+
total,
|
|
1951
|
+
count: sql`select ${total} as total`
|
|
1952
|
+
};
|
|
1953
|
+
};
|
|
1954
|
+
/** The parts every statement over a plan's heads shares. */
|
|
1955
|
+
const headQueryParts = (plan, dialect) => {
|
|
1956
|
+
const [first] = plan.members;
|
|
1957
|
+
if (!first) throw new Error("A head query reads at least one model");
|
|
1958
|
+
const { cte, table } = sourceOf$1(plan.source, plan.members.map((member) => member.modelId), plan.siteId, dialect);
|
|
1959
|
+
const parts = plan.members.length === 1 ? singleParts(plan, first, table, dialect) : unionParts(plan, table, dialect);
|
|
1960
|
+
return {
|
|
1961
|
+
prefix: cte ?? sql``,
|
|
1962
|
+
...parts
|
|
1756
1963
|
};
|
|
1757
1964
|
};
|
|
1758
1965
|
/** The SELECT for a plan: rows, plus the matching COUNT for pagination. */
|
|
1759
1966
|
const compileHeadQuery = (plan, dialect = contentDialect()) => {
|
|
1760
|
-
const { prefix, rows,
|
|
1967
|
+
const { prefix, columns, rows, count } = headQueryParts(plan, dialect);
|
|
1761
1968
|
return {
|
|
1762
|
-
rows: sql`${prefix}select ${
|
|
1969
|
+
rows: sql`${prefix}select ${columns}
|
|
1763
1970
|
${rows}`,
|
|
1764
|
-
count: sql`${prefix}
|
|
1971
|
+
count: sql`${prefix}${count}`
|
|
1765
1972
|
};
|
|
1766
1973
|
};
|
|
1767
1974
|
/**
|
|
@@ -1772,11 +1979,11 @@ const compileHeadQuery = (plan, dialect = contentDialect()) => {
|
|
|
1772
1979
|
* sequence alone, for callers that read it in the same transaction as `rows`.
|
|
1773
1980
|
*/
|
|
1774
1981
|
const compileHeadPage = (plan, options, dialect = contentDialect()) => {
|
|
1775
|
-
const { prefix, rows,
|
|
1776
|
-
const total = options.total ?
|
|
1982
|
+
const { prefix, columns, rows, total: counted } = headQueryParts(plan, dialect);
|
|
1983
|
+
const total = options.total ? counted : sql`null`;
|
|
1777
1984
|
const seq = sql`(select ps.last_seq from publication_state ps where ps.site_id = ${dialect.uuid(plan.siteId)})`;
|
|
1778
1985
|
return {
|
|
1779
|
-
rows: sql`${prefix}select ${
|
|
1986
|
+
rows: sql`${prefix}select ${columns}, ${total} as page_total, ${seq} as page_seq
|
|
1780
1987
|
${rows}`,
|
|
1781
1988
|
meta: sql`${prefix}select ${total} as page_total, ${seq} as page_seq`
|
|
1782
1989
|
};
|
|
@@ -1791,6 +1998,8 @@ const compileHeadPage = (plan, options, dialect = contentDialect()) => {
|
|
|
1791
1998
|
const entryIdIs = (id, dialect = contentDialect()) => sql`h.entry_id = ${dialect.uuid(id)}`;
|
|
1792
1999
|
/** The head belongs to one of `ids` (one bound parameter, whatever its length). */
|
|
1793
2000
|
const entryIdIn = (ids, dialect = contentDialect()) => dialect.oneOf(sql`h.entry_id`, ids, "uuid");
|
|
2001
|
+
/** No head: a read narrowed to kinds the caller may not read (`filters[__model]`). */
|
|
2002
|
+
const matchesNothing = (dialect = contentDialect()) => dialect.bool(false);
|
|
1794
2003
|
//#endregion
|
|
1795
2004
|
//#region ../../apps/api/src/content/compiler/sort.ts
|
|
1796
2005
|
/**
|
|
@@ -1807,12 +2016,11 @@ const SYSTEM_SORT_COLUMNS = {
|
|
|
1807
2016
|
createdAt: "h.entry_created_at",
|
|
1808
2017
|
updatedAt: "h.updated_at"
|
|
1809
2018
|
};
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
};
|
|
2019
|
+
const termOf = (term, dialect) => ({
|
|
2020
|
+
expression: term.target.kind === "system" ? sql.ref(SYSTEM_SORT_COLUMNS[term.target.name]) : fieldValueExpression(term.target.field.id, term.target.field.type, dialect),
|
|
2021
|
+
direction: term.direction,
|
|
2022
|
+
nullable: term.target.kind === "field"
|
|
2023
|
+
});
|
|
1816
2024
|
const defaultSortTerms = (model, isReadable) => {
|
|
1817
2025
|
const preferred = model.display.defaultSort;
|
|
1818
2026
|
const field = preferred ? model.fields.find((candidate) => candidate.id === preferred.fieldId) : void 0;
|
|
@@ -1831,7 +2039,15 @@ const defaultSortTerms = (model, isReadable) => {
|
|
|
1831
2039
|
direction: "desc"
|
|
1832
2040
|
}];
|
|
1833
2041
|
};
|
|
1834
|
-
|
|
2042
|
+
/**
|
|
2043
|
+
* The ORDER BY of a head query, as terms the compiler renders (`compile.ts`): a family read orders each arm by
|
|
2044
|
+
* them and the union by their projected aliases.
|
|
2045
|
+
*/
|
|
2046
|
+
const compileOrderBy = (terms, dialect = contentDialect()) => [...terms.map((term) => termOf(term, dialect)), {
|
|
2047
|
+
expression: sql`h.entry_id`,
|
|
2048
|
+
direction: "asc",
|
|
2049
|
+
nullable: false
|
|
2050
|
+
}];
|
|
1835
2051
|
//#endregion
|
|
1836
2052
|
//#region ../../apps/api/src/content/locales.ts
|
|
1837
2053
|
/**
|
|
@@ -2118,15 +2334,17 @@ const populateRelations = async (env, rows, fields, tree) => {
|
|
|
2118
2334
|
populated.set(fieldId, byId);
|
|
2119
2335
|
const target = resolveModelById(env.snapshot, field.settings.target);
|
|
2120
2336
|
if (!target) continue;
|
|
2121
|
-
const
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2337
|
+
const heads = await env.fetch(env, target, idsOf$1(rows, fieldId));
|
|
2338
|
+
for (const group of groupByMember(heads.members, heads.rows)) {
|
|
2339
|
+
const { model, policy } = group.member;
|
|
2340
|
+
for (const { row, data } of await env.project(env, model, policy, group.rows, {
|
|
2341
|
+
fields: null,
|
|
2342
|
+
populate: subtree
|
|
2343
|
+
})) byId.set(row.entry_id, {
|
|
2344
|
+
...env.system(env, row),
|
|
2345
|
+
...data
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2130
2348
|
}
|
|
2131
2349
|
return populated;
|
|
2132
2350
|
};
|
|
@@ -2248,26 +2466,58 @@ const readPolicy = (env, modelId) => env.permissions.evaluate(env.actor, {
|
|
|
2248
2466
|
modelId
|
|
2249
2467
|
}, env.executor);
|
|
2250
2468
|
/**
|
|
2251
|
-
*
|
|
2252
|
-
*
|
|
2469
|
+
* The plan members and shared conditions of a read over these members. A lone member's row filter stays a
|
|
2470
|
+
* plan condition, so a read of a model without kinds compiles exactly as it always has; with several, each
|
|
2471
|
+
* member carries its own.
|
|
2253
2472
|
*/
|
|
2254
|
-
const
|
|
2255
|
-
|
|
2256
|
-
|
|
2473
|
+
const planMembers = (members, actor) => {
|
|
2474
|
+
const compiled = members.map((member) => ({
|
|
2475
|
+
modelId: member.model.definition.id,
|
|
2476
|
+
rowFilter: compileRowFilter(member.policy.rowFilter, actor)
|
|
2477
|
+
}));
|
|
2478
|
+
const [single] = compiled;
|
|
2479
|
+
if (compiled.length === 1 && single) return {
|
|
2480
|
+
members: [{ modelId: single.modelId }],
|
|
2481
|
+
conditions: single.rowFilter ? [single.rowFilter] : []
|
|
2482
|
+
};
|
|
2483
|
+
return {
|
|
2484
|
+
members: compiled.map(({ modelId, rowFilter }) => ({
|
|
2485
|
+
modelId,
|
|
2486
|
+
...rowFilter ? { conditions: [rowFilter] } : {}
|
|
2487
|
+
})),
|
|
2488
|
+
conditions: []
|
|
2489
|
+
};
|
|
2490
|
+
};
|
|
2491
|
+
/**
|
|
2492
|
+
* Heads of the given entries over the model's family (a relation to a model accepts any of its kinds, plan
|
|
2493
|
+
* polymorphic-types), each member under its own read policy, served per the locale chain (or `locales`).
|
|
2494
|
+
* Members the caller may not read are left out.
|
|
2495
|
+
*/
|
|
2496
|
+
const fetchFamilyHeadsByIds = async (env, model, ids, locales) => {
|
|
2497
|
+
const members = await readableMembers(env.snapshot, model, (modelId) => readPolicy(env, modelId));
|
|
2498
|
+
if (ids.length === 0 || members.length === 0) return {
|
|
2499
|
+
members,
|
|
2500
|
+
rows: []
|
|
2501
|
+
};
|
|
2502
|
+
const plan = planMembers(members, env.actor);
|
|
2257
2503
|
const { rows } = compileHeadQuery({
|
|
2258
2504
|
siteId: env.siteId,
|
|
2259
|
-
|
|
2505
|
+
members: plan.members,
|
|
2260
2506
|
source: env.source,
|
|
2261
2507
|
locales: locales ?? readScopeFor(env.snapshot, model.definition, env.locale, { fallback: true }),
|
|
2262
|
-
conditions: [entryIdIn(ids), ...
|
|
2508
|
+
conditions: [entryIdIn(ids), ...plan.conditions],
|
|
2263
2509
|
orderBy: []
|
|
2264
2510
|
});
|
|
2265
|
-
return
|
|
2511
|
+
return {
|
|
2512
|
+
members,
|
|
2513
|
+
rows: await runHeadQuery(rows, env.executor)
|
|
2514
|
+
};
|
|
2266
2515
|
};
|
|
2267
2516
|
/**
|
|
2268
2517
|
* Relation targets the caller may see in these rows: for delivery, only targets that are published (at the
|
|
2269
2518
|
* same snapshot) and readable under their model's policy, so a published entry never leaks the ID of a
|
|
2270
|
-
* draft (brief §10).
|
|
2519
|
+
* draft (brief §10). A relation to a model with kinds shows targets of any kind the caller may read. Admin
|
|
2520
|
+
* reads see every ID (null). The heads read are returned for populate.
|
|
2271
2521
|
*/
|
|
2272
2522
|
const relationTargets = async (env, model, rows, fields) => {
|
|
2273
2523
|
const fetched = /* @__PURE__ */ new Map();
|
|
@@ -2280,12 +2530,11 @@ const relationTargets = async (env, model, rows, fields) => {
|
|
|
2280
2530
|
for (const [targetModelId, ids] of targets) {
|
|
2281
2531
|
const target = resolveModelById(env.snapshot, targetModelId);
|
|
2282
2532
|
if (!target) continue;
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
found.forEach((row) => visible.add(row.entry_id));
|
|
2533
|
+
const heads = await fetchFamilyHeadsByIds(env, target, [...ids]);
|
|
2534
|
+
heads.rows.forEach((row) => visible.add(row.entry_id));
|
|
2286
2535
|
fetched.set(targetModelId, {
|
|
2287
2536
|
ids,
|
|
2288
|
-
|
|
2537
|
+
heads
|
|
2289
2538
|
});
|
|
2290
2539
|
}
|
|
2291
2540
|
return {
|
|
@@ -2294,16 +2543,20 @@ const relationTargets = async (env, model, rows, fields) => {
|
|
|
2294
2543
|
};
|
|
2295
2544
|
};
|
|
2296
2545
|
/**
|
|
2297
|
-
* `
|
|
2298
|
-
* every one of these IDs under the same
|
|
2546
|
+
* `fetchFamilyHeadsByIds` for populate, answered from the heads relation visibility already read when it
|
|
2547
|
+
* asked for every one of these IDs under the same policies and locale scope (populate's default), else from
|
|
2548
|
+
* the database.
|
|
2299
2549
|
*/
|
|
2300
|
-
const fetchReusing = (fetched) => async (env, model,
|
|
2550
|
+
const fetchReusing = (fetched) => async (env, model, ids) => {
|
|
2301
2551
|
const known = fetched.get(model.definition.id);
|
|
2302
2552
|
if (known && ids.every((id) => known.ids.has(id))) {
|
|
2303
2553
|
const wanted = new Set(ids);
|
|
2304
|
-
return
|
|
2554
|
+
return {
|
|
2555
|
+
members: known.heads.members,
|
|
2556
|
+
rows: known.heads.rows.filter((row) => wanted.has(row.entry_id))
|
|
2557
|
+
};
|
|
2305
2558
|
}
|
|
2306
|
-
return
|
|
2559
|
+
return fetchFamilyHeadsByIds(env, model, ids);
|
|
2307
2560
|
};
|
|
2308
2561
|
/**
|
|
2309
2562
|
* Whether projecting these rows reads anything more (relation targets, populated entries, asset views). When
|
|
@@ -2369,9 +2622,21 @@ const toDeliveryAsset = (view) => ({
|
|
|
2369
2622
|
url
|
|
2370
2623
|
}))
|
|
2371
2624
|
});
|
|
2372
|
-
/**
|
|
2625
|
+
/** The singular API IDs of a model's lineage, root first, the model last. */
|
|
2626
|
+
const kindLineage = (snapshot, modelId) => [...snapshot.ancestors(modelId), modelId].flatMap((id) => {
|
|
2627
|
+
const apiKey = snapshot.byId.get(id)?.definition.apiKey;
|
|
2628
|
+
return apiKey === void 0 ? [] : [apiKey];
|
|
2629
|
+
});
|
|
2630
|
+
/**
|
|
2631
|
+
* System attributes first, then the readable, selected fields by API key. `__model` is the API ID of the
|
|
2632
|
+
* entry's own model, on every entry (plan polymorphic-types): a family read mixes kinds, and a client can rely
|
|
2633
|
+
* on it everywhere, as it does on `__component` in dynamic zones. `__kinds` is the kind's lineage, root first,
|
|
2634
|
+
* so a client can tell a Physical row from its Apparel kind without knowing the schema.
|
|
2635
|
+
*/
|
|
2373
2636
|
const systemAttributes = (env, row) => ({
|
|
2374
2637
|
id: row.entry_id,
|
|
2638
|
+
[MODEL_KEY]: env.snapshot.byId.get(row.model_id)?.definition.apiKey ?? null,
|
|
2639
|
+
[KINDS_KEY]: kindLineage(env.snapshot, row.model_id),
|
|
2375
2640
|
locale: row.locale,
|
|
2376
2641
|
createdAt: row.created_at.toISOString(),
|
|
2377
2642
|
updatedAt: row.updated_at.toISOString(),
|
|
@@ -2393,7 +2658,6 @@ const projectRows = async (env, model, policy, rows, query) => {
|
|
|
2393
2658
|
...env,
|
|
2394
2659
|
project: projectRows,
|
|
2395
2660
|
fetch: fetchReusing(fetched),
|
|
2396
|
-
policyFor: readPolicy,
|
|
2397
2661
|
system: systemAttributes
|
|
2398
2662
|
};
|
|
2399
2663
|
const populated = await populateRelations(populateEnv, rows, fields, query.populate);
|
|
@@ -2418,6 +2682,23 @@ const projectRows = async (env, model, policy, rows, query) => {
|
|
|
2418
2682
|
};
|
|
2419
2683
|
});
|
|
2420
2684
|
};
|
|
2685
|
+
/**
|
|
2686
|
+
* Rows of a family read projected each through its own member (plan polymorphic-types): the member's
|
|
2687
|
+
* effective fields, read mask, components and SEO fields. Without `fields=` a row carries every field its kind
|
|
2688
|
+
* has and the caller may read; with it, the named (shared) fields. Rows keep their order; a row of a model
|
|
2689
|
+
* that is not a member is left out.
|
|
2690
|
+
*/
|
|
2691
|
+
const projectMemberRows = async (env, members, rows, query) => {
|
|
2692
|
+
const projected = /* @__PURE__ */ new Map();
|
|
2693
|
+
for (const group of groupByMember(members, rows)) for (const { row, data } of await projectRows(env, group.member.model, group.member.policy, group.rows, query)) projected.set(row, data);
|
|
2694
|
+
return rows.flatMap((row) => {
|
|
2695
|
+
const data = projected.get(row);
|
|
2696
|
+
return data ? [{
|
|
2697
|
+
row,
|
|
2698
|
+
data
|
|
2699
|
+
}] : [];
|
|
2700
|
+
});
|
|
2701
|
+
};
|
|
2421
2702
|
//#endregion
|
|
2422
2703
|
//#region ../../apps/api/src/content/visibleReferences.ts
|
|
2423
2704
|
const ID_OPERATORS = /* @__PURE__ */ new Set([
|
|
@@ -2457,8 +2738,7 @@ const visibleIds = async (env, conditions) => {
|
|
|
2457
2738
|
continue;
|
|
2458
2739
|
}
|
|
2459
2740
|
if (env.audience === "admin") continue;
|
|
2460
|
-
|
|
2461
|
-
if (!policy.allowed) throw queryForbidden(`You may not filter on "${field.apiKey}": you may not read the entries it links to`, { field: field.apiKey });
|
|
2741
|
+
if (!(await readPolicy(env, field.settings.target)).allowed) throw queryForbidden(`You may not filter on "${field.apiKey}": you may not read the entries it links to`, { field: field.apiKey });
|
|
2462
2742
|
const target = resolveModelById(env.snapshot, field.settings.target);
|
|
2463
2743
|
if (!target) {
|
|
2464
2744
|
visible.set(field.id, {
|
|
@@ -2467,16 +2747,18 @@ const visibleIds = async (env, conditions) => {
|
|
|
2467
2747
|
});
|
|
2468
2748
|
continue;
|
|
2469
2749
|
}
|
|
2470
|
-
const
|
|
2750
|
+
const heads = await fetchFamilyHeadsByIds(env, target, ids);
|
|
2471
2751
|
visible.set(field.id, {
|
|
2472
|
-
ids: new Set(rows.map((row) => row.entry_id)),
|
|
2752
|
+
ids: new Set(heads.rows.map((row) => row.entry_id)),
|
|
2473
2753
|
shown: {
|
|
2474
2754
|
kind: "relation",
|
|
2475
2755
|
siteId: env.siteId,
|
|
2476
|
-
|
|
2756
|
+
members: heads.members.map((member) => ({
|
|
2757
|
+
modelId: member.model.definition.id,
|
|
2758
|
+
rowFilter: member.policy.rowFilter
|
|
2759
|
+
})),
|
|
2477
2760
|
source: env.source,
|
|
2478
2761
|
locales: readScopeFor(env.snapshot, target.definition, env.locale, { fallback: true }),
|
|
2479
|
-
rowFilter: policy.rowFilter,
|
|
2480
2762
|
actor: env.actor
|
|
2481
2763
|
}
|
|
2482
2764
|
});
|
|
@@ -2589,6 +2871,8 @@ const toList = (value, { commas = true } = {}) => {
|
|
|
2589
2871
|
};
|
|
2590
2872
|
//#endregion
|
|
2591
2873
|
//#region ../../apps/api/src/content/compiler/parse.ts
|
|
2874
|
+
/** The system attribute naming an entry's kind; it filters by kind, never as a field. */
|
|
2875
|
+
const KIND_KEY = "__model";
|
|
2592
2876
|
const TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
2593
2877
|
"filters",
|
|
2594
2878
|
"sort",
|
|
@@ -2625,7 +2909,7 @@ const resolveTarget = (context, key, use = "query") => {
|
|
|
2625
2909
|
};
|
|
2626
2910
|
const field = findFieldByApiKey(context.model.fields, key);
|
|
2627
2911
|
if (!field) throw queryInvalid(`Unknown field "${key}"`, { field: key });
|
|
2628
|
-
if (use === "select" ?
|
|
2912
|
+
if (!(use === "select" ? (context.isSelectable ?? context.isReadable)(field) : context.isQueryable(field))) throw queryForbidden(use === "select" ? `You may not read the field "${key}"` : `You may not filter, sort or search on "${key}"`, { field: key });
|
|
2629
2913
|
return {
|
|
2630
2914
|
kind: "field",
|
|
2631
2915
|
field
|
|
@@ -2792,10 +3076,37 @@ const parsePublicationState = (tree, context) => {
|
|
|
2792
3076
|
if (!PUBLICATION_STATE_SET.has(value)) throw queryInvalid(`"publicationState" must be one of ${PUBLICATION_STATES.join(", ")}`);
|
|
2793
3077
|
return { publicationState: value };
|
|
2794
3078
|
};
|
|
3079
|
+
/**
|
|
3080
|
+
* `filters[__model][$eq]=guide` or `[$in]=guide,opinion` (a bare value is `$eq`): the kinds of the route's
|
|
3081
|
+
* family to read, by singular API ID. A top-level filter only; it narrows which kinds are read rather than
|
|
3082
|
+
* becoming a condition.
|
|
3083
|
+
*/
|
|
3084
|
+
const parseKinds = (value, context) => {
|
|
3085
|
+
const kinds = context.kinds;
|
|
3086
|
+
if (!kinds) throw queryInvalid(`Unknown field "${KIND_KEY}"`, { field: KIND_KEY });
|
|
3087
|
+
const [entry, ...extra] = isTree(value) ? Object.entries(value) : [["$eq", value]];
|
|
3088
|
+
if (!entry || extra.length > 0 || entry[0] !== "$eq" && entry[0] !== "$in") throw queryInvalid(`"${KIND_KEY}" takes one $eq or $in of kinds`, { field: KIND_KEY });
|
|
3089
|
+
const [operator, operand] = entry;
|
|
3090
|
+
const values = operator === "$in" ? toList(operand) : [operand];
|
|
3091
|
+
if (operator === "$eq" && typeof operand !== "string") throw queryInvalid(`"${KIND_KEY}" $eq needs a single value`, { field: KIND_KEY });
|
|
3092
|
+
return values.map((item) => {
|
|
3093
|
+
const modelId = typeof item === "string" ? kinds.get(item) : void 0;
|
|
3094
|
+
if (modelId === void 0) throw queryInvalid(`"${typeof item === "string" ? item : JSON.stringify(item)}" is not a kind of this model`, { field: KIND_KEY });
|
|
3095
|
+
return modelId;
|
|
3096
|
+
});
|
|
3097
|
+
};
|
|
3098
|
+
/** The filter tree without its `__model` key, and the kinds that key names. */
|
|
3099
|
+
const splitKindFilter = (filters, context) => {
|
|
3100
|
+
const { [KIND_KEY]: kindFilter, ...rest } = filters;
|
|
3101
|
+
return {
|
|
3102
|
+
filters: Object.keys(rest).length > 0 ? rest : null,
|
|
3103
|
+
kinds: kindFilter !== void 0 ? parseKinds(kindFilter, context) : void 0
|
|
3104
|
+
};
|
|
3105
|
+
};
|
|
2795
3106
|
const parseContentQuery = (tree, context) => {
|
|
2796
3107
|
for (const key of Object.keys(tree)) if (!TOP_LEVEL_KEYS.has(key)) throw queryInvalid(`Unknown query parameter "${key}"`);
|
|
2797
|
-
|
|
2798
|
-
|
|
3108
|
+
if (tree.filters !== void 0 && !isTree(tree.filters)) throw queryInvalid("filters needs field conditions, e.g. filters[title][$eq]=Hello");
|
|
3109
|
+
const { filters, kinds } = tree.filters ? splitKindFilter(tree.filters, context) : { filters: null };
|
|
2799
3110
|
const locale = single(tree.locale, "locale");
|
|
2800
3111
|
if (locale !== void 0 && !context.locales.includes(locale)) throw queryInvalid(`Unknown locale "${locale}"`);
|
|
2801
3112
|
const snapshot = positiveInteger(tree.snapshot, "snapshot", Number.MAX_SAFE_INTEGER);
|
|
@@ -2816,7 +3127,8 @@ const parseContentQuery = (tree, context) => {
|
|
|
2816
3127
|
...parseRichText(tree, context),
|
|
2817
3128
|
...parseSeo(tree, context),
|
|
2818
3129
|
...parsePublicationState(tree, context),
|
|
2819
|
-
...parseAdminFilters(tree, context)
|
|
3130
|
+
...parseAdminFilters(tree, context),
|
|
3131
|
+
...kinds ? { kinds } : {}
|
|
2820
3132
|
};
|
|
2821
3133
|
};
|
|
2822
3134
|
//#endregion
|
|
@@ -2841,31 +3153,78 @@ sql``, sql` desc`;
|
|
|
2841
3153
|
sql` nulls first`, sql` nulls last`;
|
|
2842
3154
|
//#endregion
|
|
2843
3155
|
//#region ../../apps/api/src/services/contentReads.ts
|
|
2844
|
-
/**
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
const
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
3156
|
+
/** Whether the filter, search and admin filters of a query name an `entries` column. */
|
|
3157
|
+
const queryFilterReadsEntries = (query) => query.filter !== null && filterReadsEntries(query.filter) || query.author !== void 0;
|
|
3158
|
+
/** Filters, search and admin filters of a parsed query as SQL conditions. */
|
|
3159
|
+
const queryFilterConditions = (query) => [
|
|
3160
|
+
...query.filter ? [compileFilter(query.filter)] : [],
|
|
3161
|
+
...query.search ? [compileSearch(query.search.field.id, query.search.text)] : [],
|
|
3162
|
+
...query.status ? [compileStatusCondition(query.status)] : [],
|
|
3163
|
+
...query.author ? [compileAuthorCondition(query.author)] : []
|
|
3164
|
+
];
|
|
3165
|
+
/**
|
|
3166
|
+
* The plan of a read over a family's readable members (plan polymorphic-types): the members, and the query's
|
|
3167
|
+
* conditions shared by every arm. A lone member's row filter is a shared condition, so a model without kinds
|
|
3168
|
+
* compiles as it always has.
|
|
3169
|
+
*/
|
|
3170
|
+
const familyQueryPlan = (context, query, members) => {
|
|
3171
|
+
const [route] = members;
|
|
3172
|
+
const kinds = query.kinds ? new Set(query.kinds) : null;
|
|
3173
|
+
const narrowed = kinds ? members.filter((member) => kinds.has(member.model.definition.id)) : members;
|
|
3174
|
+
if (narrowed.length === 0 && route) return {
|
|
3175
|
+
members: [{ modelId: route.model.definition.id }],
|
|
3176
|
+
conditions: [matchesNothing()],
|
|
3177
|
+
conditionsReadEntries: false
|
|
3178
|
+
};
|
|
3179
|
+
const plan = planMembers(narrowed, context.actor);
|
|
3180
|
+
return {
|
|
3181
|
+
members: plan.members,
|
|
3182
|
+
conditions: [...queryFilterConditions(query), ...plan.conditions],
|
|
3183
|
+
conditionsReadEntries: queryFilterReadsEntries(query) || plan.conditions.length > 0
|
|
3184
|
+
};
|
|
2856
3185
|
};
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3186
|
+
/** The route model's family by API ID, for `filters[__model]`. */
|
|
3187
|
+
const kindsOf$1 = (context, model) => new Map(familyIdsOf(context.snapshot, model.definition.id).flatMap((id) => {
|
|
3188
|
+
const apiKey = context.snapshot.byId.get(id)?.definition.apiKey;
|
|
3189
|
+
return apiKey ? [[apiKey, id]] : [];
|
|
3190
|
+
}));
|
|
3191
|
+
/**
|
|
3192
|
+
* The readable members of the route model's family (the route model first, under the policy already
|
|
3193
|
+
* evaluated for it). A kind the caller may not read is left out of the read, never a 403.
|
|
3194
|
+
*/
|
|
3195
|
+
const familyMembersFor = (context, model, policy) => readableMembers(context.snapshot, model, (modelId) => modelId === model.definition.id ? Promise.resolve(policy) : context.permissions.evaluate(context.actor, {
|
|
3196
|
+
action: "read",
|
|
3197
|
+
modelId
|
|
3198
|
+
}));
|
|
3199
|
+
/**
|
|
3200
|
+
* A field the query may filter, sort or search on (and, in a family read, select): readable on every row of
|
|
3201
|
+
* every kind read, so a query never reveals a value one kind's grant hides (plan polymorphic-types, decision
|
|
3202
|
+
* 11).
|
|
3203
|
+
*/
|
|
3204
|
+
const readableOnEveryRow = (members) => (field) => members.every((member) => maskAllows(everyRowReadMask(member.policy), field));
|
|
3205
|
+
const parseQueryFor = (context, model, policy, rawQuery, options) => {
|
|
3206
|
+
const members = options.members ?? [{
|
|
3207
|
+
model,
|
|
3208
|
+
policy
|
|
3209
|
+
}];
|
|
3210
|
+
const family = members.length > 1;
|
|
3211
|
+
return parseContentQuery(parseQueryTree(rawQuery), {
|
|
3212
|
+
model: model.definition,
|
|
3213
|
+
locales: context.snapshot.locales.map((locale) => locale.code),
|
|
3214
|
+
isReadable: (field) => maskAllows(policy.readMask, field),
|
|
3215
|
+
...family ? { isSelectable: readableOnEveryRow(members) } : {},
|
|
3216
|
+
isQueryable: readableOnEveryRow(members),
|
|
3217
|
+
allowSnapshot: options.allowSnapshot,
|
|
3218
|
+
allowAdminFilters: options.allowAdminFilters ?? false,
|
|
3219
|
+
allowRichText: options.allowRichText ?? false,
|
|
3220
|
+
allowSeo: options.allowSeo ?? false,
|
|
3221
|
+
allowPublicationState: options.allowPublicationState ?? false,
|
|
3222
|
+
resolveModel: (modelId) => resolveModelById(context.snapshot, modelId)?.definition,
|
|
3223
|
+
kinds: kindsOf$1(context, model)
|
|
3224
|
+
});
|
|
3225
|
+
};
|
|
3226
|
+
/** The sort of a read: the query's, else the route model's default when every kind read shows that field. */
|
|
3227
|
+
const sortOf = (query, model, members) => query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, readableOnEveryRow(members));
|
|
2869
3228
|
//#endregion
|
|
2870
3229
|
//#region ../../apps/api/src/repositories/sites.ts
|
|
2871
3230
|
const findById = (id, trx = db) => trx.selectFrom("sites").selectAll().where("id", "=", id).executeTakeFirst();
|
|
@@ -2941,12 +3300,21 @@ const deliveryEnvironment = (context, executor, source, locale, richText, seo) =
|
|
|
2941
3300
|
...richText ? { richText } : {},
|
|
2942
3301
|
...seo ? { seo } : {}
|
|
2943
3302
|
});
|
|
3303
|
+
/** The kinds a read covers: its members, or the model alone. */
|
|
3304
|
+
const membersOf = (read) => read.members ?? [{
|
|
3305
|
+
model: read.model,
|
|
3306
|
+
policy: read.policy
|
|
3307
|
+
}];
|
|
2944
3308
|
const environmentOf = (read, executor) => deliveryEnvironment(read.context, executor, read.source, read.query.locale, read.query.richText, read.seo);
|
|
2945
3309
|
/**
|
|
2946
3310
|
* Selected top-level relation or media fields, populate, or a default SEO image to show: the read will need a
|
|
2947
3311
|
* follow-up statement.
|
|
2948
3312
|
*/
|
|
2949
|
-
const followsUp = (read) => read.query.populate.size > 0 ||
|
|
3313
|
+
const followsUp = (read) => read.query.populate.size > 0 || membersOf(read).some(({ model, policy }) => selectFields(model, policy.readMask, read.query.fields).some((field) => field.type === "relation" || field.type === "media" || Boolean(read.seo?.imageId) && isSeoField(field)));
|
|
3314
|
+
/** `needsFollowUpReads` over every kind among the rows. */
|
|
3315
|
+
const rowsNeedFollowUp = (env, read, rows) => groupByMember(membersOf(read), rows).some(({ member, rows: memberRows }) => needsFollowUpReads(env, member.model, memberRows, read.query, member.policy));
|
|
3316
|
+
/** `needsMediaReads` over every kind among the rows. */
|
|
3317
|
+
const rowsNeedMedia = (env, read, rows) => groupByMember(membersOf(read), rows).some(({ member, rows: memberRows }) => needsMediaReads(env, member.model, memberRows, selectFields(member.model, member.policy.readMask, read.query.fields)));
|
|
2950
3318
|
/**
|
|
2951
3319
|
* A live read of published heads (no `?snapshot`, not a preview or draft read, no extra conditions) without
|
|
2952
3320
|
* populate: its only possible follow-up is relation visibility (asset views are checked on the rows).
|
|
@@ -2977,14 +3345,13 @@ const readConsistently = async (read, plan, options, finish) => {
|
|
|
2977
3345
|
if (!followsUp(read) || live) {
|
|
2978
3346
|
const env = environmentOf(read, db);
|
|
2979
3347
|
const { rows, meta } = await runHeadPage(compiled.rows, db);
|
|
2980
|
-
|
|
2981
|
-
if (meta && !needsFollowUpReads(env, read.model, rows, read.query, read.policy)) return finish({
|
|
3348
|
+
if (meta && !rowsNeedFollowUp(env, read, rows)) return finish({
|
|
2982
3349
|
env,
|
|
2983
3350
|
rows,
|
|
2984
3351
|
total: meta.total,
|
|
2985
3352
|
seq: seqOf(meta.seq)
|
|
2986
3353
|
});
|
|
2987
|
-
if (meta && live && !
|
|
3354
|
+
if (meta && live && !rowsNeedMedia(env, read, rows)) return finish({
|
|
2988
3355
|
env,
|
|
2989
3356
|
rows,
|
|
2990
3357
|
total: meta.total,
|
|
@@ -3004,7 +3371,7 @@ const readConsistently = async (read, plan, options, finish) => {
|
|
|
3004
3371
|
}, db);
|
|
3005
3372
|
};
|
|
3006
3373
|
const localesOf = (read) => readScopeFor(read.context.snapshot, read.model.definition, read.query.locale, { fallback: read.fallback });
|
|
3007
|
-
const toEntries = async (read, env, rows) => (await
|
|
3374
|
+
const toEntries = async (read, env, rows) => (await projectMemberRows(env, membersOf(read), rows, read.query)).map(({ row, data }) => ({
|
|
3008
3375
|
...systemAttributes(env, row),
|
|
3009
3376
|
...data
|
|
3010
3377
|
}));
|
|
@@ -3014,21 +3381,22 @@ const toEntries = async (read, env, rows) => (await projectRows(env, read.model,
|
|
|
3014
3381
|
*/
|
|
3015
3382
|
const readEntryPage = async (unloaded, options = { total: true }) => {
|
|
3016
3383
|
const read = await withSeoDefaults(unloaded);
|
|
3017
|
-
const { context, model
|
|
3384
|
+
const { context, model } = read;
|
|
3018
3385
|
const query = {
|
|
3019
3386
|
...read.query,
|
|
3020
3387
|
filter: await visibleReferenceFilter(environmentOf(read, context.db), read.query.filter)
|
|
3021
3388
|
};
|
|
3022
3389
|
const { limit, offset } = toLimitOffset(query.page, query.pageSize);
|
|
3023
|
-
const
|
|
3390
|
+
const members = membersOf(read);
|
|
3391
|
+
const family = familyQueryPlan(context, query, members);
|
|
3024
3392
|
const plan = {
|
|
3025
3393
|
siteId: context.site.id,
|
|
3026
|
-
|
|
3394
|
+
members: family.members,
|
|
3027
3395
|
source: read.source,
|
|
3028
3396
|
locales: localesOf(read),
|
|
3029
|
-
conditions: [...
|
|
3030
|
-
conditionsReadEntries:
|
|
3031
|
-
orderBy: compileOrderBy(
|
|
3397
|
+
conditions: [...family.conditions, ...read.conditions ?? []],
|
|
3398
|
+
conditionsReadEntries: Boolean(family.conditionsReadEntries) || (read.conditions?.length ?? 0) > 0,
|
|
3399
|
+
orderBy: compileOrderBy(sortOf(query, model, members)),
|
|
3032
3400
|
limit,
|
|
3033
3401
|
offset
|
|
3034
3402
|
};
|
|
@@ -3045,18 +3413,20 @@ const readEntryPage = async (unloaded, options = { total: true }) => {
|
|
|
3045
3413
|
/** One entry by ID in the delivery shape (undefined when it is not there for this read), and the sequence. */
|
|
3046
3414
|
const readOneEntry = async (unloaded, id, options = {}) => {
|
|
3047
3415
|
const read = await withSeoDefaults(unloaded);
|
|
3048
|
-
const { context
|
|
3416
|
+
const { context } = read;
|
|
3417
|
+
const { kinds: _kinds, ...query } = read.query;
|
|
3418
|
+
const family = familyQueryPlan(context, {
|
|
3419
|
+
...query,
|
|
3420
|
+
filter: null,
|
|
3421
|
+
search: null
|
|
3422
|
+
}, membersOf(read));
|
|
3049
3423
|
const plan = {
|
|
3050
3424
|
siteId: context.site.id,
|
|
3051
|
-
|
|
3425
|
+
members: family.members,
|
|
3052
3426
|
source: read.source,
|
|
3053
3427
|
locales: localesOf(read),
|
|
3054
3428
|
conditions: [
|
|
3055
|
-
...
|
|
3056
|
-
...query,
|
|
3057
|
-
filter: null,
|
|
3058
|
-
search: null
|
|
3059
|
-
}, policy),
|
|
3429
|
+
...family.conditions,
|
|
3060
3430
|
...read.conditions ?? [],
|
|
3061
3431
|
entryIdIs(id)
|
|
3062
3432
|
],
|
|
@@ -3094,6 +3464,10 @@ const assertMayReadDrafts = async (context, modelId, modelKey) => {
|
|
|
3094
3464
|
};
|
|
3095
3465
|
//#endregion
|
|
3096
3466
|
//#region ../../apps/api/src/services/contentDelivery.ts
|
|
3467
|
+
/**
|
|
3468
|
+
* The route's model and the caller's read policy (401/403 when it may not read it), and the kinds a read of
|
|
3469
|
+
* the route covers: every member of the model's family the caller may read (plan polymorphic-types).
|
|
3470
|
+
*/
|
|
3097
3471
|
const readableModel = async (context, modelKey) => {
|
|
3098
3472
|
const model = resolveModel(context.snapshot, modelKey);
|
|
3099
3473
|
const policy = await context.permissions.evaluate(context.actor, {
|
|
@@ -3103,7 +3477,8 @@ const readableModel = async (context, modelKey) => {
|
|
|
3103
3477
|
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}"`);
|
|
3104
3478
|
return {
|
|
3105
3479
|
model,
|
|
3106
|
-
policy
|
|
3480
|
+
policy,
|
|
3481
|
+
members: await familyMembersFor(context, model, policy)
|
|
3107
3482
|
};
|
|
3108
3483
|
};
|
|
3109
3484
|
/** Whether a read asks for drafts: GraphQL's option, or `?publicationState=draft`. */
|
|
@@ -3135,24 +3510,31 @@ const snapshotCheck = (query) => (current) => {
|
|
|
3135
3510
|
};
|
|
3136
3511
|
/**
|
|
3137
3512
|
* Counts a successful REST read for field usage (plan developer-face §5): once per request, after the
|
|
3138
|
-
* projection, by principal. Admin users and draft reads are never counted.
|
|
3513
|
+
* projection, by principal. Admin users and draft reads are never counted. A family read counts each kind's
|
|
3514
|
+
* fields under that kind, for the kinds the response carried (`shown`, by API ID); a read of one model
|
|
3515
|
+
* counts under it as always.
|
|
3139
3516
|
*/
|
|
3140
|
-
const recordUsage = async (context, options,
|
|
3517
|
+
const recordUsage = async (context, options, members, query, shown) => {
|
|
3141
3518
|
const principalKey = usagePrincipalKey(context.actor);
|
|
3142
3519
|
if (!options.usage || readsDrafts(query, options) || principalKey === null) return;
|
|
3143
|
-
const reads = await restFieldReads({
|
|
3144
|
-
snapshot: context.snapshot,
|
|
3145
|
-
model,
|
|
3146
|
-
policy,
|
|
3147
|
-
query,
|
|
3148
|
-
policyFor: (modelId) => context.permissions.evaluate(context.actor, {
|
|
3149
|
-
action: "read",
|
|
3150
|
-
modelId
|
|
3151
|
-
})
|
|
3152
|
-
});
|
|
3153
3520
|
options.usage.recordRequest(context.site.id, principalKey, query.snapshot ?? null);
|
|
3154
|
-
|
|
3521
|
+
const counted = members.length === 1 ? members : members.filter((member) => shown.has(member.model.definition.apiKey));
|
|
3522
|
+
for (const { model, policy } of counted) {
|
|
3523
|
+
const reads = await restFieldReads({
|
|
3524
|
+
snapshot: context.snapshot,
|
|
3525
|
+
model,
|
|
3526
|
+
policy,
|
|
3527
|
+
query,
|
|
3528
|
+
policyFor: (modelId) => context.permissions.evaluate(context.actor, {
|
|
3529
|
+
action: "read",
|
|
3530
|
+
modelId
|
|
3531
|
+
})
|
|
3532
|
+
});
|
|
3533
|
+
options.usage.recordFieldReads(context.site.id, principalKey, model.definition.id, reads);
|
|
3534
|
+
}
|
|
3155
3535
|
};
|
|
3536
|
+
/** The kinds (API IDs) among delivered entries. */
|
|
3537
|
+
const kindsOf = (entries) => new Set(entries.map((entry) => entry[MODEL_KEY]));
|
|
3156
3538
|
/** Records usage after the response when the caller can defer it (REST), else before returning. */
|
|
3157
3539
|
const afterRead = async (options, task) => {
|
|
3158
3540
|
if (options.defer) {
|
|
@@ -3195,8 +3577,11 @@ const assertItemParameters = (rawQuery, allowed) => {
|
|
|
3195
3577
|
};
|
|
3196
3578
|
/** `GET /api/content/:modelKey`: a page of a collection, or a singleton's one entry. */
|
|
3197
3579
|
const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
|
|
3198
|
-
const { model, policy } = await readableModel(context, modelKey);
|
|
3199
|
-
const query = parseQueryFor(context, model, policy, rawQuery,
|
|
3580
|
+
const { model, policy, members } = await readableModel(context, modelKey);
|
|
3581
|
+
const query = parseQueryFor(context, model, policy, rawQuery, {
|
|
3582
|
+
...DELIVERY_PARSE,
|
|
3583
|
+
members
|
|
3584
|
+
});
|
|
3200
3585
|
const drafts = readsDrafts(query, options);
|
|
3201
3586
|
const source = sourceOf(query, drafts);
|
|
3202
3587
|
await authorizeDrafts(context, model, modelKey, drafts);
|
|
@@ -3207,6 +3592,7 @@ const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
|
|
|
3207
3592
|
source,
|
|
3208
3593
|
model,
|
|
3209
3594
|
policy,
|
|
3595
|
+
members,
|
|
3210
3596
|
query,
|
|
3211
3597
|
fallback: options.fallback ?? true
|
|
3212
3598
|
}, {
|
|
@@ -3222,14 +3608,18 @@ const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
|
|
|
3222
3608
|
data: entry,
|
|
3223
3609
|
meta: metaOf(locale, snapshot, drafts)
|
|
3224
3610
|
};
|
|
3225
|
-
} else
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3611
|
+
} else {
|
|
3612
|
+
const kinds = kindsMetaOf(context.snapshot, model, members);
|
|
3613
|
+
result = {
|
|
3614
|
+
data: entries,
|
|
3615
|
+
meta: {
|
|
3616
|
+
...metaOf(locale, snapshot, drafts),
|
|
3617
|
+
pagination: paginationMeta(query.page, limit, total),
|
|
3618
|
+
...kinds ? { kinds } : {}
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
await afterRead(options, () => recordUsage(context, options, members, query, kindsOf(entries)));
|
|
3233
3623
|
return result;
|
|
3234
3624
|
};
|
|
3235
3625
|
/**
|
|
@@ -3238,8 +3628,11 @@ const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
|
|
|
3238
3628
|
*/
|
|
3239
3629
|
const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
|
|
3240
3630
|
assertItemParameters(rawQuery, ITEM_PARAMETERS);
|
|
3241
|
-
const { model, policy } = await readableModel(context, modelKey);
|
|
3242
|
-
const query = parseQueryFor(context, model, policy, rawQuery,
|
|
3631
|
+
const { model, policy, members } = await readableModel(context, modelKey);
|
|
3632
|
+
const query = parseQueryFor(context, model, policy, rawQuery, {
|
|
3633
|
+
...DELIVERY_PARSE,
|
|
3634
|
+
members
|
|
3635
|
+
});
|
|
3243
3636
|
const drafts = readsDrafts(query, options);
|
|
3244
3637
|
const source = sourceOf(query, drafts);
|
|
3245
3638
|
await authorizeDrafts(context, model, modelKey, drafts);
|
|
@@ -3248,11 +3641,12 @@ const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
|
|
|
3248
3641
|
source,
|
|
3249
3642
|
model,
|
|
3250
3643
|
policy,
|
|
3644
|
+
members,
|
|
3251
3645
|
query,
|
|
3252
3646
|
fallback: options.fallback ?? true
|
|
3253
3647
|
}, id, { check: snapshotCheck(query) });
|
|
3254
3648
|
if (!entry) throw entryNotFound(id);
|
|
3255
|
-
await afterRead(options, () => recordUsage(context, options,
|
|
3649
|
+
await afterRead(options, () => recordUsage(context, options, members, query, kindsOf([entry])));
|
|
3256
3650
|
return {
|
|
3257
3651
|
data: entry,
|
|
3258
3652
|
meta: metaOf(query.locale ?? context.snapshot.defaultLocale, query.snapshot ?? seq, drafts)
|
|
@@ -3310,6 +3704,17 @@ const liveAt = (dialect, siteId, seq) => sql`(
|
|
|
3310
3704
|
order by p.from_seq desc
|
|
3311
3705
|
limit 1
|
|
3312
3706
|
)`;
|
|
3707
|
+
/**
|
|
3708
|
+
* The model the entry had in the publication log as of `seq` (its latest period starting by then): an entry
|
|
3709
|
+
* that changed kind (plan polymorphic-types) is reported under the kind it was published as, not under
|
|
3710
|
+
* `entries.model_id`, its kind now.
|
|
3711
|
+
*/
|
|
3712
|
+
const modelAt = (dialect, siteId, seq) => sql`(
|
|
3713
|
+
select p.model_id from publication_log p
|
|
3714
|
+
where p.site_id = ${dialect.uuid(siteId)} and p.entry_id = g.entry_id and p.from_seq <= ${dialect.seq(seq)}
|
|
3715
|
+
order by p.from_seq desc
|
|
3716
|
+
limit 1
|
|
3717
|
+
)`;
|
|
3313
3718
|
const selectSnapshotDiffRows = async (params, executor) => {
|
|
3314
3719
|
const dialect = isMysql() ? MYSQL : isSqlite() ? SQLITE : POSTGRES;
|
|
3315
3720
|
const { from, to } = params;
|
|
@@ -3338,19 +3743,23 @@ const selectSnapshotDiffRows = async (params, executor) => {
|
|
|
3338
3743
|
from states s
|
|
3339
3744
|
where ${dialect.distinct(sql`s.from_revision_id`, sql`s.to_revision_id`)}
|
|
3340
3745
|
)
|
|
3341
|
-
select
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3746
|
+
select g.entry_id, ${modelAt(dialect, params.siteId, to)} as model_id, g.locales
|
|
3747
|
+
from (
|
|
3748
|
+
select ch.entry_id,
|
|
3749
|
+
${dialect.aggregate(sql`${dialect.object}(
|
|
3750
|
+
'locale', ch.locale,
|
|
3751
|
+
'change', ch.${dialect.change},
|
|
3752
|
+
'fromRevisionId', ch.from_revision_id,
|
|
3753
|
+
'toRevisionId', ch.to_revision_id
|
|
3754
|
+
)`)} as locales
|
|
3755
|
+
from changes ch
|
|
3756
|
+
join entries e on e.id = ch.entry_id
|
|
3757
|
+
where ${params.rowFilter ?? sql`true`}
|
|
3758
|
+
group by ch.entry_id
|
|
3759
|
+
order by ch.entry_id
|
|
3760
|
+
limit ${params.maxRows}
|
|
3761
|
+
) g
|
|
3762
|
+
order by g.entry_id
|
|
3354
3763
|
`.execute(executor)).rows.map((row) => typeof row.locales === "string" ? {
|
|
3355
3764
|
...row,
|
|
3356
3765
|
locales: JSON.parse(row.locales)
|
|
@@ -3422,7 +3831,7 @@ const readableModels = async (context) => {
|
|
|
3422
3831
|
modelId: definition.id
|
|
3423
3832
|
});
|
|
3424
3833
|
if (policy.allowed) readable.push({
|
|
3425
|
-
definition,
|
|
3834
|
+
definition: context.snapshot.resolve(definition.id) ?? definition,
|
|
3426
3835
|
rowFilter: policy.rowFilter,
|
|
3427
3836
|
readMask: everyRowReadMask(policy)
|
|
3428
3837
|
});
|
|
@@ -3509,7 +3918,7 @@ const listSnapshotChanges = async (context, query) => {
|
|
|
3509
3918
|
id: item.entryId,
|
|
3510
3919
|
modelId: item.modelId,
|
|
3511
3920
|
modelKey: model.apiKey,
|
|
3512
|
-
routeKey:
|
|
3921
|
+
routeKey: modelRouteOf(context.snapshot, model).key,
|
|
3513
3922
|
...describe(item),
|
|
3514
3923
|
locales: item.locales.map(({ locale, change, fromRevisionId, toRevisionId }) => ({
|
|
3515
3924
|
locale,
|
|
@@ -3901,7 +4310,7 @@ const guardingSkew = async (runtime, statement) => {
|
|
|
3901
4310
|
return await statement();
|
|
3902
4311
|
} catch (error) {
|
|
3903
4312
|
if (error instanceof AppError) throw error;
|
|
3904
|
-
const { getPendingMigrations } = await import("./migrator-
|
|
4313
|
+
const { getPendingMigrations } = await import("./migrator-BfVtncOo.js");
|
|
3905
4314
|
if ((await getPendingMigrations(runtime.db)).length > 0) throw new ShapioVersionSkewError$1(null, SHAPIO_VERSION$1, { cause: error });
|
|
3906
4315
|
throw error;
|
|
3907
4316
|
}
|
|
@@ -3979,30 +4388,29 @@ const openCallScope = async (runtime, { token, site: requestedKey }) => {
|
|
|
3979
4388
|
};
|
|
3980
4389
|
//#endregion
|
|
3981
4390
|
//#region ../../apps/api/src/delivery/request.ts
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
4391
|
+
/**
|
|
4392
|
+
* `content/<routeKey>[/<id>]`: the route key may be nested (`products/digital`, or one encoded segment), split
|
|
4393
|
+
* the way the server's URL rewrite splits it (helpers/contentRoutes.ts).
|
|
4394
|
+
*/
|
|
4395
|
+
const contentRouteOf = (segments) => {
|
|
4396
|
+
const split = splitContentPath(segments);
|
|
4397
|
+
if (!split || split.rest.length > 1) return;
|
|
4398
|
+
const [id] = split.rest;
|
|
4399
|
+
if (id === void 0) return {
|
|
4400
|
+
kind: "list",
|
|
4401
|
+
routeKey: split.routeKey
|
|
4402
|
+
};
|
|
4403
|
+
return id ? {
|
|
4404
|
+
kind: "item",
|
|
4405
|
+
routeKey: split.routeKey,
|
|
4406
|
+
id
|
|
4407
|
+
} : void 0;
|
|
3988
4408
|
};
|
|
3989
4409
|
const routeOf = (segments) => {
|
|
3990
4410
|
const [api, group, first, second, ...rest] = segments;
|
|
3991
|
-
if (api !== "api"
|
|
3992
|
-
if (group === "content" && first)
|
|
3993
|
-
|
|
3994
|
-
if (routeKey === void 0) return;
|
|
3995
|
-
if (second === void 0) return {
|
|
3996
|
-
kind: "list",
|
|
3997
|
-
routeKey
|
|
3998
|
-
};
|
|
3999
|
-
const id = decodeSegment(second);
|
|
4000
|
-
return id ? {
|
|
4001
|
-
kind: "item",
|
|
4002
|
-
routeKey,
|
|
4003
|
-
id
|
|
4004
|
-
} : void 0;
|
|
4005
|
-
}
|
|
4411
|
+
if (api !== "api") return;
|
|
4412
|
+
if (group === "content" && first) return contentRouteOf(segments.slice(2));
|
|
4413
|
+
if (rest.length > 0) return;
|
|
4006
4414
|
if (group === "site" && first === void 0) return { kind: "site" };
|
|
4007
4415
|
if (group === "snapshots" && second === void 0) {
|
|
4008
4416
|
if (first === "current") return { kind: "snapshotCurrent" };
|
|
@@ -4336,7 +4744,7 @@ const groupByRole = (grants) => {
|
|
|
4336
4744
|
* Every role's grants, held in memory and reloaded when `system_versions.permissions_version` moves.
|
|
4337
4745
|
* The version is checked on every lookup: read here, or read once by the request (`versions`, see
|
|
4338
4746
|
* `KnownVersions`). That durable check is what makes a permission change visible to every instance from the
|
|
4339
|
-
* next request (NOTIFY would only be an
|
|
4747
|
+
* next request (NOTIFY would only be an optimization). Lookups on the pool share one reload; a
|
|
4340
4748
|
* lookup inside a transaction reads (and, when stale, reloads) through that transaction alone, never
|
|
4341
4749
|
* through a reload running on another request's transaction.
|
|
4342
4750
|
*/
|
|
@@ -4470,7 +4878,8 @@ const createPermissionEvaluator = ({ grants, fields }) => {
|
|
|
4470
4878
|
const held = await grants.getGrants(roleIds, executor, versions);
|
|
4471
4879
|
if (held.length === 0) return DENIED_POLICY;
|
|
4472
4880
|
const modelFields = roles.audience === "delivery" ? await fields.getModelFields(request.modelId, executor, versions) : void 0;
|
|
4473
|
-
|
|
4881
|
+
const ancestors = await fields.getAncestors(request.modelId, executor, versions);
|
|
4882
|
+
return buildPolicy(held, request, roles.audience, modelFields, ancestors);
|
|
4474
4883
|
},
|
|
4475
4884
|
canPerform: async (principal, action, executor) => {
|
|
4476
4885
|
const roles = await rolesOf(principal, grants, executor, versions);
|
|
@@ -4500,20 +4909,18 @@ const findValue = async (key, trx = db) => (await trx.selectFrom("system_setting
|
|
|
4500
4909
|
* Uses the durable version check on every call (or the version the request read once, `versions`), so a
|
|
4501
4910
|
* field made non-public is hidden on every instance from the next request, whether or not it got the
|
|
4502
4911
|
* notification. Deprecated fields are reported as non-public: they are hidden from the API unless a role
|
|
4503
|
-
* names them explicitly. Components are not delivery models.
|
|
4912
|
+
* names them explicitly. Components are not delivery models. Fields are the effective ones (inherited first).
|
|
4504
4913
|
*/
|
|
4505
4914
|
const createSchemaFieldVisibility = (registry) => {
|
|
4506
|
-
const activeModel = async (modelId, executor, versions) =>
|
|
4507
|
-
const active = (await registry.getSnapshot(executor, versions)).byId.get(modelId);
|
|
4508
|
-
return active && active.definition.kind !== "component" ? active.definition : void 0;
|
|
4509
|
-
};
|
|
4915
|
+
const activeModel = async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).resolve(modelId);
|
|
4510
4916
|
return {
|
|
4511
4917
|
getModelFields: async (modelId, executor, versions) => (await activeModel(modelId, executor, versions))?.fields.map((field) => ({
|
|
4512
4918
|
id: field.id,
|
|
4513
4919
|
public: field.public && !field.deprecated
|
|
4514
4920
|
})),
|
|
4515
4921
|
hasModel: async (modelId, executor, versions) => await activeModel(modelId, executor, versions) !== void 0,
|
|
4516
|
-
getModelSite: async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).scopeOf(modelId) ?? null
|
|
4922
|
+
getModelSite: async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).scopeOf(modelId) ?? null,
|
|
4923
|
+
getAncestors: async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).ancestors(modelId)
|
|
4517
4924
|
};
|
|
4518
4925
|
};
|
|
4519
4926
|
//#endregion
|
|
@@ -4533,12 +4940,14 @@ const list = (executor = db) => executor.selectFrom("locales").select(COLUMNS).o
|
|
|
4533
4940
|
* On a collision the model whose API ID is the route key keeps it (a singleton keeps the URL it always
|
|
4534
4941
|
* had); otherwise the first definition does. Deterministic, so every instance serves the same model.
|
|
4535
4942
|
*/
|
|
4536
|
-
const indexByRouteKey = (definitions) => {
|
|
4943
|
+
const indexByRouteKey = (definitions, inheritance) => {
|
|
4537
4944
|
const modelsByRouteKey = /* @__PURE__ */ new Map();
|
|
4538
4945
|
const routeKeyCollisions = [];
|
|
4946
|
+
const byId = new Map(definitions.map((active) => [active.definition.id, active.definition]));
|
|
4539
4947
|
for (const active of definitions) {
|
|
4540
|
-
if (
|
|
4541
|
-
const
|
|
4948
|
+
if (!isModelDefinition(active.definition)) continue;
|
|
4949
|
+
const ancestors = inheritance.ancestors(active.definition.id).map((id) => byId.get(id)).filter((ancestor) => ancestor !== void 0 && isModelDefinition(ancestor));
|
|
4950
|
+
const routeKey = routePathOf(active.definition, ancestors);
|
|
4542
4951
|
const existing = modelsByRouteKey.get(routeKey);
|
|
4543
4952
|
if (!existing) {
|
|
4544
4953
|
modelsByRouteKey.set(routeKey, active);
|
|
@@ -4561,7 +4970,14 @@ const byIdPart = (version, definitions, locales) => {
|
|
|
4561
4970
|
const byId = new Map(definitions.map((active) => [active.definition.id, active]));
|
|
4562
4971
|
const all = definitions.map((active) => active.definition);
|
|
4563
4972
|
const dependents = /* @__PURE__ */ new Map();
|
|
4973
|
+
const index = createDefinitionIndex(all);
|
|
4564
4974
|
return {
|
|
4975
|
+
resolve: index.resolve,
|
|
4976
|
+
ancestors: index.ancestors,
|
|
4977
|
+
childrenOf: index.childrenOf,
|
|
4978
|
+
descendants: index.descendants,
|
|
4979
|
+
family: index.family,
|
|
4980
|
+
rootOf: index.rootOf,
|
|
4565
4981
|
version,
|
|
4566
4982
|
definitions,
|
|
4567
4983
|
byId,
|
|
@@ -4582,9 +4998,10 @@ const buildView = (network, siteId, definitions) => {
|
|
|
4582
4998
|
const modelsByApiKey = /* @__PURE__ */ new Map();
|
|
4583
4999
|
const componentsByApiKey = /* @__PURE__ */ new Map();
|
|
4584
5000
|
for (const active of definitions) (isComponentDefinition(active.definition) ? componentsByApiKey : modelsByApiKey).set(active.definition.apiKey, active);
|
|
4585
|
-
const
|
|
5001
|
+
const part = byIdPart(network.version, definitions, network.locales);
|
|
5002
|
+
const { modelsByRouteKey, routeKeyCollisions } = indexByRouteKey(definitions, part);
|
|
4586
5003
|
return Object.freeze({
|
|
4587
|
-
...
|
|
5004
|
+
...part,
|
|
4588
5005
|
siteId,
|
|
4589
5006
|
modelsByApiKey,
|
|
4590
5007
|
modelsByRouteKey,
|