@elytracms/studio 0.0.73 → 0.0.75

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.
Files changed (50) hide show
  1. package/convex/cms.ts +67 -3
  2. package/convex/schema.ts +9 -0
  3. package/dist/components/app-views/default-modules.js +1 -1
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/cms/cms-labels.d.ts +6 -0
  6. package/dist/components/cms/cms-labels.js +21 -1
  7. package/dist/components/cms/cms-labels.js.map +1 -1
  8. package/dist/components/cms/cms-store.d.ts +33 -1
  9. package/dist/components/cms/cms-store.js +115 -18
  10. package/dist/components/cms/cms-store.js.map +1 -1
  11. package/dist/components/cms/collection-table.js +89 -11
  12. package/dist/components/cms/collection-table.js.map +1 -1
  13. package/dist/components/cms/column-width.d.ts +16 -0
  14. package/dist/components/cms/column-width.js +26 -0
  15. package/dist/components/cms/column-width.js.map +1 -1
  16. package/dist/components/cms/doc-state.d.ts +46 -0
  17. package/dist/components/cms/doc-state.js +47 -1
  18. package/dist/components/cms/doc-state.js.map +1 -1
  19. package/dist/components/cms/versions-model.d.ts +13 -0
  20. package/dist/components/cms/versions-model.js +44 -0
  21. package/dist/components/cms/versions-model.js.map +1 -1
  22. package/dist/components/cms/versions-pane.js +27 -6
  23. package/dist/components/cms/versions-pane.js.map +1 -1
  24. package/dist/components/cms/view-bar.js +16 -6
  25. package/dist/components/cms/view-bar.js.map +1 -1
  26. package/dist/components/collection-view/cms-collection-view-source.js +8 -0
  27. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  28. package/dist/lib/cms-views/view-model.d.ts +80 -12
  29. package/dist/lib/cms-views/view-model.js +170 -44
  30. package/dist/lib/cms-views/view-model.js.map +1 -1
  31. package/dist/lib/collection-view/collection-view-source.d.ts +29 -0
  32. package/dist/lib/collection-view/collection-view-source.js +15 -0
  33. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  34. package/dist/lib/i18n/messages.de.js +8 -0
  35. package/dist/lib/i18n/messages.de.js.map +1 -1
  36. package/dist/lib/i18n/messages.en.d.ts +5 -0
  37. package/dist/lib/i18n/messages.en.js +11 -0
  38. package/dist/lib/i18n/messages.en.js.map +1 -1
  39. package/dist/lib/operations/session-hydration.d.ts +19 -0
  40. package/dist/lib/operations/session-hydration.js +9 -0
  41. package/dist/lib/operations/session-hydration.js.map +1 -1
  42. package/dist/lib/persistence/convex-adapter.js +9 -1
  43. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  44. package/dist/ui/index.d.ts +1 -0
  45. package/dist/ui/index.js +1 -0
  46. package/dist/ui/index.js.map +1 -1
  47. package/dist/ui/status-dot.d.ts +50 -0
  48. package/dist/ui/status-dot.js +38 -0
  49. package/dist/ui/status-dot.js.map +1 -0
  50. package/package.json +2 -2
@@ -76,6 +76,74 @@ export type TableViewState = z.infer<typeof tableViewStateSchema>;
76
76
  * collide with a schema field name (`fieldDefSchema` names are identifiers).
77
77
  */
78
78
  export declare const STATE_FIELD = "$state";
