@elytracms/studio 0.0.72 → 0.0.74

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 (43) hide show
  1. package/dist/components/app-views/default-modules.js +1 -1
  2. package/dist/components/app-views/default-modules.js.map +1 -1
  3. package/dist/components/cms/cms-labels.d.ts +6 -0
  4. package/dist/components/cms/cms-labels.js +21 -1
  5. package/dist/components/cms/cms-labels.js.map +1 -1
  6. package/dist/components/cms/cms-store.d.ts +16 -1
  7. package/dist/components/cms/cms-store.js +114 -31
  8. package/dist/components/cms/cms-store.js.map +1 -1
  9. package/dist/components/cms/collection-table.js +71 -9
  10. package/dist/components/cms/collection-table.js.map +1 -1
  11. package/dist/components/cms/column-width.d.ts +8 -0
  12. package/dist/components/cms/column-width.js +18 -0
  13. package/dist/components/cms/column-width.js.map +1 -1
  14. package/dist/components/cms/versions-model.d.ts +13 -0
  15. package/dist/components/cms/versions-model.js +44 -0
  16. package/dist/components/cms/versions-model.js.map +1 -1
  17. package/dist/components/cms/versions-pane.js +27 -6
  18. package/dist/components/cms/versions-pane.js.map +1 -1
  19. package/dist/components/cms/view-bar.js +16 -6
  20. package/dist/components/cms/view-bar.js.map +1 -1
  21. package/dist/components/collection-view/cms-collection-view-source.js +7 -0
  22. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  23. package/dist/lib/cms-views/view-model.d.ts +114 -28
  24. package/dist/lib/cms-views/view-model.js +262 -71
  25. package/dist/lib/cms-views/view-model.js.map +1 -1
  26. package/dist/lib/collection-view/collection-view-source.d.ts +28 -0
  27. package/dist/lib/collection-view/collection-view-source.js +15 -0
  28. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  29. package/dist/lib/i18n/messages.de.js +5 -1
  30. package/dist/lib/i18n/messages.de.js.map +1 -1
  31. package/dist/lib/i18n/messages.en.d.ts +3 -0
  32. package/dist/lib/i18n/messages.en.js +5 -1
  33. package/dist/lib/i18n/messages.en.js.map +1 -1
  34. package/dist/lib/operations/session-hydration.d.ts +8 -0
  35. package/dist/lib/operations/session-hydration.js +3 -0
  36. package/dist/lib/operations/session-hydration.js.map +1 -1
  37. package/dist/ui/index.d.ts +1 -0
  38. package/dist/ui/index.js +1 -0
  39. package/dist/ui/index.js.map +1 -1
  40. package/dist/ui/status-dot.d.ts +39 -0
  41. package/dist/ui/status-dot.js +36 -0
  42. package/dist/ui/status-dot.js.map +1 -0
  43. package/package.json +2 -2
