@cogenta/schema 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/define-collection.d.ts.map +1 -1
  2. package/dist/define-collection.js +32 -0
  3. package/dist/define-collection.js.map +1 -1
  4. package/dist/define-taxonomy.d.ts +9 -0
  5. package/dist/define-taxonomy.d.ts.map +1 -0
  6. package/dist/define-taxonomy.js +144 -0
  7. package/dist/define-taxonomy.js.map +1 -0
  8. package/dist/fields.d.ts +20 -0
  9. package/dist/fields.d.ts.map +1 -1
  10. package/dist/fields.js +14 -0
  11. package/dist/fields.js.map +1 -1
  12. package/dist/generate-schema-json.d.ts +28 -4
  13. package/dist/generate-schema-json.d.ts.map +1 -1
  14. package/dist/generate-schema-json.js +25 -4
  15. package/dist/generate-schema-json.js.map +1 -1
  16. package/dist/generate-types.js +1 -0
  17. package/dist/generate-types.js.map +1 -1
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/links/check.d.ts +76 -0
  23. package/dist/links/check.d.ts.map +1 -0
  24. package/dist/links/check.js +169 -0
  25. package/dist/links/check.js.map +1 -0
  26. package/dist/links/extract.d.ts +36 -0
  27. package/dist/links/extract.d.ts.map +1 -0
  28. package/dist/links/extract.js +69 -0
  29. package/dist/links/extract.js.map +1 -0
  30. package/dist/links/index.d.ts +10 -0
  31. package/dist/links/index.d.ts.map +1 -0
  32. package/dist/links/index.js +8 -0
  33. package/dist/links/index.js.map +1 -0
  34. package/dist/store/columns.d.ts.map +1 -1
  35. package/dist/store/columns.js +5 -2
  36. package/dist/store/columns.js.map +1 -1
  37. package/dist/store/index.d.ts +16 -2
  38. package/dist/store/index.d.ts.map +1 -1
  39. package/dist/store/index.js +9 -1
  40. package/dist/store/index.js.map +1 -1
  41. package/dist/store/lifecycle-events.d.ts +72 -0
  42. package/dist/store/lifecycle-events.d.ts.map +1 -0
  43. package/dist/store/lifecycle-events.js +73 -0
  44. package/dist/store/lifecycle-events.js.map +1 -0
  45. package/dist/store/menu-store.d.ts +99 -0
  46. package/dist/store/menu-store.d.ts.map +1 -0
  47. package/dist/store/menu-store.js +418 -0
  48. package/dist/store/menu-store.js.map +1 -0
  49. package/dist/store/menu-tables.d.ts +26 -0
  50. package/dist/store/menu-tables.d.ts.map +1 -0
  51. package/dist/store/menu-tables.js +79 -0
  52. package/dist/store/menu-tables.js.map +1 -0
  53. package/dist/store/naming.d.ts +11 -2
  54. package/dist/store/naming.d.ts.map +1 -1
  55. package/dist/store/naming.js +13 -1
  56. package/dist/store/naming.js.map +1 -1
  57. package/dist/store/read-only.d.ts +9 -3
  58. package/dist/store/read-only.d.ts.map +1 -1
  59. package/dist/store/read-only.js +13 -3
  60. package/dist/store/read-only.js.map +1 -1
  61. package/dist/store/scheduled-publish-enqueue.d.ts +37 -0
  62. package/dist/store/scheduled-publish-enqueue.d.ts.map +1 -0
  63. package/dist/store/scheduled-publish-enqueue.js +51 -0
  64. package/dist/store/scheduled-publish-enqueue.js.map +1 -0
  65. package/dist/store/schema-2-migration.d.ts +32 -0
  66. package/dist/store/schema-2-migration.d.ts.map +1 -0
  67. package/dist/store/schema-2-migration.js +136 -0
  68. package/dist/store/schema-2-migration.js.map +1 -0
  69. package/dist/store/search-indexing.d.ts +34 -0
  70. package/dist/store/search-indexing.d.ts.map +1 -0
  71. package/dist/store/search-indexing.js +65 -0
  72. package/dist/store/search-indexing.js.map +1 -0
  73. package/dist/store/store.d.ts +45 -8
  74. package/dist/store/store.d.ts.map +1 -1
  75. package/dist/store/store.js +398 -59
  76. package/dist/store/store.js.map +1 -1
  77. package/dist/store/tables.d.ts +15 -3
  78. package/dist/store/tables.d.ts.map +1 -1
  79. package/dist/store/tables.js +98 -6
  80. package/dist/store/tables.js.map +1 -1
  81. package/dist/store/taxonomy-path.d.ts +25 -0
  82. package/dist/store/taxonomy-path.d.ts.map +1 -0
  83. package/dist/store/taxonomy-path.js +64 -0
  84. package/dist/store/taxonomy-path.js.map +1 -0
  85. package/dist/store/taxonomy-store.d.ts +62 -0
  86. package/dist/store/taxonomy-store.d.ts.map +1 -0
  87. package/dist/store/taxonomy-store.js +292 -0
  88. package/dist/store/taxonomy-store.js.map +1 -0
  89. package/dist/store/types.d.ts +42 -2
  90. package/dist/store/types.d.ts.map +1 -1
  91. package/dist/store/values.d.ts.map +1 -1
  92. package/dist/store/values.js +4 -1
  93. package/dist/store/values.js.map +1 -1
  94. package/dist/system-fields.d.ts +2 -1
  95. package/dist/system-fields.d.ts.map +1 -1
  96. package/dist/system-fields.js +4 -0
  97. package/dist/system-fields.js.map +1 -1
  98. package/dist/types.d.ts +86 -4
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +12 -3
  101. package/dist/types.js.map +1 -1
  102. package/dist/validation.d.ts.map +1 -1
  103. package/dist/validation.js +5 -1
  104. package/dist/validation.js.map +1 -1
  105. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { CogentaError, identifier, sql, limit as sqlLimit, } from '@cogenta/core';