79
+ /**
80
+ * Reserved pseudo-COLUMN keys — the columns-axis siblings of the filter-axis
81
+ * {@link STATE_FIELD}. `$`-prefixed so they can never collide with a schema field
82
+ * name; they participate in a view's `columns` list AND the Display picker exactly
83
+ * like a schema field (orderable / hideable / includable), but they are NOT drawn
84
+ * from `collection.fields` — the column UNIVERSE is `[...schemaFields, ...pseudoColumns]`.
85
+ *
86
+ * - `$state` — publication status. A reserved `$`-key, but NO LONGER a reorderable
87
+ * column: EC-448 promoted it to a FIXED structural leading column (checkbox →
88
+ * status-dot → title), so it is NOT emitted by {@link pseudoColumns} and never
89
+ * participates in the reorderable columns/hidden/order model. It stays a reserved
90
+ * key (filter + sort) and {@link isPseudoColumn} still recognizes it, so an authored
91
+ * view listing `$state` is a harmless no-op (see {@link resolveConfigColumns}).
92
+ * - `$url` — the derived delivery URL (routable collections only; NON-sortable).
93
+ * - `$updatedAt` — the record's last-write timestamp.
94
+ * - `$publishedAt` — the record's FIRST-publish instant (EC-447; opt-in, HIDDEN by
95
+ * default). Now materialized end-to-end (`cmsDocuments.publishedAt` → source
96
+ * `timestamps.publishedAt`) and emitted by {@link pseudoColumns} when the source
97
+ * both materializes timestamps and has a publish lifecycle.
98
+ */
99
+ export declare const URL_FIELD = "$url";
100
+ export declare const PUBLISHED_AT_FIELD = "$publishedAt";
101
+ export declare const UPDATED_AT_FIELD = "$updatedAt";
102
+ /** Is `name` a reserved pseudo-column (a `$`-key, not a schema field)? */
103
+ export declare function isPseudoColumn(name: string): boolean;
104
+ /**
105
+ * Capability signals that make a pseudo-column applicable to a collection/source
106
+ * (EC-445). Supplied by the caller from the portable source — the model stays pure.
107
+ */
108
+ export interface PseudoColumnOptions {
109
+ /** The source materializes per-document timestamps → `$updatedAt` applies. */
110
+ timestamps?: boolean;
111
+ /**
112
+ * The collection has a publish lifecycle. Gates `$publishedAt` (with `timestamps`).
113
+ * EC-448: it no longer gates a reorderable `$state` column — `$state` is the FIXED
114
+ * structural status column, gated by the same signal at the render site, not here.
115
+ */
116
+ publishing?: boolean;
117
+ /** The collection resolves delivery URLs → `$url` applies (mirrors `urlByDocId`). */
118
+ routable?: boolean;
119
+ }
120
+ /**
121
+ * The reserved REORDERABLE pseudo-columns that APPLY to a collection, in their default
122
+ * appended order (EC-445/EC-447): `$updatedAt`, `$publishedAt`, then `$url`. `$url` is
123
+ * gated on `routable` (a non-routable collection has no URL column, not an empty one),
124
+ * `$updatedAt` on `timestamps`. `$publishedAt` (EC-447) needs BOTH `timestamps` (its
125
+ * materialized value) AND `publishing` (a publish lifecycle to have a publish date) —
126
+ * it is OPT-IN, HIDDEN by default (`DEFAULT_HIDDEN_PSEUDO_COLUMNS`), so it joins the
127
+ * universe/picker without appearing in the baseline lens.
128
+ *
129
+ * NOTE (EC-448): `$state` is NO LONGER emitted here. It became a FIXED structural
130
+ * leading column (checkbox → status-dot → title), rendered by `collection-table.tsx`
131
+ * regardless of the reorderable model — so it is never in the order/hide/picker set,
132
+ * even though `publishing` is what gates BOTH it and the status column's presence.
133
+ * `opts.publishing` therefore only affects `$publishedAt` here. Pure — a caller
134
+ * (`pseudoColumnsForSource`) supplies the signals from the source.
135
+ */
136
+ export declare function pseudoColumns(collection: CollectionDef, opts?: PseudoColumnOptions): string[];
137
+ /**
138
+ * The per-document metadata accessors the pseudo-DATE columns sort by (EC-445) — the
139
+ * columns-axis analogue of {@link IsPublished}. Threaded into the sort machinery so a
140
+ * `$updatedAt`/`$publishedAt` sort orders by the source's materialized timestamp; a
141
+ * missing accessor (fixtures/tests) sorts those rows last. `$url` is derived → never sorted.
142
+ */
143
+ export interface PseudoColumnMeta {
144
+ updatedAt?(doc: CmsDocument): string | undefined;
145
+ publishedAt?(doc: CmsDocument): string | undefined;
146
+ }
79
147
  /** One field the filter builder may offer. */