@@ -55,6 +55,59 @@ 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 (already a filter pseudo-field; now a column too).
67
+ * - `$url` — the derived delivery URL (routable collections only; NON-sortable).
68
+ * - `$updatedAt` — the record's last-write timestamp.
69
+ * - `$publishedAt` — when the live version was published (opt-in, HIDDEN by default).
70
+ * RESERVED: its data path (materialize `publishedAt`) is an EC-445 follow-up, so it
71
+ * is not yet emitted by {@link pseudoColumns}; the constant + sort support ship now.
72
+ */
73
+ export const URL_FIELD = '$url';
74
+ export const PUBLISHED_AT_FIELD = '$publishedAt';
75
+ export const UPDATED_AT_FIELD = '$updatedAt';
76
+ /** Every reserved pseudo-column key (the columns-axis `$state` included). */
77
+ const PSEUDO_COLUMN_FIELDS = [
78
+ STATE_FIELD,
79
+ URL_FIELD,
80
+ PUBLISHED_AT_FIELD,
81
+ UPDATED_AT_FIELD,
82
+ ];
83
+ /** Is `name` a reserved pseudo-column (a `$`-key, not a schema field)? */
84
+ export function isPseudoColumn(name) {
85
+ return PSEUDO_COLUMN_FIELDS.includes(name);
86
+ }
87
+ /**
88
+ * Pseudo-columns HIDDEN by default (opt-in): the extra publish timestamp. Everything
89
+ * else applicable (`$updatedAt`/`$state`/`$url`) is on by default (owner, 2026-07-17).
90
+ */
91
+ const DEFAULT_HIDDEN_PSEUDO_COLUMNS = new Set([PUBLISHED_AT_FIELD]);
92
+ /**
93
+ * The reserved pseudo-columns that APPLY to a collection, in their default appended
94
+ * order (EC-445): `$updatedAt`, then `$state`, then `$url`. `$url` is gated on
95
+ * `routable` (a non-routable collection has no URL column, not an empty one), `$state`
96
+ * on `publishing` (a lifecycle-less rail shows no status), `$updatedAt` on `timestamps`.
97
+ * `$publishedAt` is reserved but not yet emitted (its data path is an EC-445 follow-up).
98
+ * Pure — a caller (`pseudoColumnsForSource`) supplies the signals from the source.
99
+ */
100
+ export function pseudoColumns(collection, opts = {}) {
101
+ void collection; // reserved for future per-collection gating; kept for API symmetry
102
+ const cols = [];
103
+ if (opts.timestamps)
104
+ cols.push(UPDATED_AT_FIELD);
105
+ if (opts.publishing)
106
+ cols.push(STATE_FIELD);
107
+ if (opts.routable)
108
+ cols.push(URL_FIELD);
109
+ return cols;
110
+ }
58
111
  export const STATE_OPTION = {
59
112
  field: STATE_FIELD,
60
113
  label: 'State',
@@ -267,9 +320,17 @@ function rawValueOf(collection, doc, field, localeConfig, isPublished) {
267
320
  return resolveField(collection, doc, field, locale, localeConfig).value;
268
321
  }
269
322
  /** The scalar a document exposes for a SORT field, locale-resolved. */
270
- function scalarOf(collection, doc, field, localeConfig, isPublished) {
323
+ function scalarOf(collection, doc, field, localeConfig, isPublished, meta = {}) {
271
324
  if (field === STATE_FIELD)
272
325
  return isPublished(doc) ? 'published' : 'draft';
326
+ // EC-445: the pseudo-DATE columns sort by the source's materialized ISO timestamp
327
+ // (ISO strings order lexicographically = chronologically); `$url` is derived → not sortable.
328
+ if (field === UPDATED_AT_FIELD)
329
+ return meta.updatedAt?.(doc) ?? null;
330
+ if (field === PUBLISHED_AT_FIELD)
331
+ return meta.publishedAt?.(doc) ?? null;
332
+ if (field === URL_FIELD)
333
+ return null;
273
334
  if (!collection.fields.some((f) => f.name === field))
274
335
  return null;
275
336
  const locale = documentDefaultLocale(doc, localeConfig);
@@ -292,10 +353,13 @@ export function clauseMatches(collection, doc, localeConfig, clause, isPublished
292
353
  * content accessors, the *table* may sort any rendered column — sorting is a
293
354
  * presentation concern here; the `filterable` flag governs filters.
294
355
  */
295
- export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished) {
356
+ export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished, meta = {}) {
296
357
  if (sort === null)
297
358
  return [...docs];
298
- const positions = new Map(docs.map((doc) => [doc.id, scalarOf(collection, doc, sort.field, localeConfig, isPublished)]));
359
+ const positions = new Map(docs.map((doc) => [
360
+ doc.id,
361
+ scalarOf(collection, doc, sort.field, localeConfig, isPublished, meta),
362
+ ]));
299
363
  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
364
  }
301
365
  /**
@@ -303,18 +367,18 @@ export function sortViewDocuments(collection, docs, localeConfig, sort, isPublis
303
367
  * ones, sort. Invalid clauses never filter silently — they come back as
304
368
  * issues for the UI to surface.
305
369
  */
306
- export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished) {
370
+ export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished, meta = {}) {
307
371
  const issues = validateClauses(collection, state.filters);
308
372
  const invalid = new Set(issues.map((issue) => issue.index));
309
373
  const active = state.filters.filter((_, index) => !invalid.has(index));
310
374
  const matched = docs.filter((doc) => active.every((clause) => clauseMatches(collection, doc, localeConfig, clause, isPublished)));
311
375
  return {
312
- docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished),
376
+ docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished, meta),
313
377
  issues,
314
378
  };
315
379
  }
