@cogenta/schema 0.1.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 (169) hide show
  1. package/dist/define-collection.d.ts +8 -0
  2. package/dist/define-collection.d.ts.map +1 -0
  3. package/dist/define-collection.js +311 -0
  4. package/dist/define-collection.js.map +1 -0
  5. package/dist/errors.d.ts +16 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +18 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/fields.d.ts +117 -0
  10. package/dist/fields.d.ts.map +1 -0
  11. package/dist/fields.js +107 -0
  12. package/dist/fields.js.map +1 -0
  13. package/dist/generate-schema-json.d.ts +61 -0
  14. package/dist/generate-schema-json.d.ts.map +1 -0
  15. package/dist/generate-schema-json.js +128 -0
  16. package/dist/generate-schema-json.js.map +1 -0
  17. package/dist/generate-types.d.ts +5 -0
  18. package/dist/generate-types.d.ts.map +1 -0
  19. package/dist/generate-types.js +220 -0
  20. package/dist/generate-types.js.map +1 -0
  21. package/dist/id.d.ts +8 -0
  22. package/dist/id.d.ts.map +1 -0
  23. package/dist/id.js +8 -0
  24. package/dist/id.js.map +1 -0
  25. package/dist/index.d.ts +28 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/rich-text.d.ts +164 -0
  30. package/dist/rich-text.d.ts.map +1 -0
  31. package/dist/rich-text.js +97 -0
  32. package/dist/rich-text.js.map +1 -0
  33. package/dist/routing/ids.d.ts +18 -0
  34. package/dist/routing/ids.d.ts.map +1 -0
  35. package/dist/routing/ids.js +41 -0
  36. package/dist/routing/ids.js.map +1 -0
  37. package/dist/routing/index.d.ts +13 -0
  38. package/dist/routing/index.d.ts.map +1 -0
  39. package/dist/routing/index.js +7 -0
  40. package/dist/routing/index.js.map +1 -0
  41. package/dist/routing/redirects.d.ts +76 -0
  42. package/dist/routing/redirects.d.ts.map +1 -0
  43. package/dist/routing/redirects.js +237 -0
  44. package/dist/routing/redirects.js.map +1 -0
  45. package/dist/routing/resolve.d.ts +38 -0
  46. package/dist/routing/resolve.d.ts.map +1 -0
  47. package/dist/routing/resolve.js +23 -0
  48. package/dist/routing/resolve.js.map +1 -0
  49. package/dist/routing/router.d.ts +51 -0
  50. package/dist/routing/router.d.ts.map +1 -0
  51. package/dist/routing/router.js +132 -0
  52. package/dist/routing/router.js.map +1 -0
  53. package/dist/routing/slug-change.d.ts +39 -0
  54. package/dist/routing/slug-change.d.ts.map +1 -0
  55. package/dist/routing/slug-change.js +44 -0
  56. package/dist/routing/slug-change.js.map +1 -0
  57. package/dist/routing/slug.d.ts +82 -0
  58. package/dist/routing/slug.d.ts.map +1 -0
  59. package/dist/routing/slug.js +124 -0
  60. package/dist/routing/slug.js.map +1 -0
  61. package/dist/routing/slugify.d.ts +29 -0
  62. package/dist/routing/slugify.d.ts.map +1 -0
  63. package/dist/routing/slugify.js +104 -0
  64. package/dist/routing/slugify.js.map +1 -0
  65. package/dist/scheduling/index.d.ts +3 -0
  66. package/dist/scheduling/index.d.ts.map +1 -0
  67. package/dist/scheduling/index.js +2 -0
  68. package/dist/scheduling/index.js.map +1 -0
  69. package/dist/scheduling/publish.d.ts +71 -0
  70. package/dist/scheduling/publish.d.ts.map +1 -0
  71. package/dist/scheduling/publish.js +122 -0
  72. package/dist/scheduling/publish.js.map +1 -0
  73. package/dist/search/extract.d.ts +25 -0
  74. package/dist/search/extract.d.ts.map +1 -0
  75. package/dist/search/extract.js +188 -0
  76. package/dist/search/extract.js.map +1 -0
  77. package/dist/search/index.d.ts +36 -0
  78. package/dist/search/index.d.ts.map +1 -0
  79. package/dist/search/index.js +44 -0
  80. package/dist/search/index.js.map +1 -0
  81. package/dist/search/mysql.d.ts +7 -0
  82. package/dist/search/mysql.d.ts.map +1 -0
  83. package/dist/search/mysql.js +121 -0
  84. package/dist/search/mysql.js.map +1 -0
  85. package/dist/search/postgres.d.ts +9 -0
  86. package/dist/search/postgres.d.ts.map +1 -0
  87. package/dist/search/postgres.js +167 -0
  88. package/dist/search/postgres.js.map +1 -0
  89. package/dist/search/query.d.ts +35 -0
  90. package/dist/search/query.d.ts.map +1 -0
  91. package/dist/search/query.js +116 -0
  92. package/dist/search/query.js.map +1 -0
  93. package/dist/search/sqlite.d.ts +16 -0
  94. package/dist/search/sqlite.d.ts.map +1 -0
  95. package/dist/search/sqlite.js +166 -0
  96. package/dist/search/sqlite.js.map +1 -0
  97. package/dist/search/table.d.ts +49 -0
  98. package/dist/search/table.d.ts.map +1 -0
  99. package/dist/search/table.js +86 -0
  100. package/dist/search/table.js.map +1 -0
  101. package/dist/search/text.d.ts +42 -0
  102. package/dist/search/text.d.ts.map +1 -0
  103. package/dist/search/text.js +56 -0
  104. package/dist/search/text.js.map +1 -0
  105. package/dist/search/types.d.ts +98 -0
  106. package/dist/search/types.d.ts.map +1 -0
  107. package/dist/search/types.js +2 -0
  108. package/dist/search/types.js.map +1 -0
  109. package/dist/store/columns.d.ts +33 -0
  110. package/dist/store/columns.d.ts.map +1 -0
  111. package/dist/store/columns.js +113 -0
  112. package/dist/store/columns.js.map +1 -0
  113. package/dist/store/cursor.d.ts +23 -0
  114. package/dist/store/cursor.d.ts.map +1 -0
  115. package/dist/store/cursor.js +36 -0
  116. package/dist/store/cursor.js.map +1 -0
  117. package/dist/store/diff.d.ts +59 -0
  118. package/dist/store/diff.d.ts.map +1 -0
  119. package/dist/store/diff.js +124 -0
  120. package/dist/store/diff.js.map +1 -0
  121. package/dist/store/fragments.d.ts +13 -0
  122. package/dist/store/fragments.d.ts.map +1 -0
  123. package/dist/store/fragments.js +25 -0
  124. package/dist/store/fragments.js.map +1 -0
  125. package/dist/store/index.d.ts +22 -0
  126. package/dist/store/index.d.ts.map +1 -0
  127. package/dist/store/index.js +16 -0
  128. package/dist/store/index.js.map +1 -0
  129. package/dist/store/naming.d.ts +25 -0
  130. package/dist/store/naming.d.ts.map +1 -0
  131. package/dist/store/naming.js +80 -0
  132. package/dist/store/naming.js.map +1 -0
  133. package/dist/store/read-only.d.ts +16 -0
  134. package/dist/store/read-only.d.ts.map +1 -0
  135. package/dist/store/read-only.js +36 -0
  136. package/dist/store/read-only.js.map +1 -0
  137. package/dist/store/store.d.ts +52 -0
  138. package/dist/store/store.d.ts.map +1 -0
  139. package/dist/store/store.js +706 -0
  140. package/dist/store/store.js.map +1 -0
  141. package/dist/store/tables.d.ts +40 -0
  142. package/dist/store/tables.d.ts.map +1 -0
  143. package/dist/store/tables.js +267 -0
  144. package/dist/store/tables.js.map +1 -0
  145. package/dist/store/types.d.ts +105 -0
  146. package/dist/store/types.d.ts.map +1 -0
  147. package/dist/store/types.js +2 -0
  148. package/dist/store/types.js.map +1 -0
  149. package/dist/store/uuid.d.ts +6 -0
  150. package/dist/store/uuid.d.ts.map +1 -0
  151. package/dist/store/uuid.js +64 -0
  152. package/dist/store/uuid.js.map +1 -0
  153. package/dist/store/values.d.ts +40 -0
  154. package/dist/store/values.d.ts.map +1 -0
  155. package/dist/store/values.js +160 -0
  156. package/dist/store/values.js.map +1 -0
  157. package/dist/system-fields.d.ts +60 -0
  158. package/dist/system-fields.d.ts.map +1 -0
  159. package/dist/system-fields.js +73 -0
  160. package/dist/system-fields.js.map +1 -0
  161. package/dist/types.d.ts +101 -0
  162. package/dist/types.d.ts.map +1 -0
  163. package/dist/types.js +32 -0
  164. package/dist/types.js.map +1 -0
  165. package/dist/validation.d.ts +54 -0
  166. package/dist/validation.d.ts.map +1 -0
  167. package/dist/validation.js +183 -0
  168. package/dist/validation.js.map +1 -0
  169. package/package.json +43 -0
