@cogenta/schema 0.1.2 → 0.2.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 (93) 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 +11 -2
  38. package/dist/store/index.d.ts.map +1 -1
  39. package/dist/store/index.js +6 -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/naming.d.ts +11 -2
  46. package/dist/store/naming.d.ts.map +1 -1
  47. package/dist/store/naming.js +13 -1
  48. package/dist/store/naming.js.map +1 -1
  49. package/dist/store/read-only.d.ts +9 -3
  50. package/dist/store/read-only.d.ts.map +1 -1
  51. package/dist/store/read-only.js +13 -3
  52. package/dist/store/read-only.js.map +1 -1
  53. package/dist/store/schema-2-migration.d.ts +32 -0
  54. package/dist/store/schema-2-migration.d.ts.map +1 -0
  55. package/dist/store/schema-2-migration.js +136 -0
  56. package/dist/store/schema-2-migration.js.map +1 -0
  57. package/dist/store/search-indexing.d.ts +34 -0
  58. package/dist/store/search-indexing.d.ts.map +1 -0
  59. package/dist/store/search-indexing.js +65 -0
  60. package/dist/store/search-indexing.js.map +1 -0
  61. package/dist/store/store.d.ts +36 -7
  62. package/dist/store/store.d.ts.map +1 -1
  63. package/dist/store/store.js +348 -58
  64. package/dist/store/store.js.map +1 -1
  65. package/dist/store/tables.d.ts +15 -3
  66. package/dist/store/tables.d.ts.map +1 -1
  67. package/dist/store/tables.js +98 -6
  68. package/dist/store/tables.js.map +1 -1
  69. package/dist/store/taxonomy-path.d.ts +25 -0
  70. package/dist/store/taxonomy-path.d.ts.map +1 -0
  71. package/dist/store/taxonomy-path.js +64 -0
  72. package/dist/store/taxonomy-path.js.map +1 -0
  73. package/dist/store/taxonomy-store.d.ts +62 -0
  74. package/dist/store/taxonomy-store.d.ts.map +1 -0
  75. package/dist/store/taxonomy-store.js +292 -0
  76. package/dist/store/taxonomy-store.js.map +1 -0
  77. package/dist/store/types.d.ts +42 -2
  78. package/dist/store/types.d.ts.map +1 -1
  79. package/dist/store/values.d.ts.map +1 -1
  80. package/dist/store/values.js +4 -1
  81. package/dist/store/values.js.map +1 -1
  82. package/dist/system-fields.d.ts +2 -1
  83. package/dist/system-fields.d.ts.map +1 -1
  84. package/dist/system-fields.js +4 -0
  85. package/dist/system-fields.js.map +1 -1
  86. package/dist/types.d.ts +86 -4
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js +12 -3
  89. package/dist/types.js.map +1 -1
  90. package/dist/validation.d.ts.map +1 -1
  91. package/dist/validation.js +5 -1
  92. package/dist/validation.js.map +1 -1
  93. 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}`);
@@ -166,6 +261,7 @@ export function createContentStore(options) {
166
261
  createdBy: nullableText(row['created_by']),
167
262
  updatedBy: nullableText(row['updated_by']),
168
263
  status,
264
+ deletedAt: nullableText(row['deleted_at']),
169
265
  locale: text(row['locale']),
170
266
  translationOf: nullableText(row['translation_of']),
171
267
  version: Number(overrides.version ?? row['version']),
@@ -348,68 +444,181 @@ export function createContentStore(options) {
348
444
  return encodeCursor({ field: order.field, direction: order.direction, value, id: entry.id });
349
445
  }
350
446
  // ------------------------------------------------------------------- API
447
+ /**
448
+ * The whole of `create`, minus the transaction.
449
+ *
450
+ * Split out so `duplicate` can read the source and write the copy inside a
451
+ * single transaction: the free-slug probe it does between the two would
452
+ * otherwise be a read outside the write's transaction, and a concurrent
453
+ * insert could take the slug it had just found free.
454
+ */
455
+ async function insertEntry(tx, input) {
456
+ const id = input.id ?? newId();
457
+ const status = input.status ?? 'draft';
458
+ const at = stamp();
459
+ const author = input.createdBy ?? null;
460
+ const normalised = normaliseValues(collection, input.values ?? {}, {
461
+ partial: false,
462
+ enforceRequired: status === 'published',
463
+ });
464
+ const zones = normaliseBlocks(collection, input.blocks ?? {}, newId);
465
+ const values = { ...normalised.values };
466
+ if (status === 'published' && collection.fields['publishedAt'] !== undefined) {
467
+ values['publishedAt'] ??= at;
468
+ normalised.columns['publishedAt'] ??= at;
469
+ }
470
+ const columns = [
471
+ 'id',
472
+ 'created_at',
473
+ 'updated_at',
474
+ 'created_by',
475
+ 'updated_by',
476
+ 'status',
477
+ 'locale',
478
+ 'translation_of',
479
+ 'version',
480
+ 'provenance',
481
+ 'provenance_detail',
482
+ ];
483
+ const bound = [
484
+ id,
485
+ at,
486
+ at,
487
+ author,
488
+ author,
489
+ status,
490
+ input.locale ?? defaultLocale,
491
+ input.translationOf ?? null,
492
+ 1,
493
+ input.provenance ?? 'human',
494
+ input.provenanceDetail === undefined || input.provenanceDetail === null
495
+ ? null
496
+ : JSON.stringify(input.provenanceDetail),
497
+ ];
498
+ for (const [name, value] of Object.entries(normalised.columns)) {
499
+ columns.push(columnFor(name));
500
+ bound.push(value);
501
+ }
502
+ await tx.query(sql `insert into ${entries} (${joinFragments(columns.map((column) => identifier(column, dialect)), ', ')}) values (${valueList(bound)})`);
503
+ await writeRelations(tx, id, normalised.relations);
504
+ await writeBlocks(tx, id, 1, zones);
505
+ await writeVersion(tx, id, 1, status, { ...values, ...normalised.relations }, author);
506
+ const row = await loadRow(tx, id);
507
+ if (row === null)
508
+ throw notFound(collection.name, id);
509
+ return liveEntry(tx, row);
510
+ }
511
+ // ------------------------------------------------------------ duplication
512
+ const uniqueFields = Object.entries(collection.fields).filter(([, field]) => field.unique === true && !isColumnless(field));
513
+ async function isTaken(tx, field, locale, candidate) {
514
+ // Uniqueness is per locale, exactly as the index in `tables.ts` declares
515
+ // it: the same slug in French and in English is the normal case.
516
+ const found = await tx.query(sql `select ${identifier('id', dialect)} from ${entries}
517
+ where ${identifier('locale', dialect)} = ${locale}
518
+ and ${identifier(columnFor(field), dialect)} = ${candidate}
519
+ limit ${sqlLimit(1)}`);
520
+ return found.rows.length > 0;
521
+ }
522
+ /**
523
+ * A value for a `unique` field that no row holds yet, derived from the one
524
+ * being copied: `hello` becomes `hello-copy`, then `hello-copy-2`.
525
+ *
526
+ * The unique index stays the enforcement point — this only spares the caller
527
+ * a raw constraint violation on the single most common shape there is (an
528
+ * article with a unique slug).
529
+ */
530
+ async function freeUniqueValue(tx, field, locale, source) {
531
+ for (let attempt = 1; attempt <= 1000; attempt++) {
532
+ const candidate = attempt === 1 ? `${source}-copy` : `${source}-copy-${attempt}`;
533
+ if (!(await isTaken(tx, field, locale, candidate)))
534
+ return candidate;
535
+ }
536
+ throw new CogentaError({
537
+ code: 'CONTENT_INVALID',
538
+ message: `Could not derive a free value for the unique field "${field}".`,
539
+ hint: 'A thousand copies of this value already exist. Pass an explicit value for the field instead.',
540
+ details: { collection: collection.name, field },
541
+ });
542
+ }
351
543
  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);
544
+ create: async (input) => db.transaction((tx) => insertEntry(tx, input), { immediate: true }),
545
+ /**
546
+ * Copies one entry into a new draft.
547
+ *
548
+ * Three decisions are worth stating, because each could reasonably have
549
+ * gone the other way:
550
+ *
551
+ * 1. **The copy starts its own translation family** — `translationOf` is
552
+ * always null, even when the source is itself a translation. Two rows
553
+ * of the same family sharing a locale would make `resolveLocale` pick
554
+ * between them arbitrarily (it takes the first match) and would show
555
+ * the copy in `translations()` as if it were another language. A copy
556
+ * is a new piece of content, not a new language of an old one.
557
+ * 2. **Blocks get fresh keys.** A `_key` anchors comments and RAG chunks;
558
+ * the same key living in two entries would make "which entry does this
559
+ * block belong to" answerable only with the entry id alongside it.
560
+ * 3. **Provenance is carried over, not reset to `human`.** Copying
561
+ * generated content does not make it human-written, and pressing
562
+ * duplicate must not launder it. Pass `provenance` to override.
563
+ *
564
+ * The copy is always a `draft`, its version count restarts at 1 and it
565
+ * inherits none of the source's history — the source's past is the
566
+ * source's.
567
+ */
568
+ duplicate: async (id, duplicateOptions) => db.transaction(async (tx) => {
403
569
  const row = await loadRow(tx, id);
404
570
  if (row === null)
405
571
  throw notFound(collection.name, id);
406
- return liveEntry(tx, row);
572
+ // The working state, not the published one: the admin's duplicate
573
+ // button copies what the editor is looking at.
574
+ const source = await workingEntry(tx, row);
575
+ const overrides = duplicateOptions?.values ?? {};
576
+ const locale = source.locale;
577
+ const values = { ...source.values };
578
+ // Never carried over: a copy has never been published.
579
+ if (collection.fields['publishedAt'] !== undefined)
580
+ values['publishedAt'] = null;
581
+ for (const [field] of uniqueFields) {
582
+ if (Object.hasOwn(overrides, field))
583
+ continue;
584
+ const current = values[field];
585
+ if (current === null || current === undefined || current === '')
586
+ continue;
587
+ if (typeof current !== 'string') {
588
+ throw new CogentaError({
589
+ code: 'CONTENT_INVALID',
590
+ message: `"${field}" is unique and is not text, so a copy of it cannot be derived.`,
591
+ hint: 'Pass a value for the field in duplicate()’s values, so the copy has its own.',
592
+ details: { collection: collection.name, field },
593
+ });
594
+ }
595
+ values[field] = await freeUniqueValue(tx, field, locale, current);
596
+ }
597
+ const blocks = {};
598
+ for (const [zone, list] of Object.entries(source.blocks)) {
599
+ blocks[zone] = list.map((block) => ({ ...block, key: '' }));
600
+ }
601
+ return insertEntry(tx, {
602
+ ...(duplicateOptions?.id === undefined ? {} : { id: duplicateOptions.id }),
603
+ locale,
604
+ translationOf: null,
605
+ status: 'draft',
606
+ createdBy: duplicateOptions?.createdBy ?? null,
607
+ provenance: duplicateOptions?.provenance ?? source.provenance,
608
+ provenanceDetail: duplicateOptions?.provenanceDetail ?? source.provenanceDetail,
609
+ values: { ...values, ...overrides },
610
+ blocks,
611
+ });
407
612
  }, { immediate: true }),
408
613
  read: async (id, readOptions) => {
409
614
  const state = readOptions?.state ?? 'published';
410
615
  const row = await loadRow(db, id);
411
616
  if (row === null)
412
617
  return null;
618
+ // The trash is invisible unless it is asked for by name (ADR-0022) —
619
+ // the same safe-by-default posture as `state` on the line below.
620
+ if (hiddenBy(row, readOptions?.trashed))
621
+ return null;
413
622
  // The safe default: a caller that says nothing gets the published state,
414
623
  // never a draft. The public role has no way to ask for one.
415
624
  if (state === 'published') {
@@ -457,15 +666,82 @@ export function createContentStore(options) {
457
666
  await prune(tx, id, Number(after['version']));
458
667
  return workingEntry(tx, after);
459
668
  }, { immediate: true }),
460
- delete: async (id) => {
461
- const removed = await db.query(sql `delete from ${entries} where ${identifier('id', dialect)} = ${id}`);
669
+ delete: async (id) => db.transaction(async (tx) => {
670
+ const row = await loadRow(tx, id);
671
+ if (row === null)
672
+ return false;
673
+ // Already in the trash: nothing to do, and saying so with `false`
674
+ // matches what the method meant before 2.0 for a missing row.
675
+ if (isTrashed(row))
676
+ return false;
677
+ await assertNotReferenced(tx, id);
678
+ if (!trashEnabled)
679
+ return hardDelete(tx, id);
680
+ const removed = await tx.query(sql `update ${entries} set ${deletedAt} = ${stamp()}
681
+ where ${identifier('id', dialect)} = ${id}`);
462
682
  return removed.rowsAffected > 0;
683
+ }, { immediate: true }),
684
+ untrash: async (id) => db.transaction(async (tx) => {
685
+ const row = await loadRow(tx, id);
686
+ if (row === null)
687
+ throw notFound(collection.name, id);
688
+ if (!isTrashed(row)) {
689
+ throw new CogentaError({
690
+ code: 'CONTENT_NOT_TRASHED',
691
+ message: `"${id}" is not in the "${collection.name}" trash.`,
692
+ hint: 'Only an entry that was trashed can be taken back out. Nothing was changed.',
693
+ details: { collection: collection.name, id },
694
+ });
695
+ }
696
+ // `status` is deliberately untouched: an article that was published
697
+ // when it was trashed comes back published (ADR-0022). Restoring it
698
+ // as a draft would lose information and invite a second, accidental
699
+ // publication.
700
+ await tx.query(sql `update ${entries} set ${deletedAt} = ${null}
701
+ where ${identifier('id', dialect)} = ${id}`);
702
+ const after = await loadRow(tx, id);
703
+ if (after === null)
704
+ throw notFound(collection.name, id);
705
+ return { ...(await liveEntry(tx, after)), state: 'working' };
706
+ }, { immediate: true }),
707
+ purge: async (id) => db.transaction(async (tx) => {
708
+ const row = await loadRow(tx, id);
709
+ if (row === null)
710
+ return false;
711
+ await assertNotReferenced(tx, id);
712
+ return hardDelete(tx, id);
713
+ }, { immediate: true }),
714
+ purgeExpired: async () => {
715
+ const olderThan = new Date(now().getTime() - retainDays * DAY_MS).toISOString();
716
+ if (!trashEnabled)
717
+ return { purged: 0, olderThan };
718
+ return db.transaction(async (tx) => {
719
+ const expired = await tx.query(sql `select ${identifier('id', dialect)} from ${entries}
720
+ where ${deletedAt} is not null and ${deletedAt} < ${olderThan}`);
721
+ let purged = 0;
722
+ for (const row of expired.rows) {
723
+ const id = text(row['id']);
724
+ // Each one is still checked — a sweep that quietly broke a
725
+ // `restrict` relation would be the hole the trash exists to close
726
+ // — but one blocked entry is **skipped**, not fatal: a scheduled
727
+ // sweep that dies on the first stuck row purges nothing ever
728
+ // again, and nobody would notice.
729
+ if (await isReferenced(tx, id))
730
+ continue;
731
+ if (await hardDelete(tx, id))
732
+ purged += 1;
733
+ }
734
+ return { purged, olderThan };
735
+ }, { immediate: true });
463
736
  },
464
737
  list: async (listOptions = {}) => {
465
738
  const order = sortOrder(listOptions);
466
739
  const state = listOptions.state ?? 'published';
467
740
  const size = Math.min(Math.max(listOptions.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
468
741
  const predicates = [];
742
+ const trash = trashPredicate(listOptions.trashed);
743
+ if (trash !== null)
744
+ predicates.push(trash);
469
745
  if (listOptions.status !== undefined) {
470
746
  predicates.push(sql `${identifier('status', dialect)} = ${listOptions.status}`);
471
747
  }
@@ -572,10 +848,15 @@ export function createContentStore(options) {
572
848
  throw notFound(collection.name, id);
573
849
  return { ...(await liveEntry(tx, after)), state: 'working' };
574
850
  }, { immediate: true }),
575
- history: async (id) => {
851
+ history: async (id, historyOptions) => {
576
852
  const row = await loadRow(db, id);
577
853
  if (row === null)
578
854
  throw notFound(collection.name, id);
855
+ // A trashed entry has no history as far as an ordinary caller is
856
+ // concerned: it does not exist for them, and saying "not found" is the
857
+ // same answer `read()` gives.
858
+ if (hiddenBy(row, historyOptions?.trashed))
859
+ throw notFound(collection.name, id);
579
860
  const found = await db.query(sql `select * from ${versions}