316
380
  // --- View state construction, equality, columns ----------------------------------
317
- /** The id of the unnamed built-in "All documents" view. */
381
+ /** The id of the synthesized built-in "All entries" baseline view (EC-444). */
318
382
  export const ALL_VIEW_ID = 'all';
319
383
  /** The default (unnamed) view: no filters, no sort, every field visible. */
320
384
  /**
@@ -326,37 +390,83 @@ export const ALL_VIEW_ID = 'all';
326
390
  */
327
391
  const NON_DEFAULT_COLUMN_TYPES = new Set(['blocks', 'richText', 'object']);
328
392
  /**
329
- * Field names OFF the built-in "All" lens by default: the composite editing surfaces
330
- * (blocks/richText/object) — always noise in a scalar list — PLUS any scalar the
331
- * schema opts out with `form.column: false` (EC-343). Both only affect the default
332
- * columns: the field stays in the picker one toggle away, its form and filterability
333
- * are untouched, and a named view's explicit `columns` list ignores this entirely.
393
+ * Field names OFF the built-in baseline lens by default: exactly the composite
394
+ * editing surfaces (blocks/richText/object) — always noise in a scalar list. The
395
+ * default lens is "all scalars"; there is no per-field column opt-out any more
396
+ * (EC-444 hard-removed `form.column`). A field stays in the picker one toggle away,
397
+ * and a view curates columns by declaring an explicit `columns` list (or the
398
+ * `id: 'default'` baseline override).
334
399
  */
335
- function defaultHiddenColumns(collection) {
336
- return collection.fields
337
- .filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type) || f.form?.column === false)
400
+ function defaultHiddenColumns(collection, pseudo = []) {
401
+ const composites = collection.fields
402
+ .filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type))
338
403
  .map((f) => f.name);
404
+ // EC-445: applicable opt-in pseudo-columns ($publishedAt) start hidden; the rest
405
+ // ($updatedAt/$state/$url) are visible by default (owner: keep them on).
406
+ const hiddenPseudo = pseudo.filter((name) => DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name));
407
+ return [...composites, ...hiddenPseudo];
339
408
  }
