@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/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-ajVNBhfa.js";
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, routeKeyOf, seoDefaultsForLocale, withDerivedPlural } from "@shapio/schema";
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.8";
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 toContentModel = (snapshot, active) => ({
147
- definition: active.definition,
148
- version: active.version,
149
- revisionId: active.revisionId,
150
- components: collectComponents(snapshot, active.definition)
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/usage/keys.ts
544
+ //#region ../../apps/api/src/content/family.ts
504
545
  /**
505
- * How delivery reads are attributed (plan developer-face §5). Delivery tokens are consumers, one key per
506
- * token; app users are counted together, and so are anonymous callers. Admin reads (admin users and
507
- * admin-scope API tokens), system work and previews are not consumers and are never counted.
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
- const APP_USERS_PRINCIPAL_KEY = "app_users";
510
- const ANONYMOUS_PRINCIPAL_KEY = "anonymous";
511
- /** `token:<id>`, `app_users`, `anonymous`, or null for principals whose reads are not counted. */
512
- const usagePrincipalKey = (principal) => {
513
- switch (principal.kind) {
514
- case "token": return principal.scope === "delivery" ? `token:${principal.tokenId}` : null;
515
- case "appUser": return APP_USERS_PRINCIPAL_KEY;
516
- case "anonymous": return ANONYMOUS_PRINCIPAL_KEY;
517
- default: return null;
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
- const grantsFor = (grants, action, modelId) => grants.filter((grant) => grant.action === action && (grant.modelId === null || grant.modelId === modelId));
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, modelId, seq }) => sql`
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 = ${modelId}
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, modelId, seq }) => sql`
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 = ${modelId}
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, modelId, seq }) => sql`
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 = ${modelId}
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
- modelId: modelIdLiteral(shown.modelId),
1754
+ modelIds: shown.members.map((member) => modelIdLiteral(member.modelId)),
1628
1755
  seq: shown.source.seq
1629
1756
  })})`;