80
148
  export interface FilterableFieldOption {
81
149
  /** Clause field key: a schema field name, or {@link STATE_FIELD}. */
@@ -173,7 +241,7 @@ export declare function clauseMatches(collection: CollectionDef, doc: CmsDocumen
173
241
  * content accessors, the *table* may sort any rendered column — sorting is a
174
242
  * presentation concern here; the `filterable` flag governs filters.
175
243
  */
176
- export declare function sortViewDocuments(collection: CollectionDef, docs: readonly CmsDocument[], localeConfig: LocaleConfig, sort: ViewSort | null, isPublished?: IsPublished): CmsDocument[];
244
+ export declare function sortViewDocuments(collection: CollectionDef, docs: readonly CmsDocument[], localeConfig: LocaleConfig, sort: ViewSort | null, isPublished?: IsPublished, meta?: PseudoColumnMeta): CmsDocument[];
177
245
  /** The filtered + sorted documents of a view, plus any clause issues. */
178
246
  export interface AppliedViewState {
179
247
  docs: CmsDocument[];
@@ -185,10 +253,10 @@ export interface AppliedViewState {
185
253
  * ones, sort. Invalid clauses never filter silently — they come back as
186
254
  * issues for the UI to surface.
187
255
  */
188
- export declare function applyViewState(collection: CollectionDef, docs: readonly CmsDocument[], localeConfig: LocaleConfig, state: TableViewState, isPublished?: IsPublished): AppliedViewState;
256
+ export declare function applyViewState(collection: CollectionDef, docs: readonly CmsDocument[], localeConfig: LocaleConfig, state: TableViewState, isPublished?: IsPublished, meta?: PseudoColumnMeta): AppliedViewState;
189
257
  /** The id of the synthesized built-in "All entries" baseline view (EC-444). */
190
258
  export declare const ALL_VIEW_ID = "all";
191
- export declare function defaultViewState(collection: CollectionDef): TableViewState;
259
+ export declare function defaultViewState(collection: CollectionDef, pseudo?: readonly string[]): TableViewState;
192
260
  /**
193
261
  * Resolve an authored VISIBLE column list (EC-444) into the internal two-axis
194
262
  * `{columns, hidden}` table state. The authored list is exactly the columns shown,
@@ -198,7 +266,7 @@ export declare function defaultViewState(collection: CollectionDef): TableViewSt
198
266
  * inherits the collection's default lens (all scalars visible, composites hidden).
199
267
  * Unknown names in the authored list (a field the schema no longer has) are dropped.
200
268
  */
201
- export declare function resolveConfigColumns(collection: CollectionDef, visible: readonly string[] | undefined): Pick<TableViewState, 'columns' | 'hidden'>;
269
+ export declare function resolveConfigColumns(collection: CollectionDef, visible: readonly string[] | undefined, pseudo?: readonly string[]): Pick<TableViewState, 'columns' | 'hidden'>;
202
270
  /** The reserved `views[]` id whose entry OVERRIDES the synthesized baseline view (EC-444). */
203
271
  export declare const DEFAULT_VIEW_OVERRIDE_ID = "default";
204
272
  /**
@@ -259,7 +327,7 @@ export interface ResolvedView {
259
327
  * column. Config views are shared and change only in
260
328
  * code — the runtime never mutates them.
261
329
  */
262
- export declare function configViewsFor(collection: CollectionDef): ResolvedView[];
330
+ export declare function configViewsFor(collection: CollectionDef, pseudo?: readonly string[]): ResolvedView[];
263
331
  /**
264
332
  * Does the collection declare a baseline-override view (a config view whose id is
265
333
  * {@link DEFAULT_VIEW_OVERRIDE_ID})? When true the synthesized "All entries" lens is
@@ -274,7 +342,7 @@ export declare function hasDefaultViewOverride(collection: CollectionDef): boole
274
342
  * `synthesizedBaseline` the caller passes (the studio's translated "All entries"
275
343
  * lens). Pure — the store supplies the synthesized baseline (i18n-bound `name`).
276
344
  */
277
- export declare function resolveCollectionViews(collection: CollectionDef, synthesizedBaseline: ResolvedView): ResolvedView[];
345
+ export declare function resolveCollectionViews(collection: CollectionDef, synthesizedBaseline: ResolvedView, pseudo?: readonly string[]): ResolvedView[];
278
346
  /**
279
347
  * The ids of config views that still use the DEPRECATED `default` flag without the
280
348
  * new `initial` (EC-444). Drives the one-release deprecation warning (mirrors the
@@ -325,9 +393,9 @@ export interface ResolvedColumn {
325
393
  * or hidden" — order is preserved across hide/show because visibility lives in
326
394
  * `hidden`, never in the order.
327
395
  */
328
- export declare function resolveColumns(collection: CollectionDef, state: TableViewState): ResolvedColumn[];
396
+ export declare function resolveColumns(collection: CollectionDef, state: TableViewState, pseudo?: readonly string[]): ResolvedColumn[];
329
397
  /** The visible columns of a view, in render order (EC-213). */
330
- export declare function visibleColumns(collection: CollectionDef, state: TableViewState): string[];
398
+ export declare function visibleColumns(collection: CollectionDef, state: TableViewState, pseudo?: readonly string[]): string[];
331
399
  /**
332
400
  * The title/link column — the document IDENTITY column (EC-242): the schema title
333
401
  * field, shown as the leftmost linked cell whose content is `resolveDocumentTitle`
@@ -337,7 +405,7 @@ export declare function visibleColumns(collection: CollectionDef, state: TableVi
337
405
  * the first visible column, then the first field, then `id`, when no title field
338
406
  * is resolvable (e.g. a function title with no backing `title` field).
339
407
  */
340
- export declare function primaryColumn(collection: CollectionDef, state: TableViewState): string;
408
+ export declare function primaryColumn(collection: CollectionDef, state: TableViewState, pseudo?: readonly string[]): string;
341
409
  /**
342
410
  * The canonical, schema-resolved form of a view state — full column order with
343
411
  * `hidden` pruned to live fields (EC-213). Two states that render identically
@@ -345,12 +413,12 @@ export declare function primaryColumn(collection: CollectionDef, state: TableVie
345
413
  * reconciliation or a stale `hidden` entry (only a real filter/sort/visibility/
346
414
  * order change counts as "unsaved").
347
415
  */
348
- export declare function canonicalViewState(collection: CollectionDef, state: TableViewState): TableViewState;
416
+ export declare function canonicalViewState(collection: CollectionDef, state: TableViewState, pseudo?: readonly string[]): TableViewState;
349
417
  /**
350
418
  * Toggle a column's visibility — position is preserved (EC-213): visibility
351
419
  * lives in `hidden`, the order in `columns` is untouched. Columns the saved
352
420
  * order predates are pinned into `columns` first so their place is stable.
353
421
  */
354
- export declare function toggleColumn(collection: CollectionDef, state: TableViewState, field: string): TableViewState;
422
+ export declare function toggleColumn(collection: CollectionDef, state: TableViewState, field: string, pseudo?: readonly string[]): TableViewState;
355
423
  /** Move a column one position left (-1) or right (+1) in the full column order. */
356
- export declare function moveColumn(collection: CollectionDef, state: TableViewState, field: string, delta: -1 | 1): TableViewState;
424
+ export declare function moveColumn(collection: CollectionDef, state: TableViewState, field: string, delta: -1 | 1, pseudo?: readonly string[]): TableViewState;
@@ -55,6 +55,83 @@ export const tableViewStateSchema = z.object({
55
55
  */
56
56
  export const STATE_FIELD = '$state';
57
57
  const STATE_VALUES = ['draft', 'published'];
58
+ // --- Reserved pseudo-COLUMNS (EC-445) ------------------------------------------
59
+ /**
60
+ * Reserved pseudo-COLUMN keys — the columns-axis siblings of the filter-axis
61
+ * {@link STATE_FIELD}. `$`-prefixed so they can never collide with a schema field
62
+ * name; they participate in a view's `columns` list AND the Display picker exactly
63
+ * like a schema field (orderable / hideable / includable), but they are NOT drawn
64
+ * from `collection.fields` — the column UNIVERSE is `[...schemaFields, ...pseudoColumns]`.
65
+ *
66
+ * - `$state` — publication status. A reserved `$`-key, but NO LONGER a reorderable
67
+ * column: EC-448 promoted it to a FIXED structural leading column (checkbox →
68
+ * status-dot → title), so it is NOT emitted by {@link pseudoColumns} and never
69
+ * participates in the reorderable columns/hidden/order model. It stays a reserved
70
+ * key (filter + sort) and {@link isPseudoColumn} still recognizes it, so an authored
71
+ * view listing `$state` is a harmless no-op (see {@link resolveConfigColumns}).
72
+ * - `$url` — the derived delivery URL (routable collections only; NON-sortable).
73
+ * - `$updatedAt` — the record's last-write timestamp.
74
+ * - `$publishedAt` — the record's FIRST-publish instant (EC-447; opt-in, HIDDEN by
75
+ * default). Now materialized end-to-end (`cmsDocuments.publishedAt` → source
76
+ * `timestamps.publishedAt`) and emitted by {@link pseudoColumns} when the source
77
+ * both materializes timestamps and has a publish lifecycle.
78
+ */
79
+ export const URL_FIELD = '$url';
80
+ export const PUBLISHED_AT_FIELD = '$publishedAt';
81
+ export const UPDATED_AT_FIELD = '$updatedAt';
82
+ /**
83
+ * Every reserved pseudo-column key. `$state` is included (it is still a reserved
84
+ * `$`-key: the filter/sort axis + the FIXED structural status column, EC-448) so
85
+ * {@link isPseudoColumn} never mistakes it for a schema field — but it is NOT a
86
+ * reorderable column, so it is absent from the `pseudo` sets {@link pseudoColumns}
87
+ * emits into the order/hide/picker model.
88
+ */
89
+ const PSEUDO_COLUMN_FIELDS = [
90
+ STATE_FIELD,
91
+ URL_FIELD,
92
+ PUBLISHED_AT_FIELD,
93
+ UPDATED_AT_FIELD,
94
+ ];
95
+ /** Is `name` a reserved pseudo-column (a `$`-key, not a schema field)? */
96
+ export function isPseudoColumn(name) {
97
+ return PSEUDO_COLUMN_FIELDS.includes(name);
98
+ }
99
+ /**
100
+ * Pseudo-columns HIDDEN by default (opt-in): the extra publish timestamp. Everything
101
+ * else applicable (`$updatedAt`/`$state`/`$url`) is on by default (owner, 2026-07-17).
102
+ */
103
+ const DEFAULT_HIDDEN_PSEUDO_COLUMNS = new Set([PUBLISHED_AT_FIELD]);
104
+ /**
105
+ * The reserved REORDERABLE pseudo-columns that APPLY to a collection, in their default
106
+ * appended order (EC-445/EC-447): `$updatedAt`, `$publishedAt`, then `$url`. `$url` is
107
+ * gated on `routable` (a non-routable collection has no URL column, not an empty one),
108
+ * `$updatedAt` on `timestamps`. `$publishedAt` (EC-447) needs BOTH `timestamps` (its
109
+ * materialized value) AND `publishing` (a publish lifecycle to have a publish date) —
110
+ * it is OPT-IN, HIDDEN by default (`DEFAULT_HIDDEN_PSEUDO_COLUMNS`), so it joins the
111
+ * universe/picker without appearing in the baseline lens.
112
+ *
113
+ * NOTE (EC-448): `$state` is NO LONGER emitted here. It became a FIXED structural
114
+ * leading column (checkbox → status-dot → title), rendered by `collection-table.tsx`
115
+ * regardless of the reorderable model — so it is never in the order/hide/picker set,
116
+ * even though `publishing` is what gates BOTH it and the status column's presence.
117
+ * `opts.publishing` therefore only affects `$publishedAt` here. Pure — a caller
118
+ * (`pseudoColumnsForSource`) supplies the signals from the source.
119
+ */
120
+ export function pseudoColumns(collection, opts = {}) {
121
+ void collection; // reserved for future per-collection gating; kept for API symmetry
122
+ const cols = [];
123
+ if (opts.timestamps)
124
+ cols.push(UPDATED_AT_FIELD);
125
+ // EC-447: a publish date needs both a timestamps source (the value) AND a publish
126
+ // lifecycle (the meaning). Hidden by default → surfaced via the Display picker / a
127
+ // config view's `columns`, never a blank column on a rail that can't publish.
128
+ if (opts.timestamps && opts.publishing)
129
+ cols.push(PUBLISHED_AT_FIELD);
130
+ // EC-448: $state is structural (fixed leading column), not a reorderable pseudo-column.
131
+ if (opts.routable)
132
+ cols.push(URL_FIELD);
133
+ return cols;
134
+ }
58
135
  export const STATE_OPTION = {
59
136
  field: STATE_FIELD,
60
137
  label: 'State',
@@ -267,9 +344,17 @@ function rawValueOf(collection, doc, field, localeConfig, isPublished) {
267
344
  return resolveField(collection, doc, field, locale, localeConfig).value;
268
345
  }
269
346
  /** The scalar a document exposes for a SORT field, locale-resolved. */
270
- function scalarOf(collection, doc, field, localeConfig, isPublished) {
347
+ function scalarOf(collection, doc, field, localeConfig, isPublished, meta = {}) {
271
348
  if (field === STATE_FIELD)
272
349
  return isPublished(doc) ? 'published' : 'draft';
350
+ // EC-445: the pseudo-DATE columns sort by the source's materialized ISO timestamp
351
+ // (ISO strings order lexicographically = chronologically); `$url` is derived → not sortable.
352
+ if (field === UPDATED_AT_FIELD)
353
+ return meta.updatedAt?.(doc) ?? null;
354
+ if (field === PUBLISHED_AT_FIELD)
355
+ return meta.publishedAt?.(doc) ?? null;
356
+ if (field === URL_FIELD)
357
+ return null;
273
358
  if (!collection.fields.some((f) => f.name === field))
274
359
  return null;
275
360
  const locale = documentDefaultLocale(doc, localeConfig);
@@ -292,10 +377,13 @@ export function clauseMatches(collection, doc, localeConfig, clause, isPublished
292
377
  * content accessors, the *table* may sort any rendered column — sorting is a
293
378
  * presentation concern here; the `filterable` flag governs filters.
294
379
  */
295
- export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished) {
380
+ export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished, meta = {}) {
296
381
  if (sort === null)
297
382
  return [...docs];
298
- const positions = new Map(docs.map((doc) => [doc.id, scalarOf(collection, doc, sort.field, localeConfig, isPublished)]));
383
+ const positions = new Map(docs.map((doc) => [
384
+ doc.id,
385
+ scalarOf(collection, doc, sort.field, localeConfig, isPublished, meta),
386
+ ]));
299
387
  return [...docs].sort((a, b) => compareListEntries({ id: a.id, sortValue: positions.get(a.id) ?? null }, { id: b.id, sortValue: positions.get(b.id) ?? null }, sort.dir));
300
388
  }
301
389
  /**
@@ -303,13 +391,13 @@ export function sortViewDocuments(collection, docs, localeConfig, sort, isPublis
303
391
  * ones, sort. Invalid clauses never filter silently — they come back as
304
392
  * issues for the UI to surface.
305
393
  */
306
- export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished) {
394
+ export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished, meta = {}) {
307
395
  const issues = validateClauses(collection, state.filters);
308
396
  const invalid = new Set(issues.map((issue) => issue.index));
309
397
  const active = state.filters.filter((_, index) => !invalid.has(index));
310
398
  const matched = docs.filter((doc) => active.every((clause) => clauseMatches(collection, doc, localeConfig, clause, isPublished)));
311
399
  return {
312
- docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished),
400
+ docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished, meta),
313
401
  issues,
314
402
  };
315
403
  }
@@ -333,17 +421,24 @@ const NON_DEFAULT_COLUMN_TYPES = new Set(['blocks', 'richText', 'object']);
333
421
  * and a view curates columns by declaring an explicit `columns` list (or the
334
422
  * `id: 'default'` baseline override).
335
423
  */
336
- function defaultHiddenColumns(collection) {
337
- return collection.fields.filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type)).map((f) => f.name);
338
- }
339
- export function defaultViewState(collection) {
424
+ function defaultHiddenColumns(collection, pseudo = []) {
425
+ const composites = collection.fields
426
+ .filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type))
427
+ .map((f) => f.name);
428
+ // EC-445: applicable opt-in pseudo-columns ($publishedAt) start hidden; the rest
429
+ // ($updatedAt/$state/$url) are visible by default (owner: keep them on).
430
+ const hiddenPseudo = pseudo.filter((name) => DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name));
431
+ return [...composites, ...hiddenPseudo];
432
+ }
433
+ export function defaultViewState(collection, pseudo = []) {
340
434
  return {
341
435
  filters: [],
342
436
  sort: null,
343
437
  // Every field keeps a slot (order + picker), but the composite surfaces start
344
438
  // hidden so the default lens is the collection's scalar columns (EC-246b/EC-444).
345
- columns: orderedColumnNames(collection),
346
- hidden: defaultHiddenColumns(collection),
439
+ // EC-445: the applicable reserved pseudo-columns append after the data columns.
440
+ columns: orderedColumnNames(collection, pseudo),
441
+ hidden: defaultHiddenColumns(collection, pseudo),
347
442
  };
348
443
  }
349
444
  /**
@@ -355,23 +450,35 @@ export function defaultViewState(collection) {
355
450
  * inherits the collection's default lens (all scalars visible, composites hidden).
356
451
  * Unknown names in the authored list (a field the schema no longer has) are dropped.
357
452
  */
358
- export function resolveConfigColumns(collection, visible) {
453
+ export function resolveConfigColumns(collection, visible, pseudo = []) {
359
454
  if (visible === undefined || visible.length === 0) {
360
- const base = defaultViewState(collection);
455
+ const base = defaultViewState(collection, pseudo);
361
456
  return { columns: base.columns, hidden: base.hidden };
362
457
  }
363
- const schemaNames = orderedColumnNames(collection);
364
- const schema = new Set(schemaNames);
458
+ const universe = orderedColumnNames(collection, pseudo);
459
+ const inUniverse = new Set(universe);
365
460
  const seen = new Set();
366
461
  const shown = [];
367
462
  for (const name of visible) {
368
- if (schema.has(name) && !seen.has(name)) {
463
+ if (inUniverse.has(name) && !seen.has(name)) {
369
464
  shown.push(name);
370
465
  seen.add(name);
371
466
  }
372
467
  }
373
- const rest = schemaNames.filter((name) => !seen.has(name));
374
- return { columns: [...shown, ...rest], hidden: rest };
468
+ const rest = universe.filter((name) => !seen.has(name));
469
+ // EC-445: a schema-ONLY authored list (mentioning no pseudo-column) keeps the
470
+ // applicable pseudo-columns at their DEFAULT visibility — so a consumer's
471
+ // pre-EC-445 `columns:[…schema…]` list still shows today's url (+ updatedAt).
472
+ // Mentioning ANY reorderable pseudo-column switches them to the visible-list rule
473
+ // (only listed shows). EC-448: `$state` is EXCLUDED here — it is a fixed structural
474
+ // column, not a reorderable one, so an authored `$state` is an inert no-op: it must
475
+ // not flip the visible-list rule (nor is it in the universe, so it is simply dropped).
476
+ const mentionsPseudo = visible.some((name) => isPseudoColumn(name) && name !== STATE_FIELD);
477
+ const keepVisible = mentionsPseudo
478
+ ? new Set()
479
+ : new Set(pseudo.filter((name) => !DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name)));
480
+ const hidden = rest.filter((name) => !keepVisible.has(name));
481
+ return { columns: [...shown, ...rest], hidden };
375
482
  }
376
483
  /** The reserved `views[]` id whose entry OVERRIDES the synthesized baseline view (EC-444). */
377
484
  export const DEFAULT_VIEW_OVERRIDE_ID = 'default';
@@ -436,7 +543,7 @@ function configViewId(name, index) {
436
543
  * column. Config views are shared and change only in
437
544
  * code — the runtime never mutates them.
438
545
  */
439
- export function configViewsFor(collection) {
546
+ export function configViewsFor(collection, pseudo = []) {
440
547
  const declared = collection.views;
441
548
  if (!Array.isArray(declared))
442
549
  return [];
@@ -453,7 +560,7 @@ export function configViewsFor(collection) {
453
560
  seen.add(id);
454
561
  // EC-444: `initial` supersedes the deprecated `default` alias (one-release window).
455
562
  const initial = view.initial ?? view.default;
456
- const { columns, hidden } = resolveConfigColumns(collection, view.columns);
563
+ const { columns, hidden } = resolveConfigColumns(collection, view.columns, pseudo);
457
564
  resolved.push({
458
565
  id,
459
566
  name: view.name,
@@ -484,8 +591,8 @@ export function hasDefaultViewOverride(collection) {
484
591
  * `synthesizedBaseline` the caller passes (the studio's translated "All entries"
485
592
  * lens). Pure — the store supplies the synthesized baseline (i18n-bound `name`).
486
593
  */
487
- export function resolveCollectionViews(collection, synthesizedBaseline) {
488
- const configViews = configViewsFor(collection);
594
+ export function resolveCollectionViews(collection, synthesizedBaseline, pseudo = []) {
595
+ const configViews = configViewsFor(collection, pseudo);
489
596
  const overrideIndex = configViews.findIndex((view) => view.id === DEFAULT_VIEW_OVERRIDE_ID);
490
597
  if (overrideIndex < 0)
491
598
  return [synthesizedBaseline, ...configViews];
@@ -555,12 +662,15 @@ export function configViewIssues(collection) {
555
662
  * selector, treating the first visible column as the title), then the
556
663
  * remaining fields in schema order.
557
664
  */
558
- function orderedColumnNames(collection) {
665
+ function orderedColumnNames(collection, pseudo = []) {
559
666
  const names = collection.fields.map((field) => field.name);
560
667
  const title = titleFieldOf(collection)?.name;
561
- if (title === undefined || !names.includes(title))
562
- return names;
563
- return [title, ...names.filter((name) => name !== title)];
668
+ const schemaOrdered = title === undefined || !names.includes(title)
669
+ ? names
670
+ : [title, ...names.filter((name) => name !== title)];
671
+ // EC-445: the column UNIVERSE = schema fields, then the applicable reserved
672
+ // pseudo-columns (they are NOT in `collection.fields`).
673
+ return [...schemaOrdered, ...pseudo];
564
674
  }
565
675
  /** Deep-cloned canonical form (stable key order via the Zod shape). */
566
676
  export function normalizeViewState(state) {
@@ -578,26 +688,40 @@ export function viewStatesEqual(a, b) {
578
688
  * or hidden" — order is preserved across hide/show because visibility lives in
579
689
  * `hidden`, never in the order.
580
690
  */
581
- export function resolveColumns(collection, state) {
691
+ export function resolveColumns(collection, state, pseudo = []) {
582
692
  const schemaNames = collection.fields.map((field) => field.name);
583
- const schema = new Set(schemaNames);
693
+ // EC-445: the valid universe to KEEP from the saved order is schema fields PLUS the
694
+ // applicable pseudo-columns (which are not in `collection.fields`) — so a seeded
695
+ // `$state`/`$url`/`$updatedAt` is preserved + ordered, never dropped as "unknown".
696
+ const keep = new Set([...schemaNames, ...pseudo]);
584
697
  const hidden = new Set(state.hidden);
585
- // Keep the saved order, restricted to live fields and de-duplicated (a
698
+ const inState = new Set(state.columns);
699
+ // Keep the saved order, restricted to the live universe and de-duplicated (a
586
700
  // corrupted persisted state must never render one field as two columns).
587
701
  const ordered = [];
588
702
  const seen = new Set();
589
703
  for (const name of state.columns) {
590
- if (schema.has(name) && !seen.has(name)) {
704
+ if (keep.has(name) && !seen.has(name)) {
591
705
  ordered.push(name);
592
706
  seen.add(name);
593
707
  }
594
708
  }
595
- const appended = schemaNames.filter((name) => !seen.has(name));
596
- return [...ordered, ...appended].map((field) => ({ field, visible: !hidden.has(field) }));
709
+ // Append universe members the saved order predates: new schema fields (EC-213
710
+ // discovery), then applicable pseudo-columns not yet placed (EC-445).
711
+ const appended = [
712
+ ...schemaNames.filter((name) => !seen.has(name)),
713
+ ...pseudo.filter((name) => !seen.has(name)),
714
+ ];
715
+ return [...ordered, ...appended].map((field) => {
716
+ // A freshly-appended opt-in pseudo-column ($publishedAt) starts hidden; once it
717
+ // is in the saved order (an editor un-hid it) its visibility follows `hidden`.
718
+ const defaultHiddenAppend = DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(field) && !inState.has(field);
719
+ return { field, visible: !hidden.has(field) && !defaultHiddenAppend };
720
+ });
597
721
  }
598
722
  /** The visible columns of a view, in render order (EC-213). */
599
- export function visibleColumns(collection, state) {
600
- return resolveColumns(collection, state)
723
+ export function visibleColumns(collection, state, pseudo = []) {
724
+ return resolveColumns(collection, state, pseudo)
601
725
  .filter((column) => column.visible)
602
726
  .map((column) => column.field);
603
727
  }
@@ -610,9 +734,11 @@ export function visibleColumns(collection, state) {
610
734
  * the first visible column, then the first field, then `id`, when no title field
611
735
  * is resolvable (e.g. a function title with no backing `title` field).
612
736
  */
613
- export function primaryColumn(collection, state) {
737
+ export function primaryColumn(collection, state, pseudo = []) {
614
738
  return (titleFieldOf(collection)?.name ??
615
- visibleColumns(collection, state)[0] ??
739
+ // EC-445: a pseudo-column is never the identity column (it has no title value) —
740
+ // fall back to the first visible SCHEMA column.
741
+ visibleColumns(collection, state, pseudo).find((name) => !isPseudoColumn(name)) ??
616
742
  collection.fields[0]?.name ??
617
743
  'id');
618
744
  }
@@ -622,8 +748,8 @@ export function primaryColumn(collection, state) {
622
748
  * `hidden` to fields the live schema still has (a column removed from the schema
623
749
  * must not leave dead `hidden` cruft that trips dirty-detection / persists).
624
750
  */
625
- function withResolvedOrder(collection, state) {
626
- const columns = resolveColumns(collection, state).map((column) => column.field);
751
+ function withResolvedOrder(collection, state, pseudo = []) {
752
+ const columns = resolveColumns(collection, state, pseudo).map((column) => column.field);
627
753
  const live = new Set(columns);
628
754
  const hidden = state.hidden.filter((name) => live.has(name));
629
755
  return { ...state, columns, hidden };
@@ -635,24 +761,24 @@ function withResolvedOrder(collection, state) {
635
761
  * reconciliation or a stale `hidden` entry (only a real filter/sort/visibility/
636
762
  * order change counts as "unsaved").
637
763
  */
638
- export function canonicalViewState(collection, state) {
639
- return normalizeViewState(withResolvedOrder(collection, state));
764
+ export function canonicalViewState(collection, state, pseudo = []) {
765
+ return normalizeViewState(withResolvedOrder(collection, state, pseudo));
640
766
  }
641
767
  /**
642
768
  * Toggle a column's visibility — position is preserved (EC-213): visibility
643
769
  * lives in `hidden`, the order in `columns` is untouched. Columns the saved
644
770
  * order predates are pinned into `columns` first so their place is stable.
645
771
  */
646
- export function toggleColumn(collection, state, field) {
647
- const base = withResolvedOrder(collection, state);
772
+ export function toggleColumn(collection, state, field, pseudo = []) {
773
+ const base = withResolvedOrder(collection, state, pseudo);
648
774
  const hidden = base.hidden.includes(field)
649
775
  ? base.hidden.filter((name) => name !== field)
650
776
  : [...base.hidden, field];
651
777
  return { ...base, hidden };
652
778
  }
653
779
  /** Move a column one position left (-1) or right (+1) in the full column order. */
654
- export function moveColumn(collection, state, field, delta) {
655
- const base = withResolvedOrder(collection, state);
780
+ export function moveColumn(collection, state, field, delta, pseudo = []) {
781
+ const base = withResolvedOrder(collection, state, pseudo);
656
782
  const from = base.columns.indexOf(field);
657
783
  const to = from + delta;
658
784
  if (from < 0 || to < 0 || to >= base.columns.length)