340
- export function defaultViewState(collection) {
409
+ export function defaultViewState(collection, pseudo = []) {
341
410
  return {
342
411
  filters: [],
343
412
  sort: null,
344
- // Every field keeps a slot (order + picker), but the composite surfaces and any
345
- // `form.column: false` scalars start hidden so the default lens is the
346
- // collection's chosen scalar columns (EC-246b / EC-343).
347
- columns: orderedColumnNames(collection),
348
- hidden: defaultHiddenColumns(collection),
413
+ // Every field keeps a slot (order + picker), but the composite surfaces start
414
+ // hidden so the default lens is the collection's scalar columns (EC-246b/EC-444).
415
+ // EC-445: the applicable reserved pseudo-columns append after the data columns.
416
+ columns: orderedColumnNames(collection, pseudo),
417
+ hidden: defaultHiddenColumns(collection, pseudo),
349
418
  };
350
419
  }
351
420
  /**
352
- * A config-declared view (EC-246b) — the flat authoring shape a collection
353
- * declares in `cms/` config: `{ name, filters?, sort?, columns?, hidden? }`.
421
+ * Resolve an authored VISIBLE column list (EC-444) into the internal two-axis
422
+ * `{columns, hidden}` table state. The authored list is exactly the columns shown,
423
+ * in order; every OTHER schema field is appended (in schema order) and hidden — the
424
+ * same shape {@link defaultViewState} produces, so {@link resolveColumns} renders
425
+ * precisely the authored list, visible, in that order. An omitted (or empty) list
426
+ * inherits the collection's default lens (all scalars visible, composites hidden).
427
+ * Unknown names in the authored list (a field the schema no longer has) are dropped.
428
+ */
429
+ export function resolveConfigColumns(collection, visible, pseudo = []) {
430
+ if (visible === undefined || visible.length === 0) {
431
+ const base = defaultViewState(collection, pseudo);
432
+ return { columns: base.columns, hidden: base.hidden };
433
+ }
434
+ const universe = orderedColumnNames(collection, pseudo);
435
+ const inUniverse = new Set(universe);
436
+ const seen = new Set();
437
+ const shown = [];
438
+ for (const name of visible) {
439
+ if (inUniverse.has(name) && !seen.has(name)) {
440
+ shown.push(name);
441
+ seen.add(name);
442
+ }
443
+ }
444
+ const rest = universe.filter((name) => !seen.has(name));
445
+ // EC-445: a schema-ONLY authored list (mentioning no pseudo-column) keeps the
446
+ // applicable pseudo-columns at their DEFAULT visibility — so a consumer's
447
+ // pre-EC-445 `columns:[…schema…]` list still shows today's status + url (+ updatedAt).
448
+ // Mentioning ANY pseudo-column switches them to the visible-list rule (only listed shows).
449
+ const mentionsPseudo = visible.some(isPseudoColumn);
450
+ const keepVisible = mentionsPseudo
451
+ ? new Set()
452
+ : new Set(pseudo.filter((name) => !DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name)));
453
+ const hidden = rest.filter((name) => !keepVisible.has(name));
454
+ return { columns: [...shown, ...rest], hidden };
455
+ }
456
+ /** The reserved `views[]` id whose entry OVERRIDES the synthesized baseline view (EC-444). */
457
+ export const DEFAULT_VIEW_OVERRIDE_ID = 'default';
458
+ /**
459
+ * A config-declared view (EC-246b/EC-444) — the flat authoring shape a collection
460
+ * declares in `cms/` config: `{ name, initial?, filters?, sort?, columns? }`.
354
461
  * Filters/sort/columns sit at the top level (no nested `state` wrapper). The
355
462
  * studio validates + interprets these; `cms-core` stores them opaquely (it
356
463
  * can't reference the filter vocabulary without a layering cycle).
357
464
  */