1630
- const rowFilter = compileRowFilter(shown.rowFilter, shown.actor, dialect);
1631
- const where = whereOf({
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
- modelId: shown.modelId,
1768
+ model: modelIs(single.modelId),
1634
1769
  source: shown.source,
1635
1770
  locales: shown.locales,
1636
- conditions: [ids.matches(sql`h.entry_id`), ...rowFilter ? [rowFilter] : []],
1637
- orderBy: []
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, modelId, siteId, dialect) => {
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
- modelId: modelIdLiteral(modelId),
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 whereOf = (plan, table, dialect) => {
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(plan.siteId)}`,
1727
- sql`h.model_id = ${modelIdLiteral(plan.modelId)}`,
1728
- ...plan.source.kind === "heads" ? [sql`h.state = ${plan.source.state}`] : [],
1729
- ...localeConditions(plan.locales, table, plan.source, dialect),
1730
- ...plan.conditions
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 parts every statement over a plan's heads shares. */
1745
- const headQueryParts = (plan, dialect) => {
1746
- const { cte, table } = sourceOf$1(plan.source, plan.modelId, plan.siteId, dialect);
1747
- const where = whereOf(plan, table, dialect);
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
- const orderBy = plan.orderBy.length > 0 ? sql` order by ${sql.join([...plan.orderBy])}` : sql``;
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
- prefix: cte ?? sql``,
1914
+ columns: HEAD_COLUMNS,
1754
1915
  rows: sql`${from}${orderBy}${limit}${offset}`,
1755
- countFrom: countFrom(plan, table, where, from)
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, countFrom: counted } = headQueryParts(plan, dialect);
1967
+ const { prefix, columns, rows, count } = headQueryParts(plan, dialect);
1761
1968
  return {
1762
- rows: sql`${prefix}select ${HEAD_COLUMNS}
1969
+ rows: sql`${prefix}select ${columns}
1763
1970
  ${rows}`,
1764
- count: sql`${prefix}select count(*) as total ${counted}`
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, countFrom: counted } = headQueryParts(plan, dialect);
1776
- const total = options.total ? sql`(select count(*) ${counted})` : sql`null`;
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 ${HEAD_COLUMNS}, ${total} as page_total, ${seq} as page_seq
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 termSql = (term, dialect) => {
1811
- const nullable = term.target.kind === "field";
1812
- const expression = term.target.kind === "system" ? sql.ref(SYSTEM_SORT_COLUMNS[term.target.name]) : fieldValueExpression(term.target.field.id, term.target.field.type, dialect);
1813
- if (dialect.sortTerm) return dialect.sortTerm(expression, term.direction, nullable);
1814
- return sql`${expression} ${dialect.sortDirection(term.direction, nullable)}`;
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
- const compileOrderBy = (terms, dialect = contentDialect()) => [...terms.map((term) => termSql(term, dialect)), sql`h.entry_id asc`];
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 policy = await env.policyFor(env, target.definition.id);
2122
- const targetRows = await env.fetch(env, target, policy, idsOf$1(rows, fieldId));
2123
- for (const { row, data } of await env.project(env, target, policy, targetRows, {
2124
- fields: null,
2125
- populate: subtree
2126
- })) byId.set(row.entry_id, {
2127
- ...env.system(env, row),
2128
- ...data
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
- * Heads of the given entries that the policy lets the caller read, served per the locale chain (or per
2252
- * `locales` when given: GraphQL's `fallback: false` and `localizations`).
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 fetchHeadsByIds = async (env, model, policy, ids, locales) => {
2255
- if (ids.length === 0 || !policy.allowed) return [];
2256
- const rowFilter = compileRowFilter(policy.rowFilter, env.actor);
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
- modelId: model.definition.id,
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), ...rowFilter ? [rowFilter] : []],
2508
+ conditions: [entryIdIn(ids), ...plan.conditions],
2263
2509
  orderBy: []
2264
2510
  });
2265
- return runHeadQuery(rows, env.executor);
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). Admin reads see every ID (null). The heads read are returned for populate.
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 policy = await readPolicy(env, targetModelId);
2284
- const found = await fetchHeadsByIds(env, target, policy, [...ids]);
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
- rows: found
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
- * `fetchHeadsByIds` for populate, answered from the heads relation visibility already read when it asked for
2298
- * every one of these IDs under the same policy and locale scope (populate's default), else from the database.
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, policy, ids) => {
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 known.rows.filter((row) => wanted.has(row.entry_id));
2554
+ return {
2555
+ members: known.heads.members,
2556
+ rows: known.heads.rows.filter((row) => wanted.has(row.entry_id))
2557
+ };
2305
2558
  }
2306
- return fetchHeadsByIds(env, model, policy, ids);
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
- /** System attributes first, then the readable, selected fields by API key. */
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
- const policy = await readPolicy(env, field.settings.target);
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 rows = await fetchHeadsByIds(env, target, policy, ids);
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
- modelId: target.definition.id,
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" ? !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 });
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
- const filters = tree.filters;
2798
- if (filters !== void 0 && !isTree(filters)) throw queryInvalid("filters needs field conditions, e.g. filters[title][$eq]=Hello");
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
- /** Filters, search and row filter of a parsed query as SQL conditions. */
2845
- /** Whether `queryConditions` names an `entries` column (`HeadQueryPlan.conditionsReadEntries`). */
2846
- const queryReadsEntries = (query, policy) => query.filter !== null && filterReadsEntries(query.filter) || query.author !== void 0 || policy.rowFilter !== null;
2847
- const queryConditions = (context, query, policy) => {
2848
- const rowFilter = compileRowFilter(policy.rowFilter, context.actor);
2849
- return [
2850
- ...query.filter ? [compileFilter(query.filter)] : [],
2851
- ...query.search ? [compileSearch(query.search.field.id, query.search.text)] : [],
2852
- ...query.status ? [compileStatusCondition(query.status)] : [],
2853
- ...query.author ? [compileAuthorCondition(query.author)] : [],
2854
- ...rowFilter ? [rowFilter] : []
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
- const parseQueryFor = (context, model, policy, rawQuery, options) => parseContentQuery(parseQueryTree(rawQuery), {
2858
- model: model.definition,
2859
- locales: context.snapshot.locales.map((locale) => locale.code),
2860
- isReadable: (field) => maskAllows(policy.readMask, field),
2861
- isQueryable: (field) => maskAllows(everyRowReadMask(policy), field),
2862
- allowSnapshot: options.allowSnapshot,
2863
- allowAdminFilters: options.allowAdminFilters ?? false,
2864
- allowRichText: options.allowRichText ?? false,
2865
- allowSeo: options.allowSeo ?? false,
2866
- allowPublicationState: options.allowPublicationState ?? false,
2867
- resolveModel: (modelId) => resolveModelById(context.snapshot, modelId)?.definition
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 || selectFields(read.model, read.policy.readMask, read.query.fields).some((field) => field.type === "relation" || field.type === "media" || Boolean(read.seo?.imageId) && isSeoField(field));
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
- const fields = selectFields(read.model, read.policy.readMask, read.query.fields);
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 && !needsMediaReads(env, read.model, rows, fields)) return finish({
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 projectRows(env, read.model, read.policy, rows, read.query)).map(({ row, data }) => ({
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, policy } = read;
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 sort = query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, (field) => maskAllows(everyRowReadMask(policy), field));
3390
+ const members = membersOf(read);
3391
+ const family = familyQueryPlan(context, query, members);
3024
3392
  const plan = {
3025
3393
  siteId: context.site.id,
3026
- modelId: model.definition.id,
3394
+ members: family.members,
3027
3395
  source: read.source,
3028
3396
  locales: localesOf(read),
3029
- conditions: [...queryConditions(context, query, policy), ...read.conditions ?? []],
3030
- conditionsReadEntries: queryReadsEntries(query, policy) || (read.conditions?.length ?? 0) > 0,
3031
- orderBy: compileOrderBy(sort),
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, model, policy, query } = read;
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
- modelId: model.definition.id,
3425
+ members: family.members,
3052
3426
  source: read.source,
3053
3427
  locales: localesOf(read),
3054
3428
  conditions: [
3055
- ...queryConditions(context, {
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, model, policy, query) => {
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
- options.usage.recordFieldReads(context.site.id, principalKey, model.definition.id, reads);
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, DELIVERY_PARSE);
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 result = {
3226
- data: entries,
3227
- meta: {
3228
- ...metaOf(locale, snapshot, drafts),
3229
- pagination: paginationMeta(query.page, limit, total)
3230
- }
3231
- };
3232
- await afterRead(options, () => recordUsage(context, options, model, policy, query));
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, DELIVERY_PARSE);
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, model, policy, query));
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 ch.entry_id, e.model_id,
3342
- ${dialect.aggregate(sql`${dialect.object}(
3343
- 'locale', ch.locale,
3344
- 'change', ch.${dialect.change},
3345
- 'fromRevisionId', ch.from_revision_id,
3346
- 'toRevisionId', ch.to_revision_id
3347
- )`)} as locales
3348
- from changes ch
3349
- join entries e on e.id = ch.entry_id
3350
- where ${params.rowFilter ?? sql`true`}
3351
- group by ch.entry_id, e.model_id
3352
- order by ch.entry_id
3353
- limit ${params.maxRows}
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: routeKeyOf(model),
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-CO5sXosL.js");
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
- const decodeSegment = (segment) => {
3983
- try {
3984
- return decodeURIComponent(segment);
3985
- } catch {
3986
- return;
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" || rest.length > 0) return;
3992
- if (group === "content" && first) {
3993
- const routeKey = decodeSegment(first);
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 optimisation). Lookups on the pool share one reload; a
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
- return buildPolicy(held, request, roles.audience, modelFields);
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 (isComponentDefinition(active.definition)) continue;
4541
- const routeKey = routeKeyOf(active.definition);
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 { modelsByRouteKey, routeKeyCollisions } = indexByRouteKey(definitions);
5001
+ const part = byIdPart(network.version, definitions, network.locales);
5002
+ const { modelsByRouteKey, routeKeyCollisions } = indexByRouteKey(definitions, part);
4586
5003
  return Object.freeze({
4587
- ...byIdPart(network.version, definitions, network.locales),
5004
+ ...part,
4588
5005
  siteId,
4589
5006
  modelsByApiKey,
4590
5007
  modelsByRouteKey,