@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.
- package/dist/define-collection.d.ts.map +1 -1
- package/dist/define-collection.js +32 -0
- package/dist/define-collection.js.map +1 -1
- package/dist/define-taxonomy.d.ts +9 -0
- package/dist/define-taxonomy.d.ts.map +1 -0
- package/dist/define-taxonomy.js +144 -0
- package/dist/define-taxonomy.js.map +1 -0
- package/dist/fields.d.ts +20 -0
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +14 -0
- package/dist/fields.js.map +1 -1
- package/dist/generate-schema-json.d.ts +28 -4
- package/dist/generate-schema-json.d.ts.map +1 -1
- package/dist/generate-schema-json.js +25 -4
- package/dist/generate-schema-json.js.map +1 -1
- package/dist/generate-types.js +1 -0
- package/dist/generate-types.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/links/check.d.ts +76 -0
- package/dist/links/check.d.ts.map +1 -0
- package/dist/links/check.js +169 -0
- package/dist/links/check.js.map +1 -0
- package/dist/links/extract.d.ts +36 -0
- package/dist/links/extract.d.ts.map +1 -0
- package/dist/links/extract.js +69 -0
- package/dist/links/extract.js.map +1 -0
- package/dist/links/index.d.ts +10 -0
- package/dist/links/index.d.ts.map +1 -0
- package/dist/links/index.js +8 -0
- package/dist/links/index.js.map +1 -0
- package/dist/store/columns.d.ts.map +1 -1
- package/dist/store/columns.js +5 -2
- package/dist/store/columns.js.map +1 -1
- package/dist/store/index.d.ts +11 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +6 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/lifecycle-events.d.ts +72 -0
- package/dist/store/lifecycle-events.d.ts.map +1 -0
- package/dist/store/lifecycle-events.js +73 -0
- package/dist/store/lifecycle-events.js.map +1 -0
- package/dist/store/naming.d.ts +11 -2
- package/dist/store/naming.d.ts.map +1 -1
- package/dist/store/naming.js +13 -1
- package/dist/store/naming.js.map +1 -1
- package/dist/store/read-only.d.ts +9 -3
- package/dist/store/read-only.d.ts.map +1 -1
- package/dist/store/read-only.js +13 -3
- package/dist/store/read-only.js.map +1 -1
- package/dist/store/schema-2-migration.d.ts +32 -0
- package/dist/store/schema-2-migration.d.ts.map +1 -0
- package/dist/store/schema-2-migration.js +136 -0
- package/dist/store/schema-2-migration.js.map +1 -0
- package/dist/store/search-indexing.d.ts +34 -0
- package/dist/store/search-indexing.d.ts.map +1 -0
- package/dist/store/search-indexing.js +65 -0
- package/dist/store/search-indexing.js.map +1 -0
- package/dist/store/store.d.ts +36 -7
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +348 -58
- package/dist/store/store.js.map +1 -1
- package/dist/store/tables.d.ts +15 -3
- package/dist/store/tables.d.ts.map +1 -1
- package/dist/store/tables.js +98 -6
- package/dist/store/tables.js.map +1 -1
- package/dist/store/taxonomy-path.d.ts +25 -0
- package/dist/store/taxonomy-path.d.ts.map +1 -0
- package/dist/store/taxonomy-path.js +64 -0
- package/dist/store/taxonomy-path.js.map +1 -0
- package/dist/store/taxonomy-store.d.ts +62 -0
- package/dist/store/taxonomy-store.d.ts.map +1 -0
- package/dist/store/taxonomy-store.js +292 -0
- package/dist/store/taxonomy-store.js.map +1 -0
- package/dist/store/types.d.ts +42 -2
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/values.d.ts.map +1 -1
- package/dist/store/values.js +4 -1
- package/dist/store/values.js.map +1 -1
- package/dist/system-fields.d.ts +2 -1
- package/dist/system-fields.d.ts.map +1 -1
- package/dist/system-fields.js +4 -0
- package/dist/system-fields.js.map +1 -1
- package/dist/types.d.ts +86 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -3
- package/dist/types.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +5 -1
- package/dist/validation.js.map +1 -1
- package/package.json +2 -2
package/dist/store/store.js
CHANGED
|
@@ -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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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);
|