358
465
  export const configViewSchema = z.object({
359
- /** Optional stable id; defaults to a slug of `name`. */
466
+ /**
467
+ * Optional stable id; defaults to a slug of `name`. The reserved value
468
+ * {@link DEFAULT_VIEW_OVERRIDE_ID} (`'default'`) marks the baseline-override entry.
469
+ */
360
470
  id: z.string().min(1).optional(),
361
471
  /**
362
472
  * View label; localizable (EC-343): a plain string or a `{ locale }` map, resolved
@@ -364,17 +474,26 @@ export const configViewSchema = z.object({
364
474
  */
365
475
  name: localizedStringSchema,
366
476
  /**
367
- * When true this view seeds the collection's INITIAL active view (EC-343) — the
368
- * curated lens an editor lands on before picking one. At most one per collection;
369
- * a duplicate is a visible validation issue ({@link configViewIssues}), never
370
- * silent. The built-in "All" lens stays available and stays the fallback.
477
+ * When true this view seeds the collection's INITIAL active view (EC-343/EC-444) —
478
+ * the curated lens an editor lands on before picking one. At most one per collection;
479
+ * a duplicate is a visible validation issue ({@link configViewIssues}), never silent.
480
+ * The baseline lens stays available and stays the fallback.
481
+ */
482
+ initial: z.boolean().optional(),
483
+ /**
484
+ * @deprecated EC-444 — renamed to {@link ConfigView.initial}. Still read as an alias
485
+ * for one release (`initial ?? default`) with a console warning; removed next release.
486
+ * Distinct from a field's default VALUE and from the reserved `id: 'default'` override.
371
487
  */
372
488
  default: z.boolean().optional(),
373
489
  filters: z.array(filterClauseSchema).optional(),
374
490
  sort: viewSortSchema.nullable().optional(),
375
- /** Column order; when omitted, the view inherits the collection's default columns. */
491
+ /**
492
+ * The single ordered list of VISIBLE columns (EC-444): what you list is what shows,
493
+ * in that order. Omit to inherit the collection's default columns (all scalars). The
494
+ * two-axis `{columns, hidden}` internal state is derived by {@link resolveConfigColumns}.
495
+ */
376
496
  columns: z.array(z.string().min(1)).optional(),
377
- hidden: z.array(z.string().min(1)).optional(),
378
497
  });
379
498
  /**
380
499
  * Slug used to derive a config view's stable id from its name. A localized name
@@ -397,11 +516,10 @@ function configViewId(name, index) {
397
516
  * column. Config views are shared and change only in
398
517
  * code — the runtime never mutates them.
399
518
  */