@@ -0,0 +1,706 @@
1
+ import { CogentaError, identifier, sql, limit as sqlLimit, } from '@cogenta/core';
2
+ import { newId as uuidv7 } from '../id.js';
3
+ import { isColumnless } from './columns.js';
4
+ import { decodeCursor, encodeCursor } from './cursor.js';
5
+ import { diffContent } from './diff.js';
6
+ import { joinFragments, valueList } from './fragments.js';
7
+ import { blocksTable, columnFor, entriesTable, relationTable, versionsTable } from './naming.js';
8
+ import { relationsOf } from './tables.js';
9
+ import { decodeFieldValue, normaliseBlocks, normaliseValues } from './values.js';
10
+ const DEFAULT_PAGE_SIZE = 25;
11
+ const MAX_PAGE_SIZE = 200;
12
+ const DEFAULT_SORT = { field: 'id', direction: 'desc' };
13
+ /** Enough history to answer "what changed last week" without unbounded growth. */
14
+ const DEFAULT_KEEP = 20;
15
+ const SORT_COLUMNS = { id: 'id', createdAt: 'created_at', updatedAt: 'updated_at' };
16
+ function notFound(collection, id) {
17
+ return new CogentaError({
18
+ code: 'CONTENT_NOT_FOUND',
19
+ message: `No entry "${id}" in the "${collection}" collection.`,
20
+ hint: 'Check the identifier, and remember that an entry is per locale: a translation has its own id.',
21
+ details: { collection, id },
22
+ });
23
+ }
24
+ function text(value) {
25
+ return typeof value === 'string' ? value : String(value);
26
+ }
27
+ function nullableText(value) {
28
+ return value === null || value === undefined ? null : text(value);
29
+ }
30
+ export function createContentStore(options) {
31
+ const { db, collection } = options;
32
+ const dialect = db.dialect;
33
+ const now = options.now ?? (() => new Date());
34
+ const newId = options.newId ?? uuidv7;
35
+ const defaultLocale = options.defaultLocale ?? 'en';
36
+ const entries = identifier(entriesTable(collection.name), dialect);
37
+ const versions = identifier(versionsTable(collection.name), dialect);
38
+ const blocks = identifier(blocksTable(collection.name), dialect);
39
+ const relations = relationsOf(collection).filter((relation) => relation.many);
40
+ const columnFields = Object.entries(collection.fields).filter(([, field]) => !isColumnless(field));
41
+ const zoneNames = Object.entries(collection.fields)
42
+ .filter(([, field]) => field.kind === 'blocks')
43
+ .map(([name]) => name);
44
+ const keep = Math.max(2, collection.versioning?.history === true ? (collection.versioning.keep ?? DEFAULT_KEEP) : 2);
45
+ const draftsEnabled = collection.versioning?.drafts === true;
46
+ const stamp = () => now().toISOString();
47
+ // ---------------------------------------------------------------- reading
48
+ async function loadRow(tx, id) {
49
+ const found = await tx.query(sql `select * from ${entries} where ${identifier('id', dialect)} = ${id}`);
50
+ return found.rows[0] ?? null;
51
+ }
52
+ function valuesFromRow(row) {
53
+ const values = {};
54
+ for (const [name, field] of columnFields) {
55
+ values[name] = decodeFieldValue(field, row[columnFor(name)]);
56
+ }
57
+ return values;
58
+ }
59
+ async function loadRelations(tx, entryIds) {
60
+ const byEntry = new Map();
61
+ for (const id of entryIds)
62
+ byEntry.set(id, {});
63
+ if (entryIds.length === 0 || relations.length === 0)
64
+ return byEntry;
65
+ for (const relation of relations) {
66
+ const table = identifier(relationTable(collection.name, relation.field), dialect);
67
+ const found = await tx.query(sql `select ${identifier('entry_id', dialect)}, ${identifier('target_id', dialect)}
68
+ from ${table}
69
+ where ${identifier('entry_id', dialect)} in (${valueList([...entryIds])})
70
+ order by ${identifier('position', dialect)} asc`);
71
+ for (const row of found.rows) {
72
+ const bucket = byEntry.get(text(row.entry_id));
73
+ if (bucket === undefined)
74
+ continue;
75
+ const targets = bucket[relation.field] ?? [];
76
+ targets.push(text(row.target_id));
77
+ bucket[relation.field] = targets;
78
+ }
79
+ }
80
+ for (const bucket of byEntry.values()) {
81
+ for (const relation of relations)
82
+ bucket[relation.field] ??= [];
83
+ }
84
+ return byEntry;
85
+ }
86
+ async function loadBlocks(tx, pairs) {
87
+ const byEntry = new Map();
88
+ for (const [id] of pairs)
89
+ byEntry.set(id, emptyZones());
90
+ if (pairs.length === 0 || zoneNames.length === 0)
91
+ return byEntry;
92
+ // One statement for the whole page: a block zone per entry would be the N+1
93
+ // the L1 spec warns about, and a list of ten entries is the common case.
94
+ const predicate = joinFragments(pairs.map(([id, version]) => sql `(${identifier('entry_id', dialect)} = ${id} and ${identifier('version', dialect)} = ${version})`), ' or ');
95
+ const found = await tx.query(sql `select ${identifier('entry_id', dialect)}, ${identifier('zone', dialect)},
96
+ ${identifier('block_key', dialect)}, ${identifier('block_type', dialect)},
97
+ ${identifier('data', dialect)}
98
+ from ${blocks}
99
+ where ${predicate}
100
+ order by ${identifier('zone', dialect)} asc, ${identifier('position', dialect)} asc`);
101
+ const collected = new Map();
102
+ for (const row of found.rows) {
103
+ const entryId = text(row.entry_id);
104
+ const zones = collected.get(entryId) ?? {};
105
+ collected.set(entryId, zones);
106
+ const zone = text(row.zone);
107
+ const list = zones[zone] ?? [];
108
+ list.push({
109
+ key: text(row.block_key),
110
+ type: text(row.block_type),
111
+ data: parseObject(row.data),
112
+ });
113
+ zones[zone] = list;
114
+ }
115
+ for (const [id, zones] of collected)
116
+ byEntry.set(id, { ...emptyZones(), ...zones });
117
+ return byEntry;
118
+ }
119
+ function emptyZones() {
120
+ return Object.fromEntries(zoneNames.map((zone) => [zone, []]));
121
+ }
122
+ /** Only the four keys contract A declares; anything else is dropped, not trusted. */
123
+ function parseProvenanceDetail(raw) {
124
+ if (raw === null || raw === undefined)
125
+ return null;
126
+ const parsed = parseObject(raw);
127
+ const detail = {};
128
+ for (const key of ['agent', 'model', 'at', 'prompt']) {
129
+ const value = parsed[key];
130
+ if (typeof value === 'string')
131
+ detail[key] = value;
132
+ }
133
+ return Object.keys(detail).length === 0 ? null : detail;
134
+ }
135
+ function parseObject(raw) {
136
+ if (typeof raw !== 'string')
137
+ return {};
138
+ try {
139
+ const parsed = JSON.parse(raw);
140
+ return typeof parsed === 'object' && parsed !== null
141
+ ? parsed
142
+ : {};
143
+ }
144
+ catch {
145
+ return {};
146
+ }
147
+ }
148
+ function publishedAtOf(values, status) {
149
+ // Contract A declares `publishedAt` as an ordinary field of the collection
150
+ // (see its own example), so the engine maintains it when it is there rather
151
+ // than shadowing it with a system column of the same name.
152
+ const declared = collection.fields['publishedAt'];
153
+ if (declared === undefined)
154
+ return null;
155
+ const value = values['publishedAt'];
156
+ if (typeof value !== 'string')
157
+ return null;
158
+ return status === 'published' || value !== '' ? value : null;
159
+ }
160
+ function toEntry(row, values, zones, state, overrides = {}) {
161
+ const status = (overrides.status ?? text(row['status']));
162
+ return {
163
+ id: text(row['id']),
164
+ createdAt: text(row['created_at']),
165
+ updatedAt: text(row['updated_at']),
166
+ createdBy: nullableText(row['created_by']),
167
+ updatedBy: nullableText(row['updated_by']),
168
+ status,
169
+ locale: text(row['locale']),
170
+ translationOf: nullableText(row['translation_of']),
171
+ version: Number(overrides.version ?? row['version']),
172
+ provenance: text(row['provenance']),
173
+ provenanceDetail: parseProvenanceDetail(row['provenance_detail']),
174
+ publishedAt: publishedAtOf(values, status),
175
+ state,
176
+ values: values,
177
+ blocks: zones,
178
+ };
179
+ }
180
+ /**
181
+ * The live state of a whole page, in a fixed number of queries.
182
+ *
183
+ * One query per row for its blocks and its relations is the N+1 the L1 spec
184
+ * names; a page of twenty entries would be sixty round trips. Batching here
185
+ * means every caller gets it, including the ones written later.
186
+ */
187
+ async function liveEntries(tx, rows) {
188
+ if (rows.length === 0)
189
+ return [];
190
+ const ids = rows.map((row) => text(row['id']));
191
+ const related = await loadRelations(tx, ids);
192
+ const zones = await loadBlocks(tx, rows.map((row) => [text(row['id']), Number(row['version'])]));
193
+ return rows.map((row) => {
194
+ const id = text(row['id']);
195
+ const values = { ...valuesFromRow(row), ...(related.get(id) ?? {}) };
196
+ return toEntry(row, values, zones.get(id) ?? emptyZones(), 'published');
197
+ });
198
+ }
199
+ async function liveEntry(tx, row) {
200
+ const [entry] = await liveEntries(tx, [row]);
201
+ if (entry === undefined)
202
+ throw notFound(collection.name, text(row['id']));
203
+ return entry;
204
+ }
205
+ async function latestVersionRow(tx, id) {
206
+ const found = await tx.query(sql `select * from ${versions}
207
+ where ${identifier('entry_id', dialect)} = ${id}
208
+ order by ${identifier('version', dialect)} desc
209
+ limit ${sqlLimit(1)}`);
210
+ return found.rows[0] ?? null;
211
+ }
212
+ async function versionRow(tx, id, version) {
213
+ const found = await tx.query(sql `select * from ${versions}
214
+ where ${identifier('entry_id', dialect)} = ${id}
215
+ and ${identifier('version', dialect)} = ${version}`);
216
+ return found.rows[0] ?? null;
217
+ }
218
+ async function snapshotOf(tx, id, row) {
219
+ const zones = await loadBlocks(tx, [[id, Number(row.version)]]);
220
+ return {
221
+ values: parseObject(row.data),
222
+ blocks: zones.get(id) ?? emptyZones(),
223
+ };
224
+ }
225
+ /**
226
+ * The working state: the newest version when it is ahead of the live row,
227
+ * the live row otherwise.
228
+ */
229
+ async function workingEntry(tx, row) {
230
+ const id = text(row['id']);
231
+ const latest = await latestVersionRow(tx, id);
232
+ if (latest === null || Number(latest.version) <= Number(row['version'])) {
233
+ const live = await liveEntry(tx, row);
234
+ return { ...live, state: 'working' };
235
+ }
236
+ const snapshot = await snapshotOf(tx, id, latest);
237
+ return toEntry(row, snapshot.values, snapshot.blocks, 'working', {
238
+ version: Number(latest.version),
239
+ status: text(latest.status),
240
+ });
241
+ }
242
+ // ---------------------------------------------------------------- writing
243
+ async function writeBlocks(tx, entryId, version, zones) {
244
+ await tx.query(sql `delete from ${blocks}
245
+ where ${identifier('entry_id', dialect)} = ${entryId}
246
+ and ${identifier('version', dialect)} = ${version}`);
247
+ for (const [zone, list] of Object.entries(zones)) {
248
+ for (const [position, block] of list.entries()) {
249
+ await tx.query(sql `insert into ${blocks} (${identifier('id', dialect)}, ${identifier('entry_id', dialect)},
250
+ ${identifier('version', dialect)}, ${identifier('zone', dialect)},
251
+ ${identifier('position', dialect)}, ${identifier('block_key', dialect)},
252
+ ${identifier('block_type', dialect)}, ${identifier('data', dialect)})
253
+ values (${newId()}, ${entryId}, ${version}, ${zone}, ${position},
254
+ ${block.key}, ${block.type}, ${JSON.stringify(block.data)})`);
255
+ }
256
+ }
257
+ }
258
+ async function writeRelations(tx, entryId, values) {
259
+ for (const relation of relations) {
260
+ const table = identifier(relationTable(collection.name, relation.field), dialect);
261
+ const targets = values[relation.field];
262
+ if (!Array.isArray(targets))
263
+ continue;
264
+ await tx.query(sql `delete from ${table} where ${identifier('entry_id', dialect)} = ${entryId}`);
265
+ for (const [position, target] of targets.entries()) {
266
+ await tx.query(sql `insert into ${table} (${identifier('entry_id', dialect)},
267
+ ${identifier('target_id', dialect)}, ${identifier('position', dialect)})
268
+ values (${entryId}, ${text(target)}, ${position})`);
269
+ }
270
+ }
271
+ }
272
+ async function writeVersion(tx, entryId, version, status, values, author) {
273
+ await tx.query(sql `delete from ${versions}
274
+ where ${identifier('entry_id', dialect)} = ${entryId}
275
+ and ${identifier('version', dialect)} = ${version}`);
276
+ await tx.query(sql `insert into ${versions} (${identifier('id', dialect)}, ${identifier('entry_id', dialect)},
277
+ ${identifier('version', dialect)}, ${identifier('status', dialect)},
278
+ ${identifier('data', dialect)}, ${identifier('created_at', dialect)},
279
+ ${identifier('created_by', dialect)})
280
+ values (${newId()}, ${entryId}, ${version}, ${status},
281
+ ${JSON.stringify(values)}, ${stamp()}, ${author})`);
282
+ }
283
+ /** Keeps the newest `keep` versions, and never the live one. */
284
+ async function prune(tx, entryId, liveVersion) {
285
+ const found = await tx.query(sql `select ${identifier('version', dialect)} from ${versions}
286
+ where ${identifier('entry_id', dialect)} = ${entryId}
287
+ order by ${identifier('version', dialect)} desc`);
288
+ const doomed = found.rows
289
+ .map((row) => Number(row.version))
290
+ .slice(keep)
291
+ .filter((version) => version !== liveVersion);
292
+ if (doomed.length === 0)
293
+ return;
294
+ const list = valueList(doomed);
295
+ await tx.query(sql `delete from ${versions}
296
+ where ${identifier('entry_id', dialect)} = ${entryId}
297
+ and ${identifier('version', dialect)} in (${list})`);
298
+ await tx.query(sql `delete from ${blocks}
299
+ where ${identifier('entry_id', dialect)} = ${entryId}
300
+ and ${identifier('version', dialect)} in (${list})`);
301
+ }
302
+ async function writeLiveColumns(tx, id, columns, system) {
303
+ const assignments = [];
304
+ for (const [name, value] of Object.entries(system)) {
305
+ assignments.push(sql `${identifier(name, dialect)} = ${value}`);
306
+ }
307
+ for (const [name, value] of Object.entries(columns)) {
308
+ assignments.push(sql `${identifier(columnFor(name), dialect)} = ${value}`);
309
+ }
310
+ await tx.query(sql `update ${entries} set ${joinFragments(assignments, ', ')}
311
+ where ${identifier('id', dialect)} = ${id}`);
312
+ }
313
+ // ------------------------------------------------------------ pagination
314
+ function sortOrder(options) {
315
+ return options.sort ?? DEFAULT_SORT;
316
+ }
317
+ function keysetPredicate(cursor) {
318
+ const column = identifier(SORT_COLUMNS[cursor.field], dialect);
319
+ const id = identifier('id', dialect);
320
+ const comparison = cursor.direction === 'asc' ? '>' : '<';
321
+ // Strictly after the last row handed out, in the same order. A row inserted
322
+ // concurrently is either before that point — and was already returned — or
323
+ // after it, and comes on a later page. Nothing shifts, which an offset
324
+ // cannot promise.
325
+ if (cursor.field === 'id') {
326
+ return sql `${id} ${rawOperator(comparison)} ${cursor.id}`;
327
+ }
328
+ return sql `(${column} ${rawOperator(comparison)} ${cursor.value}
329
+ or (${column} = ${cursor.value} and ${id} ${rawOperator(comparison)} ${cursor.id}))`;
330
+ }
331
+ function rawOperator(comparison) {
332
+ // The operator comes from a closed set in this file, never from a caller.
333
+ return comparison === '<' ? sql `<` : sql `>`;
334
+ }
335
+ function orderClause(order) {
336
+ const direction = order.direction === 'asc' ? sql `asc` : sql `desc`;
337
+ const column = identifier(SORT_COLUMNS[order.field], dialect);
338
+ // The id is always the tie-breaker: two rows created in the same
339
+ // millisecond must still have one stable order, or a cursor could skip one.
340
+ return sql `${column} ${direction}, ${identifier('id', dialect)} ${direction}`;
341
+ }
342
+ function cursorFor(entry, order) {
343
+ const value = order.field === 'id'
344
+ ? entry.id
345
+ : order.field === 'createdAt'
346
+ ? entry.createdAt
347
+ : entry.updatedAt;
348
+ return encodeCursor({ field: order.field, direction: order.direction, value, id: entry.id });
349
+ }
350
+ // ------------------------------------------------------------------- API
351
+ 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);
403
+ const row = await loadRow(tx, id);
404
+ if (row === null)
405
+ throw notFound(collection.name, id);
406
+ return liveEntry(tx, row);
407
+ }, { immediate: true }),
408
+ read: async (id, readOptions) => {
409
+ const state = readOptions?.state ?? 'published';
410
+ const row = await loadRow(db, id);
411
+ if (row === null)
412
+ return null;
413
+ // The safe default: a caller that says nothing gets the published state,
414
+ // never a draft. The public role has no way to ask for one.
415
+ if (state === 'published') {
416
+ return text(row['status']) === 'published' ? liveEntry(db, row) : null;
417
+ }
418
+ return workingEntry(db, row);
419
+ },
420
+ update: async (id, input) => db.transaction(async (tx) => {
421
+ const row = await loadRow(tx, id);
422
+ if (row === null)
423
+ throw notFound(collection.name, id);
424
+ const working = await workingEntry(tx, row);
425
+ const next = working.version + 1;
426
+ const author = input.updatedBy ?? nullableText(row['updated_by']);
427
+ const merged = { ...working.values, ...(input.values ?? {}) };
428
+ const normalised = normaliseValues(collection, merged, {
429
+ partial: false,
430
+ enforceRequired: false,
431
+ });
432
+ const zones = normaliseBlocks(collection, { ...working.blocks, ...(input.blocks ?? {}) }, newId);
433
+ const snapshot = { ...normalised.values, ...normalised.relations };
434
+ await writeBlocks(tx, id, next, zones);
435
+ await writeVersion(tx, id, next, 'draft', snapshot, author);
436
+ // With drafts on, editing a published entry must not touch what the
437
+ // public sees: the change lands as a version and waits for publish().
438
+ const overlayOnly = draftsEnabled && text(row['status']) === 'published';
439
+ if (!overlayOnly) {
440
+ const system = {
441
+ updated_at: stamp(),
442
+ updated_by: author,
443
+ version: next,
444
+ };
445
+ if (input.provenance !== undefined)
446
+ system['provenance'] = input.provenance;
447
+ if (input.provenanceDetail !== undefined) {
448
+ system['provenance_detail'] =
449
+ input.provenanceDetail === null ? null : JSON.stringify(input.provenanceDetail);
450
+ }
451
+ await writeLiveColumns(tx, id, normalised.columns, system);
452
+ await writeRelations(tx, id, normalised.relations);
453
+ }
454
+ const after = await loadRow(tx, id);
455
+ if (after === null)
456
+ throw notFound(collection.name, id);
457
+ await prune(tx, id, Number(after['version']));
458
+ return workingEntry(tx, after);
459
+ }, { immediate: true }),
460
+ delete: async (id) => {
461
+ const removed = await db.query(sql `delete from ${entries} where ${identifier('id', dialect)} = ${id}`);
462
+ return removed.rowsAffected > 0;
463
+ },
464
+ list: async (listOptions = {}) => {
465
+ const order = sortOrder(listOptions);
466
+ const state = listOptions.state ?? 'published';
467
+ const size = Math.min(Math.max(listOptions.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
468
+ const predicates = [];
469
+ if (listOptions.status !== undefined) {
470
+ predicates.push(sql `${identifier('status', dialect)} = ${listOptions.status}`);
471
+ }
472
+ else if (state === 'published') {
473
+ predicates.push(sql `${identifier('status', dialect)} = ${'published'}`);
474
+ }
475
+ if (listOptions.locale !== undefined) {
476
+ predicates.push(sql `${identifier('locale', dialect)} = ${listOptions.locale}`);
477
+ }
478
+ if (listOptions.translationOf !== undefined) {
479
+ predicates.push(listOptions.translationOf === null
480
+ ? sql `${identifier('translation_of', dialect)} is null`
481
+ : sql `${identifier('translation_of', dialect)} = ${listOptions.translationOf}`);
482
+ }
483
+ for (const [field, value] of Object.entries(listOptions.where ?? {})) {
484
+ const definition = collection.fields[field];
485
+ if (definition === undefined || isColumnless(definition)) {
486
+ throw new CogentaError({
487
+ code: 'CONTENT_INVALID',
488
+ message: `Cannot filter "${collection.name}" on "${field}".`,
489
+ hint: 'Filters apply to declared fields that have a column: not to block zones or to-many relations.',
490
+ details: { collection: collection.name, field },
491
+ });
492
+ }
493
+ predicates.push(value === null
494
+ ? sql `${identifier(columnFor(field), dialect)} is null`
495
+ : sql `${identifier(columnFor(field), dialect)} = ${value}`);
496
+ }
497
+ if (listOptions.cursor !== undefined) {
498
+ predicates.push(keysetPredicate(decodeCursor(listOptions.cursor, order)));
499
+ }
500
+ const where = predicates.length === 0 ? sql `` : sql ` where ${joinFragments(predicates, ' and ')}`;
501
+ // One more row than asked for: its existence is the answer to "is there a
502
+ // next page", without a second count query that would race the inserts.
503
+ const found = await db.query(sql `select * from ${entries}${where} order by ${orderClause(order)} limit ${sqlLimit(size + 1)}`);
504
+ const rows = found.rows.slice(0, size);
505
+ let items;
506
+ if (state === 'published') {
507
+ items = await liveEntries(db, rows);
508
+ }
509
+ else {
510
+ // The working state of an entry is its newest version row, which cannot
511
+ // be reached in the same pass; the admin list pays for what it asks.
512
+ items = [];
513
+ for (const row of rows)
514
+ items.push(await workingEntry(db, row));
515
+ }
516
+ const last = items.at(-1);
517
+ const hasMore = found.rows.length > size;
518
+ return {
519
+ items,
520
+ hasMore,
521
+ nextCursor: hasMore && last !== undefined ? cursorFor(last, order) : null,
522
+ };
523
+ },
524
+ publish: async (id, publishOptions) => db.transaction(async (tx) => {
525
+ const row = await loadRow(tx, id);
526
+ if (row === null)
527
+ throw notFound(collection.name, id);
528
+ const working = await workingEntry(tx, row);
529
+ const at = stamp();
530
+ const author = publishOptions?.publishedBy ?? nullableText(row['updated_by']);
531
+ const values = { ...working.values };
532
+ if (collection.fields['publishedAt'] !== undefined && values['publishedAt'] == null) {
533
+ values['publishedAt'] = at;
534
+ }
535
+ // Publication is the moment `required` starts to mean something: a
536
+ // half-written draft can be saved, but it cannot go out.
537
+ const normalised = normaliseValues(collection, values, {
538
+ partial: false,
539
+ enforceRequired: true,
540
+ });
541
+ await writeLiveColumns(tx, id, normalised.columns, {
542
+ status: 'published',
543
+ version: working.version,
544
+ updated_at: at,
545
+ updated_by: author,
546
+ });
547
+ await writeRelations(tx, id, normalised.relations);
548
+ // The version row is updated rather than rewritten: its `created_at`
549
+ // is when the draft was written, and publication must not erase it.
550
+ await tx.query(sql `update ${versions}
551
+ set ${identifier('status', dialect)} = ${'published'},
552
+ ${identifier('data', dialect)} = ${JSON.stringify({
553
+ ...normalised.values,
554
+ ...normalised.relations,
555
+ })}
556
+ where ${identifier('entry_id', dialect)} = ${id}
557
+ and ${identifier('version', dialect)} = ${working.version}`);
558
+ await prune(tx, id, working.version);
559
+ const after = await loadRow(tx, id);
560
+ if (after === null)
561
+ throw notFound(collection.name, id);
562
+ return liveEntry(tx, after);
563
+ }, { immediate: true }),
564
+ unpublish: async (id, unpublishOptions) => db.transaction(async (tx) => {
565
+ const row = await loadRow(tx, id);
566
+ if (row === null)
567
+ throw notFound(collection.name, id);
568
+ const status = unpublishOptions?.status ?? 'draft';
569
+ await writeLiveColumns(tx, id, {}, { status, updated_at: stamp() });
570
+ const after = await loadRow(tx, id);
571
+ if (after === null)
572
+ throw notFound(collection.name, id);
573
+ return { ...(await liveEntry(tx, after)), state: 'working' };
574
+ }, { immediate: true }),
575
+ history: async (id) => {
576
+ const row = await loadRow(db, id);
577
+ if (row === null)
578
+ throw notFound(collection.name, id);
579
+ const found = await db.query(sql `select * from ${versions}
580
+ where ${identifier('entry_id', dialect)} = ${id}
581
+ order by ${identifier('version', dialect)} desc`);
582
+ const live = Number(row['version']);
583
+ return found.rows.map((version) => ({
584
+ version: Number(version.version),
585
+ status: text(version.status),
586
+ createdAt: text(version.created_at),
587
+ createdBy: nullableText(version.created_by),
588
+ live: Number(version.version) === live,
589
+ }));
590
+ },
591
+ readVersion: async (id, version) => {
592
+ const row = await loadRow(db, id);
593
+ if (row === null)
594
+ return null;
595
+ const found = await versionRow(db, id, version);
596
+ if (found === null)
597
+ return null;
598
+ const snapshot = await snapshotOf(db, id, found);
599
+ return toEntry(row, snapshot.values, snapshot.blocks, 'working', {
600
+ version,
601
+ status: text(found.status),
602
+ });
603
+ },
604
+ restore: async (id, version, restoreOptions) => db.transaction(async (tx) => {
605
+ const row = await loadRow(tx, id);
606
+ if (row === null)
607
+ throw notFound(collection.name, id);
608
+ const found = await versionRow(tx, id, version);
609
+ if (found === null) {
610
+ throw new CogentaError({
611
+ code: 'CONTENT_NOT_FOUND',
612
+ message: `Version ${version} of "${id}" is no longer kept.`,
613
+ hint: `This collection keeps ${keep} versions. Older ones are pruned on write.`,
614
+ details: { collection: collection.name, id, version, keep },
615
+ });
616
+ }
617
+ const snapshot = await snapshotOf(tx, id, found);
618
+ const working = await workingEntry(tx, row);
619
+ const next = working.version + 1;
620
+ const author = restoreOptions?.updatedBy ?? nullableText(row['updated_by']);
621
+ // Restoring is itself an edit: it creates a new version rather than
622
+ // rewinding the counter, so the history stays append-only and the
623
+ // restore can itself be undone (rule R6).
624
+ const normalised = normaliseValues(collection, snapshot.values, {
625
+ partial: false,
626
+ enforceRequired: false,
627
+ });
628
+ const zones = normaliseBlocks(collection, snapshot.blocks, newId);
629
+ await writeBlocks(tx, id, next, zones);
630
+ await writeVersion(tx, id, next, 'draft', { ...normalised.values, ...normalised.relations }, author);
631
+ if (!(draftsEnabled && text(row['status']) === 'published')) {
632
+ await writeLiveColumns(tx, id, normalised.columns, {
633
+ updated_at: stamp(),
634
+ updated_by: author,
635
+ version: next,
636
+ });
637
+ await writeRelations(tx, id, normalised.relations);
638
+ }
639
+ const after = await loadRow(tx, id);
640
+ if (after === null)
641
+ throw notFound(collection.name, id);
642
+ await prune(tx, id, Number(after['version']));
643
+ return workingEntry(tx, after);
644
+ }, { immediate: true }),
645
+ diff: async (id, from, to) => {
646
+ const row = await loadRow(db, id);
647
+ if (row === null)
648
+ throw notFound(collection.name, id);
649
+ const load = async (version) => {
650
+ const found = await versionRow(db, id, version);
651
+ if (found === null) {
652
+ throw new CogentaError({
653
+ code: 'CONTENT_NOT_FOUND',
654
+ message: `Version ${version} of "${id}" is no longer kept.`,
655
+ hint: `This collection keeps ${keep} versions. Compare one that history() still lists.`,
656
+ details: { collection: collection.name, id, version },
657
+ });
658
+ }
659
+ return snapshotOf(db, id, found);
660
+ };
661
+ return diffContent(await load(from), await load(to));
662
+ },
663
+ translations: async (id) => {
664
+ const row = await loadRow(db, id);
665
+ if (row === null)
666
+ return [];
667
+ const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
668
+ const found = await db.query(sql `select * from ${entries}
669
+ where ${identifier('id', dialect)} = ${sourceId}
670
+ or ${identifier('translation_of', dialect)} = ${sourceId}
671
+ order by ${identifier('locale', dialect)} asc`);
672
+ return liveEntries(db, found.rows);
673
+ },
674
+ resolveLocale: async (id, locale, resolveOptions) => {
675
+ const state = resolveOptions.state ?? 'published';
676
+ const row = await loadRow(db, id);
677
+ if (row === null)
678
+ return { outcome: 'notFound' };
679
+ const sourceId = nullableText(row['translation_of']) ?? text(row['id']);
680
+ const found = await db.query(sql `select * from ${entries}
681
+ where ${identifier('id', dialect)} = ${sourceId}
682
+ or ${identifier('translation_of', dialect)} = ${sourceId}`);
683
+ // Publication is per language (ADR-0014): a French entry can be live while
684
+ // its English translation is still a draft, and the renderer must treat
685
+ // the draft as if it did not exist.
686
+ const visible = found.rows.filter((member) => state === 'working' || text(member['status']) === 'published');
687
+ const match = visible.find((member) => text(member['locale']) === locale);
688
+ if (match !== undefined) {
689
+ const entry = state === 'published' ? await liveEntry(db, match) : await workingEntry(db, match);
690
+ return { outcome: 'found', entry, fellBack: false };
691
+ }
692
+ if (resolveOptions.fallback === 'hide')
693
+ return { outcome: 'hidden' };
694
+ if (resolveOptions.fallback === 'notFound')
695
+ return { outcome: 'notFound' };
696
+ const original = visible.find((member) => text(member['id']) === sourceId);
697
+ // 'original' cannot invent a source that is itself unpublished; there is
698
+ // nothing to show, so the honest answer is the same as a missing page.
699
+ if (original === undefined)
700
+ return { outcome: 'notFound' };
701
+ const entry = state === 'published' ? await liveEntry(db, original) : await workingEntry(db, original);
702
+ return { outcome: 'found', entry, fellBack: true };
703
+ },
704
+ };
705
+ }
706
+ //# sourceMappingURL=store.js.map