580
861
  where ${identifier('entry_id', dialect)} = ${id}
581
862
  order by ${identifier('version', dialect)} desc`);
@@ -660,22 +941,30 @@ export function createContentStore(options) {
660
941
  };
661
942
  return diffContent(await load(from), await load(to));
662
943
  },
663
- translations: async (id) => {
944
+ translations: async (id, translationsOptions) => {
664
945
  const row = await loadRow(db, id);
665
946
  if (row === null)
666
947
  return [];
948
+ if (hiddenBy(row, translationsOptions?.trashed))
949
+ return [];
667
950
  const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
668
951
  const found = await db.query(sql `select * from ${entries}
669
952
  where ${identifier('id', dialect)} = ${sourceId}
670
953
  or ${identifier('translation_of', dialect)} = ${sourceId}
671
954
  order by ${identifier('locale', dialect)} asc`);
672
- return liveEntries(db, found.rows);
955
+ // A trashed member of the family is filtered out here rather than in the
956
+ // query, so that the family is still found through *any* of its members
957
+ // — including one whose source is in the trash.
958
+ const visible = found.rows.filter((member) => !hiddenBy(member, translationsOptions?.trashed));
959
+ return liveEntries(db, visible);
673
960
  },
674
961
  resolveLocale: async (id, locale, resolveOptions) => {
675
962
  const state = resolveOptions.state ?? 'published';
676
963
  const row = await loadRow(db, id);
677
964
  if (row === null)
678
965
  return { outcome: 'notFound' };
966
+ if (hiddenBy(row, resolveOptions.trashed))
967
+ return { outcome: 'notFound' };
679
968
  const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
680
969
  const found = await db.query(sql `select * from ${entries}
681
970
  where ${identifier('id', dialect)} = ${sourceId}
@@ -683,7 +972,8 @@ export function createContentStore(options) {
683
972
  // Publication is per language (ADR-0014): a French entry can be live while
684
973
  // its English translation is still a draft, and the renderer must treat
685
974
  // the draft as if it did not exist.
686
- const visible = found.rows.filter((member) => state === 'working' || text(member['status']) === 'published');
975
+ const visible = found.rows.filter((member) => !hiddenBy(member, resolveOptions.trashed) &&
976
+ (state === 'working' || text(member['status']) === 'published'));
687
977
  const match = visible.find((member) => text(member['locale']) === locale);
688
978
  if (match !== undefined) {
689
979
  const entry = state === 'published' ? await liveEntry(db, match) : await workingEntry(db, match);