400
- export function configViewsFor(collection) {
519
+ export function configViewsFor(collection, pseudo = []) {
401
520
  const declared = collection.views;
402
521
  if (!Array.isArray(declared))
403
522
  return [];
404
- const base = defaultViewState(collection);
405
523
  const seen = new Set();
406
524
  const resolved = [];
407
525
  declared.forEach((raw, index) => {
@@ -413,46 +531,100 @@ export function configViewsFor(collection) {
413
531
  if (seen.has(id))
414
532
  return;
415
533
  seen.add(id);
534
+ // EC-444: `initial` supersedes the deprecated `default` alias (one-release window).
535
+ const initial = view.initial ?? view.default;
536
+ const { columns, hidden } = resolveConfigColumns(collection, view.columns, pseudo);
416
537
  resolved.push({
417
538
  id,
418
539
  name: view.name,
419
- ...(view.default ? { default: true } : {}),
540
+ ...(initial ? { initial: true } : {}),
420
541
  state: normalizeViewState({
421
542
  filters: view.filters ?? [],
422
543
  sort: view.sort ?? null,
423
- columns: view.columns && view.columns.length > 0 ? view.columns : base.columns,
424
- hidden: view.hidden ?? [],
544
+ columns,
545
+ hidden,
425
546
  }),
426
547
  });
427
548
  });
428
549
  return resolved;
429
550
  }
430
551
  /**
431
- * The id of the config view flagged `default: true` (EC-343) — the seed for a
432
- * collection's INITIAL active view. When several are flagged (a misconfiguration,
433
- * reported by {@link configViewIssues}) the FIRST in declaration order wins
434
- * deterministically; when none is flagged the caller falls back to the built-in
435
- * "All" lens. `undefined` when the collection declares no default view.
552
+ * Does the collection declare a baseline-override view (a config view whose id is
553
+ * {@link DEFAULT_VIEW_OVERRIDE_ID})? When true the synthesized "All entries" lens is
554
+ * replaced by that entry verbatim (see {@link resolveCollectionViews}), EC-444.
555
+ */
556
+ export function hasDefaultViewOverride(collection) {
557
+ return configViewsFor(collection).some((view) => view.id === DEFAULT_VIEW_OVERRIDE_ID);
558
+ }
559
+ /**
560
+ * The ordered view list a collection surfaces (EC-444): the baseline first, then the
561
+ * config-declared views in declaration order. The baseline is either an authored
562
+ * OVERRIDE (the config view whose id is {@link DEFAULT_VIEW_OVERRIDE_ID}, used
563
+ * VERBATIM and NOT repeated among the trailing views) or, absent one, the
564
+ * `synthesizedBaseline` the caller passes (the studio's translated "All entries"
565
+ * lens). Pure — the store supplies the synthesized baseline (i18n-bound `name`).
566
+ */
567
+ export function resolveCollectionViews(collection, synthesizedBaseline, pseudo = []) {
568
+ const configViews = configViewsFor(collection, pseudo);
569
+ const overrideIndex = configViews.findIndex((view) => view.id === DEFAULT_VIEW_OVERRIDE_ID);
570
+ if (overrideIndex < 0)
571
+ return [synthesizedBaseline, ...configViews];
572
+ const override = configViews[overrideIndex];
573
+ const rest = configViews.filter((_, index) => index !== overrideIndex);
574
+ return [override, ...rest];
575
+ }
576
+ /**
577
+ * The ids of config views that still use the DEPRECATED `default` flag without the
578
+ * new `initial` (EC-444). Drives the one-release deprecation warning (mirrors the
579
+ * duplicate-default channel); empty once every view has migrated to `initial`.
580
+ */
581
+ export function deprecatedDefaultFlagViewIds(collection) {
582
+ const declared = collection.views;
583
+ if (!Array.isArray(declared))
584
+ return [];
585
+ const seen = new Set();
586
+ const ids = [];
587
+ declared.forEach((raw, index) => {
588
+ const parsed = configViewSchema.safeParse(raw);
589
+ if (!parsed.success)
590
+ return;
591
+ const view = parsed.data;
592
+ const id = view.id ?? configViewId(view.name, index);
593
+ if (seen.has(id))
594
+ return;
595
+ seen.add(id);
596
+ if (view.default !== undefined && view.initial === undefined)
597
+ ids.push(id);
598
+ });
599
+ return ids;
600
+ }
601
+ /**
602
+ * The id of the config view flagged `initial: true` (EC-343/EC-444; `default` read
603
+ * as a deprecated alias) — the seed for a collection's INITIAL active view. When
604
+ * several are flagged (a misconfiguration, reported by {@link configViewIssues}) the
605
+ * FIRST in declaration order wins deterministically; when none is flagged the caller
606
+ * falls back to the baseline lens. `undefined` when the collection flags none.
436
607
  */
437
608
  export function defaultConfigViewId(collection) {
438
- return configViewsFor(collection).find((view) => view.default)?.id;
609
+ return configViewsFor(collection).find((view) => view.initial)?.id;
439
610
  }
440
611
  /**
441
- * Validate a collection's config-declared views (EC-343): at most one may set
442
- * `default: true`. A duplicate is returned as an explicit, structured issue —
443
- * never silently swallowed — so the studio can surface it (the first default still
444
- * wins deterministically for seeding, per {@link defaultConfigViewId}). Pure and
445
- * schema-only; the DOM-free counterpart of the "failures are first-class" contract.
612
+ * Validate a collection's config-declared views (EC-343/EC-444): at most one may set
613
+ * `initial: true` (`default` counted as its deprecated alias). A duplicate is returned
614
+ * as an explicit, structured issue — never silently swallowed — so the studio can
615
+ * surface it (the first still wins deterministically for seeding, per
616
+ * {@link defaultConfigViewId}). Pure and schema-only; the DOM-free counterpart of the
617
+ * "failures are first-class" contract.
446
618
  */
447
619
  export function configViewIssues(collection) {
448
- const defaults = configViewsFor(collection).filter((view) => view.default);
449
- if (defaults.length <= 1)
620
+ const initials = configViewsFor(collection).filter((view) => view.initial);
621
+ if (initials.length <= 1)
450
622
  return [];
451
- const viewIds = defaults.map((view) => view.id);
623
+ const viewIds = initials.map((view) => view.id);
452
624
  return [
453
625
  {
454
626
  code: 'duplicate-default',
455
- message: `Collection "${collection.id}" declares ${defaults.length} default views (${viewIds.join(', ')}); at most one view may set \`default: true\`. Seeding "${viewIds[0]}".`,
627
+ message: `Collection "${collection.id}" declares ${initials.length} initial views (${viewIds.join(', ')}); at most one view may set \`initial: true\`. Seeding "${viewIds[0]}".`,
456
628
  viewIds,
457
629
  },
458
630
  ];
@@ -463,12 +635,15 @@ export function configViewIssues(collection) {
463
635
  * selector, treating the first visible column as the title), then the
464
636
  * remaining fields in schema order.
465
637
  */
466
- function orderedColumnNames(collection) {
638
+ function orderedColumnNames(collection, pseudo = []) {
467
639
  const names = collection.fields.map((field) => field.name);
468
640
  const title = titleFieldOf(collection)?.name;
469
- if (title === undefined || !names.includes(title))
470
- return names;
471
- return [title, ...names.filter((name) => name !== title)];
641
+ const schemaOrdered = title === undefined || !names.includes(title)
642
+ ? names
643
+ : [title, ...names.filter((name) => name !== title)];
644
+ // EC-445: the column UNIVERSE = schema fields, then the applicable reserved
645
+ // pseudo-columns (they are NOT in `collection.fields`).
646
+ return [...schemaOrdered, ...pseudo];
472
647
  }
473
648
  /** Deep-cloned canonical form (stable key order via the Zod shape). */
474
649
  export function normalizeViewState(state) {
@@ -486,26 +661,40 @@ export function viewStatesEqual(a, b) {
486
661
  * or hidden" — order is preserved across hide/show because visibility lives in
487
662
  * `hidden`, never in the order.
488
663
  */
489
- export function resolveColumns(collection, state) {
664
+ export function resolveColumns(collection, state, pseudo = []) {
490
665
  const schemaNames = collection.fields.map((field) => field.name);
491
- const schema = new Set(schemaNames);
666
+ // EC-445: the valid universe to KEEP from the saved order is schema fields PLUS the
667
+ // applicable pseudo-columns (which are not in `collection.fields`) — so a seeded
668
+ // `$state`/`$url`/`$updatedAt` is preserved + ordered, never dropped as "unknown".
669
+ const keep = new Set([...schemaNames, ...pseudo]);
492
670
  const hidden = new Set(state.hidden);
493
- // Keep the saved order, restricted to live fields and de-duplicated (a
671
+ const inState = new Set(state.columns);
672
+ // Keep the saved order, restricted to the live universe and de-duplicated (a
494
673
  // corrupted persisted state must never render one field as two columns).
495
674
  const ordered = [];
496
675
  const seen = new Set();
497
676
  for (const name of state.columns) {
498
- if (schema.has(name) && !seen.has(name)) {
677
+ if (keep.has(name) && !seen.has(name)) {
499
678
  ordered.push(name);
500
679
  seen.add(name);
501
680
  }
502
681
  }
503
- const appended = schemaNames.filter((name) => !seen.has(name));
504
- return [...ordered, ...appended].map((field) => ({ field, visible: !hidden.has(field) }));
682
+ // Append universe members the saved order predates: new schema fields (EC-213
683
+ // discovery), then applicable pseudo-columns not yet placed (EC-445).
684
+ const appended = [
685
+ ...schemaNames.filter((name) => !seen.has(name)),
686
+ ...pseudo.filter((name) => !seen.has(name)),
687
+ ];
688
+ return [...ordered, ...appended].map((field) => {
689
+ // A freshly-appended opt-in pseudo-column ($publishedAt) starts hidden; once it
690
+ // is in the saved order (an editor un-hid it) its visibility follows `hidden`.
691
+ const defaultHiddenAppend = DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(field) && !inState.has(field);
692
+ return { field, visible: !hidden.has(field) && !defaultHiddenAppend };
693
+ });
505
694
  }
506
695
  /** The visible columns of a view, in render order (EC-213). */
507
- export function visibleColumns(collection, state) {
508
- return resolveColumns(collection, state)
696
+ export function visibleColumns(collection, state, pseudo = []) {
697
+ return resolveColumns(collection, state, pseudo)
509
698
  .filter((column) => column.visible)
510
699
  .map((column) => column.field);
511
700
  }
@@ -518,9 +707,11 @@ export function visibleColumns(collection, state) {
518
707
  * the first visible column, then the first field, then `id`, when no title field
519
708
  * is resolvable (e.g. a function title with no backing `title` field).
520
709
  */
521
- export function primaryColumn(collection, state) {
710
+ export function primaryColumn(collection, state, pseudo = []) {
522
711
  return (titleFieldOf(collection)?.name ??
523
- visibleColumns(collection, state)[0] ??
712
+ // EC-445: a pseudo-column is never the identity column (it has no title value) —
713
+ // fall back to the first visible SCHEMA column.
714
+ visibleColumns(collection, state, pseudo).find((name) => !isPseudoColumn(name)) ??
524
715
  collection.fields[0]?.name ??
525
716
  'id');
526
717
  }
@@ -530,8 +721,8 @@ export function primaryColumn(collection, state) {
530
721
  * `hidden` to fields the live schema still has (a column removed from the schema
531
722
  * must not leave dead `hidden` cruft that trips dirty-detection / persists).
532
723
  */
533
- function withResolvedOrder(collection, state) {
534
- const columns = resolveColumns(collection, state).map((column) => column.field);
724
+ function withResolvedOrder(collection, state, pseudo = []) {
725
+ const columns = resolveColumns(collection, state, pseudo).map((column) => column.field);
535
726
  const live = new Set(columns);
536
727
  const hidden = state.hidden.filter((name) => live.has(name));
537
728
  return { ...state, columns, hidden };
@@ -543,24 +734,24 @@ function withResolvedOrder(collection, state) {
543
734
  * reconciliation or a stale `hidden` entry (only a real filter/sort/visibility/
544
735
  * order change counts as "unsaved").
545
736
  */
546
- export function canonicalViewState(collection, state) {
547
- return normalizeViewState(withResolvedOrder(collection, state));
737
+ export function canonicalViewState(collection, state, pseudo = []) {
738
+ return normalizeViewState(withResolvedOrder(collection, state, pseudo));
548
739
  }
549
740
  /**
550
741
  * Toggle a column's visibility — position is preserved (EC-213): visibility
551
742
  * lives in `hidden`, the order in `columns` is untouched. Columns the saved
552
743
  * order predates are pinned into `columns` first so their place is stable.
553
744
  */
554
- export function toggleColumn(collection, state, field) {
555
- const base = withResolvedOrder(collection, state);
745
+ export function toggleColumn(collection, state, field, pseudo = []) {
746
+ const base = withResolvedOrder(collection, state, pseudo);
556
747
  const hidden = base.hidden.includes(field)
557
748
  ? base.hidden.filter((name) => name !== field)
558
749
  : [...base.hidden, field];
559
750
  return { ...base, hidden };
560
751
  }
561
752
  /** Move a column one position left (-1) or right (+1) in the full column order. */
562
- export function moveColumn(collection, state, field, delta) {
563
- const base = withResolvedOrder(collection, state);
753
+ export function moveColumn(collection, state, field, delta, pseudo = []) {
754
+ const base = withResolvedOrder(collection, state, pseudo);
564
755
  const from = base.columns.indexOf(field);
565
756
  const to = from + delta;
566
757
  if (from < 0 || to < 0 || to >= base.columns.length)