@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.
- 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 +16 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +9 -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/menu-store.d.ts +99 -0
- package/dist/store/menu-store.d.ts.map +1 -0
- package/dist/store/menu-store.js +418 -0
- package/dist/store/menu-store.js.map +1 -0
- package/dist/store/menu-tables.d.ts +26 -0
- package/dist/store/menu-tables.d.ts.map +1 -0
- package/dist/store/menu-tables.js +79 -0
- package/dist/store/menu-tables.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/scheduled-publish-enqueue.d.ts +37 -0
- package/dist/store/scheduled-publish-enqueue.d.ts.map +1 -0
- package/dist/store/scheduled-publish-enqueue.js +51 -0
- package/dist/store/scheduled-publish-enqueue.js.map +1 -0
- 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 +45 -8
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +398 -59
- 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}`);
|
|
@@ -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(
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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);
|