2
2
  import { newId as uuidv7 } from '../id.js';
3
+ import { DEFAULT_TRASH_RETAIN_DAYS, } from '../types.js';
3
4
  import { isColumnless } from './columns.js';
4
5
  import { decodeCursor, encodeCursor } from './cursor.js';
5
6
  import { diffContent } from './diff.js';
@@ -12,6 +13,7 @@ const MAX_PAGE_SIZE = 200;
12
13
  const DEFAULT_SORT = { field: 'id', direction: 'desc' };
13
14
  /** Enough history to answer "what changed last week" without unbounded growth. */
14
15
  const DEFAULT_KEEP = 20;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
17
  const SORT_COLUMNS = { id: 'id', createdAt: 'created_at', updatedAt: 'updated_at' };
16
18
  function notFound(collection, id) {
17
19
  return new CogentaError({
@@ -43,7 +45,100 @@ export function createContentStore(options) {
43
45
  .map(([name]) => name);
44
46
  const keep = Math.max(2, collection.versioning?.history === true ? (collection.versioning.keep ?? DEFAULT_KEEP) : 2);
45
47
  const draftsEnabled = collection.versioning?.drafts === true;
48
+ /** `trash: false` opts back out; absent means the default window is on. */
49
+ const trashEnabled = collection.trash !== false;
50
+ const retainDays = collection.trash === false || collection.trash === undefined
51
+ ? DEFAULT_TRASH_RETAIN_DAYS
52
+ : collection.trash.retainDays;
46
53
  const stamp = () => now().toISOString();
54
+ // -------------------------------------------------------------- the trash
55
+ const deletedAt = identifier('deleted_at', dialect);
56
+ /**
57
+ * The one place the trash filter is spelled out.
58
+ *
59
+ * Returning `null` for `'include'` rather than a `1 = 1` fragment keeps the
60
+ * generated SQL identical to what it was before 2.0 whenever the caller
61
+ * wanted everything — nothing to explain in a query plan.
62
+ */
63
+ function trashPredicate(filter) {
64
+ const effective = filter ?? 'exclude';
65
+ if (effective === 'include')
66
+ return null;
67
+ return effective === 'only' ? sql `${deletedAt} is not null` : sql `${deletedAt} is null`;
68
+ }
69
+ function isTrashed(row) {
70
+ return row['deleted_at'] !== null && row['deleted_at'] !== undefined;
71
+ }
72
+ /** True when this row is invisible to a caller asking for `filter`. */
73
+ function hiddenBy(row, filter) {
74
+ const effective = filter ?? 'exclude';
75
+ if (effective === 'include')
76
+ return false;
77
+ return effective === 'only' ? !isTrashed(row) : isTrashed(row);
78
+ }
79
+ /**
80
+ * Every `restrict` relation pointing at this collection, from any collection
81
+ * the store was told about — including this one, whose self-references it
82
+ * would otherwise miss.
83
+ */
84
+ const restrictingRelations = (options.siblings ?? [collection]).flatMap((sibling) => relationsOf(sibling)
85
+ .filter((relation) => relation.kind === 'relation' &&
86
+ relation.to === collection.name &&
87
+ relation.onDelete === 'restrict')
88
+ .map((relation) => ({ sibling, relation })));
89
+ async function referencesTo(tx, id) {
90
+ const blocking = [];
91
+ for (const { sibling, relation } of restrictingRelations) {
92
+ const siblingEntries = identifier(entriesTable(sibling.name), dialect);
93
+ const count = relation.many
94
+ ? await countJoinReferences(tx, sibling, relation.field, siblingEntries, id)
95
+ : await countColumnReferences(tx, relation.field, siblingEntries, id);
96
+ if (count > 0) {
97
+ blocking.push({ collection: sibling.name, field: relation.field, count });
98
+ }
99
+ }
100
+ return blocking;
101
+ }
102
+ async function isReferenced(tx, id) {
103
+ return (await referencesTo(tx, id)).length > 0;
104
+ }
105
+ async function assertNotReferenced(tx, id) {
106
+ const blocking = await referencesTo(tx, id);
107
+ if (blocking.length === 0)
108
+ return;
109
+ const naming = blocking
110
+ .map(({ count, collection: name }) => `${count} ${count === 1 ? 'entry' : 'entries'} of "${name}"`)
111
+ .join(', ');
112
+ throw new CogentaError({
113
+ code: 'CONTENT_REFERENCED',
114
+ message: `"${id}" cannot be removed from "${collection.name}": ${naming} still reference it.`,
115
+ hint: "Point those entries somewhere else first, or declare the relation with onDelete: 'cascade' if losing them along with their target is really what you want.",
116
+ details: { collection: collection.name, id, blocking },
117
+ });
118
+ }
119
+ /** The real `DELETE`. Cascades take the versions, blocks and join rows. */
120
+ async function hardDelete(tx, id) {
121
+ const removed = await tx.query(sql `delete from ${entries} where ${identifier('id', dialect)} = ${id}`);
122
+ return removed.rowsAffected > 0;
123
+ }
124
+ async function countColumnReferences(tx, field, table, id) {
125
+ // A referrer that is itself in the trash does not block: it is not visible
126
+ // content any more, and blocking on it would make the trash a place
127
+ // entries can enter but never leave in the right order.
128
+ const found = await tx.query(sql `select ${identifier('id', dialect)} from ${table}
129
+ where ${identifier(columnFor(field), dialect)} = ${id}
130
+ and ${identifier('deleted_at', dialect)} is null`);
131
+ return found.rows.length;
132
+ }
133
+ async function countJoinReferences(tx, sibling, field, siblingEntries, id) {
134
+ const join = identifier(relationTable(sibling.name, field), dialect);
135
+ const found = await tx.query(sql `select ${join}.${identifier('entry_id', dialect)} from ${join}
136
+ join ${siblingEntries}
137
+ on ${siblingEntries}.${identifier('id', dialect)} = ${join}.${identifier('entry_id', dialect)}
138
+ where ${join}.${identifier('target_id', dialect)} = ${id}
139
+ and ${siblingEntries}.${identifier('deleted_at', dialect)} is null`);
140
+ return found.rows.length;
141
+ }
47
142
  // ---------------------------------------------------------------- reading
48
143
  async function loadRow(tx, id) {
49
144
  const found = await tx.query(sql `select * from ${entries} where ${identifier('id', dialect)} = ${id}`);
@@ -145,6 +240,23 @@ export function createContentStore(options) {
145
240
  return {};
146
241
  }
147
242
  }
243
+ /** The three forms `schedulePublication` accepts, turned into the ISO string `publishedAt` is stored as. */
244
+ function scheduleDateIso(value, entryId) {
245
+ const milliseconds = value instanceof Date
246
+ ? value.getTime()
247
+ : typeof value === 'number'
248
+ ? value
249
+ : Date.parse(value);
250
+ if (!Number.isFinite(milliseconds)) {
251
+ throw new CogentaError({
252
+ code: 'CONTENT_SCHEDULE_INVALID',
253
+ message: `"${String(value)}" is not a date to schedule "${entryId}" for.`,
254
+ hint: 'Pass a Date, an ISO 8601 timestamp such as 2026-09-01T09:00:00Z, or epoch milliseconds.',
255
+ details: { collection: collection.name, entryId },
256
+ });
257
+ }
258
+ return new Date(milliseconds).toISOString();
259
+ }
148
260
  function publishedAtOf(values, status) {
149
261
  // Contract A declares `publishedAt` as an ordinary field of the collection
150
262
  // (see its own example), so the engine maintains it when it is there rather
@@ -166,6 +278,7 @@ export function createContentStore(options) {
166
278
  createdBy: nullableText(row['created_by']),
167
279
  updatedBy: nullableText(row['updated_by']),
168
280
  status,
281
+ deletedAt: nullableText(row['deleted_at']),
169
282
  locale: text(row['locale']),
170
283
  translationOf: nullableText(row['translation_of']),
171
284
  version: Number(overrides.version ?? row['version']),
@@ -348,68 +461,181 @@ export function createContentStore(options) {
348
461
  return encodeCursor({ field: order.field, direction: order.direction, value, id: entry.id });
349
462
  }
350
463
  // ------------------------------------------------------------------- API
464
+ /**
465
+ * The whole of `create`, minus the transaction.
466
+ *
467
+ * Split out so `duplicate` can read the source and write the copy inside a
468
+ * single transaction: the free-slug probe it does between the two would
469
+ * otherwise be a read outside the write's transaction, and a concurrent
470
+ * insert could take the slug it had just found free.
471
+ */
472
+ async function insertEntry(tx, input) {
473
+ const id = input.id ?? newId();
474
+ const status = input.status ?? 'draft';
475
+ const at = stamp();
476
+ const author = input.createdBy ?? null;
477
+ const normalised = normaliseValues(collection, input.values ?? {}, {
478
+ partial: false,
479
+ enforceRequired: status === 'published',
480
+ });
481
+ const zones = normaliseBlocks(collection, input.blocks ?? {}, newId);
482
+ const values = { ...normalised.values };
483
+ if (status === 'published' && collection.fields['publishedAt'] !== undefined) {
484
+ values['publishedAt'] ??= at;
485
+ normalised.columns['publishedAt'] ??= at;
486
+ }
487
+ const columns = [
488
+ 'id',
489
+ 'created_at',
490
+ 'updated_at',
491
+ 'created_by',
492
+ 'updated_by',
493
+ 'status',
494
+ 'locale',
495
+ 'translation_of',
496
+ 'version',
497
+ 'provenance',
498
+ 'provenance_detail',
499
+ ];
500
+ const bound = [
501
+ id,
502
+ at,
503
+ at,
504
+ author,
505
+ author,
506
+ status,
507
+ input.locale ?? defaultLocale,
508
+ input.translationOf ?? null,
509
+ 1,
510
+ input.provenance ?? 'human',
511
+ input.provenanceDetail === undefined || input.provenanceDetail === null
512
+ ? null
513
+ : JSON.stringify(input.provenanceDetail),
514
+ ];
515
+ for (const [name, value] of Object.entries(normalised.columns)) {
516
+ columns.push(columnFor(name));
517
+ bound.push(value);
518
+ }
519
+ await tx.query(sql `insert into ${entries} (${joinFragments(columns.map((column) => identifier(column, dialect)), ', ')}) values (${valueList(bound)})`);
520
+ await writeRelations(tx, id, normalised.relations);
521
+ await writeBlocks(tx, id, 1, zones);
522
+ await writeVersion(tx, id, 1, status, { ...values, ...normalised.relations }, author);
523
+ const row = await loadRow(tx, id);
524
+ if (row === null)
525
+ throw notFound(collection.name, id);
526
+ return liveEntry(tx, row);
527
+ }
528
+ // ------------------------------------------------------------ duplication
529
+ const uniqueFields = Object.entries(collection.fields).filter(([, field]) => field.unique === true && !isColumnless(field));
530
+ async function isTaken(tx, field, locale, candidate) {
531
+ // Uniqueness is per locale, exactly as the index in `tables.ts` declares
532
+ // it: the same slug in French and in English is the normal case.
533
+ const found = await tx.query(sql `select ${identifier('id', dialect)} from ${entries}
534
+ where ${identifier('locale', dialect)} = ${locale}
535
+ and ${identifier(columnFor(field), dialect)} = ${candidate}
536
+ limit ${sqlLimit(1)}`);
537
+ return found.rows.length > 0;
538
+ }
539
+ /**
540
+ * A value for a `unique` field that no row holds yet, derived from the one
541
+ * being copied: `hello` becomes `hello-copy`, then `hello-copy-2`.
542
+ *
543
+ * The unique index stays the enforcement point — this only spares the caller
544
+ * a raw constraint violation on the single most common shape there is (an
545
+ * article with a unique slug).
546
+ */
547
+ async function freeUniqueValue(tx, field, locale, source) {
548
+ for (let attempt = 1; attempt <= 1000; attempt++) {
549
+ const candidate = attempt === 1 ? `${source}-copy` : `${source}-copy-${attempt}`;
550
+ if (!(await isTaken(tx, field, locale, candidate)))
551
+ return candidate;
552
+ }
553
+ throw new CogentaError({
554
+ code: 'CONTENT_INVALID',
555
+ message: `Could not derive a free value for the unique field "${field}".`,
556
+ hint: 'A thousand copies of this value already exist. Pass an explicit value for the field instead.',
557
+ details: { collection: collection.name, field },
558
+ });
559
+ }
351
560
  return {
352
- create: async (input) => db.transaction(async (tx) => {
353
- const id = input.id ?? newId();
354
- const status = input.status ?? 'draft';
355
- const at = stamp();
356
- const author = input.createdBy ?? null;
357
- const normalised = normaliseValues(collection, input.values ?? {}, {
358
- partial: false,
359
- enforceRequired: status === 'published',
360
- });
361
- const zones = normaliseBlocks(collection, input.blocks ?? {}, newId);
362
- const values = { ...normalised.values };
363
- if (status === 'published' && collection.fields['publishedAt'] !== undefined) {
364
- values['publishedAt'] ??= at;
365
- normalised.columns['publishedAt'] ??= at;
366
- }
367
- const columns = [
368
- 'id',
369
- 'created_at',
370
- 'updated_at',
371
- 'created_by',
372
- 'updated_by',
373
- 'status',
374
- 'locale',
375
- 'translation_of',
376
- 'version',
377
- 'provenance',
378
- 'provenance_detail',
379
- ];
380
- const bound = [
381
- id,
382
- at,
383
- at,
384
- author,
385
- author,
386
- status,
387
- input.locale ?? defaultLocale,
388
- input.translationOf ?? null,
389
- 1,
390
- input.provenance ?? 'human',
391
- input.provenanceDetail === undefined || input.provenanceDetail === null
392
- ? null
393
- : JSON.stringify(input.provenanceDetail),
394
- ];
395
- for (const [name, value] of Object.entries(normalised.columns)) {
396
- columns.push(columnFor(name));
397
- bound.push(value);
398
- }
399
- await tx.query(sql `insert into ${entries} (${joinFragments(columns.map((column) => identifier(column, dialect)), ', ')}) values (${valueList(bound)})`);
400
- await writeRelations(tx, id, normalised.relations);
401
- await writeBlocks(tx, id, 1, zones);
402
- await writeVersion(tx, id, 1, status, { ...values, ...normalised.relations }, author);
561
+ create: async (input) => db.transaction((tx) => insertEntry(tx, input), { immediate: true }),
562
+ /**
563
+ * Copies one entry into a new draft.
564
+ *
565
+ * Three decisions are worth stating, because each could reasonably have
566
+ * gone the other way:
567
+ *
568
+ * 1. **The copy starts its own translation family** — `translationOf` is
569
+ * always null, even when the source is itself a translation. Two rows
570
+ * of the same family sharing a locale would make `resolveLocale` pick
571
+ * between them arbitrarily (it takes the first match) and would show
572
+ * the copy in `translations()` as if it were another language. A copy
573
+ * is a new piece of content, not a new language of an old one.
574
+ * 2. **Blocks get fresh keys.** A `_key` anchors comments and RAG chunks;
575
+ * the same key living in two entries would make "which entry does this
576
+ * block belong to" answerable only with the entry id alongside it.
577
+ * 3. **Provenance is carried over, not reset to `human`.** Copying
578
+ * generated content does not make it human-written, and pressing
579
+ * duplicate must not launder it. Pass `provenance` to override.
580
+ *
581
+ * The copy is always a `draft`, its version count restarts at 1 and it
582
+ * inherits none of the source's history — the source's past is the
583
+ * source's.
584
+ */
585
+ duplicate: async (id, duplicateOptions) => db.transaction(async (tx) => {
403
586
  const row = await loadRow(tx, id);
404
587
  if (row === null)
405
588
  throw notFound(collection.name, id);
406
- return liveEntry(tx, row);
589
+ // The working state, not the published one: the admin's duplicate
590
+ // button copies what the editor is looking at.
591
+ const source = await workingEntry(tx, row);
592
+ const overrides = duplicateOptions?.values ?? {};
593
+ const locale = source.locale;
594
+ const values = { ...source.values };
595
+ // Never carried over: a copy has never been published.
596
+ if (collection.fields['publishedAt'] !== undefined)
597
+ values['publishedAt'] = null;
598
+ for (const [field] of uniqueFields) {
599
+ if (Object.hasOwn(overrides, field))
600
+ continue;
601
+ const current = values[field];
602
+ if (current === null || current === undefined || current === '')
603
+ continue;
604
+ if (typeof current !== 'string') {
605
+ throw new CogentaError({
606
+ code: 'CONTENT_INVALID',
607
+ message: `"${field}" is unique and is not text, so a copy of it cannot be derived.`,
608
+ hint: 'Pass a value for the field in duplicate()’s values, so the copy has its own.',
609
+ details: { collection: collection.name, field },
610
+ });
611
+ }
612
+ values[field] = await freeUniqueValue(tx, field, locale, current);
613
+ }
614
+ const blocks = {};
615
+ for (const [zone, list] of Object.entries(source.blocks)) {
616
+ blocks[zone] = list.map((block) => ({ ...block, key: '' }));
617
+ }
618
+ return insertEntry(tx, {
619
+ ...(duplicateOptions?.id === undefined ? {} : { id: duplicateOptions.id }),
620
+ locale,
621
+ translationOf: null,
622
+ status: 'draft',
623
+ createdBy: duplicateOptions?.createdBy ?? null,
624
+ provenance: duplicateOptions?.provenance ?? source.provenance,
625
+ provenanceDetail: duplicateOptions?.provenanceDetail ?? source.provenanceDetail,
626
+ values: { ...values, ...overrides },
627
+ blocks,
628
+ });
407
629
  }, { immediate: true }),
408
630
  read: async (id, readOptions) => {
409
631
  const state = readOptions?.state ?? 'published';
410
632
  const row = await loadRow(db, id);
411
633
  if (row === null)
412
634
  return null;
635
+ // The trash is invisible unless it is asked for by name (ADR-0022) —
636
+ // the same safe-by-default posture as `state` on the line below.
637
+ if (hiddenBy(row, readOptions?.trashed))
638
+ return null;
413
639
  // The safe default: a caller that says nothing gets the published state,
414
640
  // never a draft. The public role has no way to ask for one.
415
641
  if (state === 'published') {
@@ -457,15 +683,82 @@ export function createContentStore(options) {
457
683
  await prune(tx, id, Number(after['version']));
458
684
  return workingEntry(tx, after);
459
685
  }, { immediate: true }),
460
- delete: async (id) => {
461
- const removed = await db.query(sql `delete from ${entries} where ${identifier('id', dialect)} = ${id}`);
686
+ delete: async (id) => db.transaction(async (tx) => {
687
+ const row = await loadRow(tx, id);
688
+ if (row === null)
689
+ return false;
690
+ // Already in the trash: nothing to do, and saying so with `false`
691
+ // matches what the method meant before 2.0 for a missing row.
692
+ if (isTrashed(row))
693
+ return false;
694
+ await assertNotReferenced(tx, id);
695
+ if (!trashEnabled)
696
+ return hardDelete(tx, id);
697
+ const removed = await tx.query(sql `update ${entries} set ${deletedAt} = ${stamp()}
698
+ where ${identifier('id', dialect)} = ${id}`);
462
699
  return removed.rowsAffected > 0;
700
+ }, { immediate: true }),
701
+ untrash: async (id) => db.transaction(async (tx) => {
702
+ const row = await loadRow(tx, id);
703
+ if (row === null)
704
+ throw notFound(collection.name, id);
705
+ if (!isTrashed(row)) {
706
+ throw new CogentaError({
707
+ code: 'CONTENT_NOT_TRASHED',
708
+ message: `"${id}" is not in the "${collection.name}" trash.`,
709
+ hint: 'Only an entry that was trashed can be taken back out. Nothing was changed.',
710
+ details: { collection: collection.name, id },
711
+ });
712
+ }
713
+ // `status` is deliberately untouched: an article that was published
714
+ // when it was trashed comes back published (ADR-0022). Restoring it
715
+ // as a draft would lose information and invite a second, accidental
716
+ // publication.
717
+ await tx.query(sql `update ${entries} set ${deletedAt} = ${null}
718
+ where ${identifier('id', dialect)} = ${id}`);
719
+ const after = await loadRow(tx, id);
720
+ if (after === null)
721
+ throw notFound(collection.name, id);
722
+ return { ...(await liveEntry(tx, after)), state: 'working' };
723
+ }, { immediate: true }),
724
+ purge: async (id) => db.transaction(async (tx) => {
725
+ const row = await loadRow(tx, id);
726
+ if (row === null)
727
+ return false;
728
+ await assertNotReferenced(tx, id);
729
+ return hardDelete(tx, id);
730
+ }, { immediate: true }),
731
+ purgeExpired: async () => {
732
+ const olderThan = new Date(now().getTime() - retainDays * DAY_MS).toISOString();
733
+ if (!trashEnabled)
734
+ return { purged: 0, olderThan };
735
+ return db.transaction(async (tx) => {
736
+ const expired = await tx.query(sql `select ${identifier('id', dialect)} from ${entries}
737
+ where ${deletedAt} is not null and ${deletedAt} < ${olderThan}`);
738
+ let purged = 0;
739
+ for (const row of expired.rows) {
740
+ const id = text(row['id']);
741
+ // Each one is still checked — a sweep that quietly broke a
742
+ // `restrict` relation would be the hole the trash exists to close
743
+ // — but one blocked entry is **skipped**, not fatal: a scheduled
744
+ // sweep that dies on the first stuck row purges nothing ever
745
+ // again, and nobody would notice.
746
+ if (await isReferenced(tx, id))
747
+ continue;
748
+ if (await hardDelete(tx, id))
749
+ purged += 1;
750
+ }
751
+ return { purged, olderThan };
752
+ }, { immediate: true });
463
753
  },
464
754
  list: async (listOptions = {}) => {
465
755
  const order = sortOrder(listOptions);
466
756
  const state = listOptions.state ?? 'published';
467
757
  const size = Math.min(Math.max(listOptions.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
468
758
  const predicates = [];
759
+ const trash = trashPredicate(listOptions.trashed);
760
+ if (trash !== null)
761
+ predicates.push(trash);
469
762
  if (listOptions.status !== undefined) {
470
763
  predicates.push(sql `${identifier('status', dialect)} = ${listOptions.status}`);
471
764
  }
@@ -566,16 +859,53 @@ export function createContentStore(options) {
566
859
  if (row === null)
567
860
  throw notFound(collection.name, id);
568
861
  const status = unpublishOptions?.status ?? 'draft';
569
- await writeLiveColumns(tx, id, {}, { status, updated_at: stamp() });
862
+ if (status === 'scheduled') {
863
+ // Scheduling has nowhere to put the date on a collection that
864
+ // never declared `publishedAt` as a field (contract A: it is an
865
+ // ordinary, optional field, not a system column every collection
866
+ // gets for free).
867
+ if (collection.fields['publishedAt'] === undefined) {
868
+ throw new CogentaError({
869
+ code: 'CONTENT_SCHEDULE_INVALID',
870
+ message: `"${collection.name}" has no "publishedAt" field to schedule a publication against.`,
871
+ hint: 'Declare a `publishedAt` field on this collection before scheduling an entry.',
872
+ details: { collection: collection.name, entryId: id },
873
+ });
874
+ }
875
+ if (unpublishOptions?.publishedAt === undefined) {
876
+ throw new CogentaError({
877
+ code: 'CONTENT_SCHEDULE_INVALID',
878
+ message: 'A scheduled publication needs a date.',
879
+ hint: 'Pass `publishedAt` — a Date, an ISO 8601 timestamp, or epoch milliseconds.',
880
+ details: { collection: collection.name, entryId: id },
881
+ });
882
+ }
883
+ const iso = scheduleDateIso(unpublishOptions.publishedAt, id);
884
+ const working = await workingEntry(tx, row);
885
+ const merged = { ...working.values, publishedAt: iso };
886
+ const normalised = normaliseValues(collection, merged, {
887
+ partial: false,
888
+ enforceRequired: false,
889
+ });
890
+ await writeLiveColumns(tx, id, normalised.columns, { status, updated_at: stamp() });
891
+ }
892
+ else {
893
+ await writeLiveColumns(tx, id, {}, { status, updated_at: stamp() });
894
+ }
570
895
  const after = await loadRow(tx, id);
571
896
  if (after === null)
572
897
  throw notFound(collection.name, id);
573
898
  return { ...(await liveEntry(tx, after)), state: 'working' };
574
899
  }, { immediate: true }),
575
- history: async (id) => {
900
+ history: async (id, historyOptions) => {
576
901
  const row = await loadRow(db, id);
577
902
  if (row === null)
578
903
  throw notFound(collection.name, id);
904
+ // A trashed entry has no history as far as an ordinary caller is
905
+ // concerned: it does not exist for them, and saying "not found" is the
906
+ // same answer `read()` gives.
907
+ if (hiddenBy(row, historyOptions?.trashed))
908
+ throw notFound(collection.name, id);
579
909
  const found = await db.query(sql `select * from ${versions}
580
910
  where ${identifier('entry_id', dialect)} = ${id}
581
911
  order by ${identifier('version', dialect)} desc`);
@@ -660,22 +990,30 @@ export function createContentStore(options) {
660
990
  };
661
991
  return diffContent(await load(from), await load(to));
662
992
  },
663
- translations: async (id) => {
993
+ translations: async (id, translationsOptions) => {
664
994
  const row = await loadRow(db, id);
665
995
  if (row === null)
666
996
  return [];
997
+ if (hiddenBy(row, translationsOptions?.trashed))
998
+ return [];
667
999
  const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
668
1000
  const found = await db.query(sql `select * from ${entries}
669
1001
  where ${identifier('id', dialect)} = ${sourceId}
670
1002
  or ${identifier('translation_of', dialect)} = ${sourceId}
671
1003
  order by ${identifier('locale', dialect)} asc`);
672
- return liveEntries(db, found.rows);
1004
+ // A trashed member of the family is filtered out here rather than in the
1005
+ // query, so that the family is still found through *any* of its members
1006
+ // — including one whose source is in the trash.
1007
+ const visible = found.rows.filter((member) => !hiddenBy(member, translationsOptions?.trashed));
1008
+ return liveEntries(db, visible);
673
1009
  },
674
1010
  resolveLocale: async (id, locale, resolveOptions) => {
675
1011
  const state = resolveOptions.state ?? 'published';
676
1012
  const row = await loadRow(db, id);
677
1013
  if (row === null)
678
1014
  return { outcome: 'notFound' };
1015
+ if (hiddenBy(row, resolveOptions.trashed))
1016
+ return { outcome: 'notFound' };
679
1017
  const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
680
1018
  const found = await db.query(sql `select * from ${entries}
681
1019
  where ${identifier('id', dialect)} = ${sourceId}
@@ -683,7 +1021,8 @@ export function createContentStore(options) {
683
1021
  // Publication is per language (ADR-0014): a French entry can be live while
684
1022
  // its English translation is still a draft, and the renderer must treat
685
1023
  // the draft as if it did not exist.
686
- const visible = found.rows.filter((member) => state === 'working' || text(member['status']) === 'published');
1024
+ const visible = found.rows.filter((member) => !hiddenBy(member, resolveOptions.trashed) &&
1025
+ (state === 'working' || text(member['status']) === 'published'));
687
1026
  const match = visible.find((member) => text(member['locale']) === locale);
688
1027
  if (match !== undefined) {
689
1028
  const entry = state === 'published' ? await liveEntry(db, match) : await workingEntry(db, match);