@karibsen/eponyme 0.6.0 → 0.8.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 (74) hide show
  1. package/README.md +109 -45
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +109 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +19 -6
  6. package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
  7. package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
  9. package/dist/runtime/components/fields/FieldRenderer.vue +22 -0
  10. package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
  11. package/dist/runtime/components/fields/PhoneField.vue +61 -0
  12. package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  14. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  15. package/dist/runtime/components/fields/TagsField.vue +55 -0
  16. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  17. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  18. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  19. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  20. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  21. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  22. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  23. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  24. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  25. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  26. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  27. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  28. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  29. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  30. package/dist/runtime/composables/useEponymeForm.js +1 -8
  31. package/dist/runtime/fields/index.d.ts +4 -0
  32. package/dist/runtime/fields/index.js +3 -1
  33. package/dist/runtime/fields/phone.d.ts +2 -0
  34. package/dist/runtime/fields/phone.js +3 -0
  35. package/dist/runtime/fields/tags.d.ts +6 -0
  36. package/dist/runtime/fields/tags.js +3 -0
  37. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  38. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  39. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  40. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  41. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  42. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  43. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  44. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  45. package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
  46. package/dist/runtime/server/services/eponyme-store.js +405 -73
  47. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  48. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  49. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  50. package/dist/runtime/types/eponyme.d.ts +54 -6
  51. package/dist/runtime/types/field.d.ts +82 -4
  52. package/dist/runtime/types/index.d.ts +0 -1
  53. package/dist/runtime/types/index.js +0 -1
  54. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  55. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  56. package/dist/runtime/utils/get-field-default-value.js +2 -0
  57. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  58. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  59. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  60. package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
  61. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  62. package/dist/runtime/utils/normalize-phone.js +17 -0
  63. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  64. package/dist/runtime/utils/normalize-tags.js +17 -0
  65. package/dist/runtime/utils/validate-eponyme-data.js +45 -3
  66. package/dist/runtime/utils/variables.d.ts +4 -0
  67. package/dist/runtime/utils/variables.js +8 -1
  68. package/package.json +4 -2
  69. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  70. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  71. package/dist/runtime/types/captcha.d.ts +0 -16
  72. package/dist/runtime/types/captcha.js +0 -0
  73. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  74. package/dist/runtime/utils/no-captcha.js +0 -5
@@ -1,11 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { createDefaultEponymeData } from "../../utils/create-default-eponyme-data.js";
4
+ import { isArrayItemFieldDefinition } from "../../utils/get-field-default-value.js";
4
5
  import { getEponymeCollections, getEponymeSchemas } from "../../utils/get-eponyme-schemas.js";
5
6
  import { normalizeSlug } from "../../utils/normalize-slug.js";
6
7
  import { applyPreviewSlug } from "../../utils/preview.js";
7
8
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
9
+ import { normalizeEponymeValues } from "../../utils/normalize-eponyme-values.js";
10
+ import { buildEponymeIndexRows, describeEponymeIndexSchema, eponymeIndexKeys, foldEponymeIndexValue } from "../../utils/eponyme-entry-index.js";
8
11
  export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
12
+ const IMPORT_TRANSACTION_MS = 12e4;
9
13
  export class EponymeService {
10
14
  constructor(config, client, options = {}) {
11
15
  this.client = client;
@@ -43,7 +47,118 @@ export class EponymeService {
43
47
  invalidate(name) {
44
48
  this.cache.delete(`row:${name}`);
45
49
  const collection = this.getCollectionEntry(name)?.name;
46
- if (collection) this.cache.delete(`rows:${collection}`);
50
+ if (collection) {
51
+ for (const key of this.cache.keys()) if (key.startsWith(`rows:${collection}:`)) this.cache.delete(key);
52
+ }
53
+ }
54
+ /**
55
+ * Replaces an entry's filterable values, in the same transaction as the write that
56
+ * changed them.
57
+ *
58
+ * Deleting before inserting rather than diffing: an entry carries a handful of rows, the
59
+ * two statements are cheap, and a diff is one more place for the index to drift from the
60
+ * content it describes — a failure mode that returns wrong listings without erroring.
61
+ */
62
+ async reindexEntry(db, name, state) {
63
+ const schema = this.getSchema(name);
64
+ if (!schema) return;
65
+ await db.eponymeEntryIndex.deleteMany({ where: { entryName: name } });
66
+ const rows = buildEponymeIndexRows(name, schema, state.__eponyme);
67
+ if (rows.length) await db.eponymeEntryIndex.createMany({ data: rows });
68
+ }
69
+ collectionFilterKeys(name) {
70
+ const definition = this.collections[name];
71
+ return definition ? eponymeIndexKeys(definition.fields) : void 0;
72
+ }
73
+ /**
74
+ * One transaction per entry rather than one for the whole collection: a collection has no
75
+ * bound, and a single transaction over it would exceed Prisma's timeout on anything real.
76
+ * The unit that has to be atomic is the entry, and it is.
77
+ */
78
+ async reindexName(name) {
79
+ const definition = this.collections[name];
80
+ if (!definition) {
81
+ const state = await this.loadState(name);
82
+ if (!state) return 0;
83
+ await this.transaction(async (tx) => await this.reindexEntry(tx, name, state));
84
+ return 1;
85
+ }
86
+ let entries = 0;
87
+ for (const row of await this.liveCollectionRows(name)) {
88
+ const slug = row.name.slice(name.length + 1);
89
+ if (!slug || slug.includes("/")) continue;
90
+ const state = this.rowToState(definition.fields, row);
91
+ await this.transaction(async (tx) => await this.reindexEntry(tx, row.name, state));
92
+ entries++;
93
+ }
94
+ return entries;
95
+ }
96
+ indexedNames() {
97
+ return [...Object.keys(this.schemas), ...Object.keys(this.collections)];
98
+ }
99
+ /**
100
+ * The unconditional rebuild, for a first backfill or a manual repair. It refreshes the
101
+ * recorded fingerprints too, so a repair does not leave the next boot with work to redo.
102
+ */
103
+ async reindexAll() {
104
+ let entries = 0;
105
+ for (const name of this.indexedNames()) {
106
+ entries += await this.reindexName(name);
107
+ await this.writeIndexState(name);
108
+ }
109
+ return { entries };
110
+ }
111
+ /**
112
+ * Rebuilds only what a configuration change invalidated, and nothing when nothing changed.
113
+ *
114
+ * This is what makes the index self-maintaining. Ordinary writes keep it current, but a
115
+ * newly filterable field leaves the entries already stored without any row for that key —
116
+ * and a filter on them then answers "none" rather than failing, which is the worst way for
117
+ * this to break.
118
+ *
119
+ * Unchanged configuration costs one read of a table with one row per collection.
120
+ */
121
+ async syncIndexState() {
122
+ const recorded = new Map((await this.client.eponymeIndexState.findMany()).map((row) => [row.name, row.fingerprint]));
123
+ const reindexed = [];
124
+ for (const name of this.indexedNames()) {
125
+ if (recorded.get(name) === this.indexFingerprint(name)) continue;
126
+ await this.reindexName(name);
127
+ await this.writeIndexState(name);
128
+ reindexed.push(name);
129
+ }
130
+ const stale = [...recorded.keys()].filter((name) => !this.schemas[name] && !this.collections[name]);
131
+ if (stale.length) await this.client.eponymeIndexState.deleteMany({ where: { name: { in: stale } } });
132
+ return { reindexed };
133
+ }
134
+ indexFingerprint(name) {
135
+ const schema = this.collections[name]?.fields ?? this.schemas[name];
136
+ return createHash("sha256").update(describeEponymeIndexSchema(schema ?? {})).digest("hex").slice(0, 32);
137
+ }
138
+ async writeIndexState(name) {
139
+ const fingerprint = this.indexFingerprint(name);
140
+ await this.client.eponymeIndexState.upsert({
141
+ where: { name },
142
+ create: { name, fingerprint },
143
+ update: { fingerprint }
144
+ });
145
+ }
146
+ /**
147
+ * Runs writes that have to land together, then drops what they invalidated.
148
+ *
149
+ * The callback reports the entries it touched rather than invalidating them itself:
150
+ * inside the transaction the write has not landed yet, and a read racing an
151
+ * uncommitted change would re-cache the old row and hold it for the whole TTL. The
152
+ * drop is deliberate on a rollback too — losing a cache entry costs one query, while
153
+ * keeping a wrong one costs the TTL.
154
+ */
155
+ async transaction(fn, options) {
156
+ const touched = /* @__PURE__ */ new Set();
157
+ try {
158
+ return await this.client.$transaction((tx) => fn(tx, (name) => touched.add(name)), options);
159
+ } finally {
160
+ for (const name of touched) this.invalidate(name);
161
+ }
47
162
  }
48
163
  getSchema(name) {
49
164
  return this.schemas[name] ?? this.getCollectionEntry(name)?.definition.fields;
@@ -63,15 +178,43 @@ export class EponymeService {
63
178
  async syncAll() {
64
179
  await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name, { heal: true })));
65
180
  }
181
+ /**
182
+ * Brings a stored document back in line with the schema: keys the schema no longer declares
183
+ * are dropped, and a key it declares but the document lacks takes its default.
184
+ *
185
+ * Recursive, because a field added inside a section, a tab or an array item is otherwise
186
+ * never filled in: the container it lives in already exists and is still valid, so a
187
+ * top-level pass keeps it untouched and the new field reads as missing forever. That was
188
+ * silent — `defaultValue` simply did nothing for anything but a root field.
189
+ */
66
190
  reconcile(schema, value, mode) {
67
- const persisted = value && typeof value === "object" && !Array.isArray(value) ? value : {};
191
+ const persisted = isPlainObject(value) ? value : {};
68
192
  const defaults = createDefaultEponymeData(schema);
69
193
  return Object.fromEntries(Object.keys(schema).map((key) => {
70
- const candidate = key in persisted ? persisted[key] : defaults[key];
71
- const errors = validateEponymeData({ [key]: schema[key] }, { [key]: candidate }, mode);
194
+ const definition = schema[key];
195
+ const candidate = key in persisted ? this.reconcileField(definition, persisted[key], mode) : defaults[key];
196
+ const errors = validateEponymeData({ [key]: definition }, { [key]: candidate }, mode);
72
197
  return [key, Object.keys(errors).length ? defaults[key] : candidate];
73
198
  }));
74
199
  }
200
+ /** Descends into the containers; a leaf is returned untouched for `reconcile` to judge. */
201
+ reconcileField(definition, value, mode) {
202
+ if (definition.type === "section")
203
+ return isPlainObject(value) ? this.reconcile(definition.options.fields, value, mode) : value;
204
+ if (definition.type === "tabs") {
205
+ if (!isPlainObject(value)) return value;
206
+ return Object.fromEntries(Object.entries(definition.options.tabs).map(([tabName, tab]) => [
207
+ tabName,
208
+ this.reconcile(tab.fields, value[tabName], mode)
209
+ ]));
210
+ }
211
+ if (definition.type === "array") {
212
+ const of = definition.options.of;
213
+ if (!Array.isArray(value) || isArrayItemFieldDefinition(of)) return value;
214
+ return value.map((item) => isPlainObject(item) ? this.reconcile(of, item, mode) : item);
215
+ }
216
+ return value;
217
+ }
75
218
  createState(schema, value) {
76
219
  const data = this.reconcile(schema, value ?? createDefaultEponymeData(schema), "publish");
77
220
  return {
@@ -84,6 +227,12 @@ export class EponymeService {
84
227
  }
85
228
  };
86
229
  }
230
+ /**
231
+ * Reads a state out of an envelope. Live content is in columns now, so this serves the two
232
+ * places an envelope still exists: a history row, which deliberately keeps the old shape so
233
+ * that versions written before this change stay restorable, and an import, which rebuilds
234
+ * one from the portable file.
235
+ */
87
236
  normalizeState(schema, value) {
88
237
  const stored = getStoredState(value);
89
238
  if (!stored) return this.createState(schema, value);
@@ -97,6 +246,17 @@ export class EponymeService {
97
246
  }
98
247
  };
99
248
  }
249
+ rowToState(schema, row) {
250
+ return {
251
+ __eponyme: {
252
+ version: 1,
253
+ draft: this.reconcile(schema, row.draft, "draft"),
254
+ published: this.reconcile(schema, row.published, "publish"),
255
+ status: row.status === "draft" ? "draft" : "published",
256
+ publishedAt: toIsoOrNull(row.publishedAt)
257
+ }
258
+ };
259
+ }
100
260
  /**
101
261
  * A singleton row is created on first read, but only when it is genuinely missing.
102
262
  * An `upsert` would send a write on every read instead, and each one costs a database
@@ -106,7 +266,7 @@ export class EponymeService {
106
266
  const row = await this.client.eponyme.findUnique({ where: { name } });
107
267
  if (row) return row;
108
268
  try {
109
- return await this.client.eponyme.create({ data: { name, data: this.createState(schema) } });
269
+ return await this.client.eponyme.create({ data: { name, ...stateToColumns(this.createState(schema)) } });
110
270
  } catch (error) {
111
271
  if (isPrismaError(error, "P2002")) return await this.client.eponyme.findUnique({ where: { name } });
112
272
  throw error;
@@ -131,11 +291,11 @@ export class EponymeService {
131
291
  if (!schema) return void 0;
132
292
  const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.loadSingletonRow(name, schema);
133
293
  if (!row || row.deletedAt) return void 0;
134
- const state = this.normalizeState(schema, row.data);
135
- if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
294
+ const state = this.rowToState(schema, row);
295
+ if (sameRow(state, row)) return { state, updatedAt: row.updatedAt };
136
296
  if (!heal && this.healedByRead.has(name)) return { state, updatedAt: row.updatedAt };
137
297
  this.healedByRead.add(name);
138
- const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
298
+ const healed = await this.client.eponyme.update({ where: { name }, data: stateToColumns(state) });
139
299
  this.invalidate(name);
140
300
  return { state, updatedAt: healed.updatedAt ?? row.updatedAt };
141
301
  }
@@ -146,34 +306,90 @@ export class EponymeService {
146
306
  * Writes a new state only if the row still carries the `updatedAt` we read.
147
307
  * Returns false when a concurrent editor won the race, so the caller can report a conflict
148
308
  * instead of silently discarding their changes.
309
+ *
310
+ * Dropping the cached row is left to `transaction`, which does it once the write has
311
+ * actually committed rather than while it is still in flight.
149
312
  */
150
- async writeState(name, next, expectedUpdatedAt) {
151
- const data = next;
152
- try {
153
- if (!expectedUpdatedAt) {
154
- await this.client.eponyme.update({ where: { name }, data: { data } });
155
- return true;
156
- }
157
- const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
158
- return count > 0;
159
- } finally {
160
- this.invalidate(name);
313
+ async writeState(name, next, expectedUpdatedAt, db) {
314
+ const data = stateToColumns(next);
315
+ if (!expectedUpdatedAt) {
316
+ await db.eponyme.update({ where: { name }, data });
317
+ return true;
161
318
  }
319
+ const { count } = await db.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data });
320
+ return count > 0;
162
321
  }
163
322
  /**
164
- * The live rows of a collection, shared by the public listing and the sitemap so both
165
- * read them once. The trash and the export deliberately query around it: one wants the
166
- * rows this filter excludes, the other must not see a cached view of the content.
323
+ * Every live row of a collection, payload included. The reindex needs both versions of the
324
+ * content, so this is the one read that still selects everything; a listing goes through
325
+ * `collectionRows` with a narrower select.
167
326
  */
168
- async liveCollectionRows(name, cache = false) {
169
- const load = () => this.client.eponyme.findMany({
327
+ async liveCollectionRows(name) {
328
+ return await this.client.eponyme.findMany({
170
329
  where: { name: { startsWith: `${name}/` }, deletedAt: null },
171
330
  orderBy: [
172
331
  { updatedAt: "desc" },
173
332
  { name: "asc" }
174
333
  ]
175
334
  });
176
- return cache ? await this.cached(`rows:${name}`, load) : await load();
335
+ }
336
+ /**
337
+ * The names a `where` keeps, resolved from the index table before a single row of content
338
+ * is read. This is the point of the index: a filtered listing loads and normalizes the
339
+ * entries it returns, not the whole collection.
340
+ *
341
+ * One query per key, intersected: the values of a key are ORed by the query itself, and
342
+ * the keys are ANDed by narrowing the set. An empty set short-circuits the whole listing.
343
+ */
344
+ async resolveFilter(name, version, where) {
345
+ let matching;
346
+ const excluded = /* @__PURE__ */ new Set();
347
+ for (const [key, condition] of Object.entries(where)) {
348
+ const { positive, negative } = splitCondition(condition);
349
+ if (positive) {
350
+ const names = await this.indexNames(name, version, key, positive);
351
+ matching = matching === void 0 ? names : new Set([...matching].filter((entry) => names.has(entry)));
352
+ if (!matching.size) return matching;
353
+ }
354
+ if (negative) {
355
+ for (const entry of await this.indexNames(name, version, key, negative)) excluded.add(entry);
356
+ }
357
+ }
358
+ const result = matching ?? await this.collectionEntryNames(name);
359
+ for (const entry of excluded) result.delete(entry);
360
+ return result;
361
+ }
362
+ async indexNames(name, version, key, value) {
363
+ const rows = await this.client.eponymeEntryIndex.findMany({
364
+ where: { entryName: { startsWith: `${name}/` }, version, key, value },
365
+ select: { entryName: true }
366
+ });
367
+ return new Set(rows.map((row) => row.entryName));
368
+ }
369
+ async collectionEntryNames(name) {
370
+ const rows = await this.client.eponyme.findMany({
371
+ where: { name: { startsWith: `${name}/` }, deletedAt: null },
372
+ orderBy: [{ name: "asc" }],
373
+ select: { name: true }
374
+ });
375
+ return new Set(rows.flatMap((row) => {
376
+ const slug = row.name.slice(name.length + 1);
377
+ return !slug || slug.includes("/") ? [] : [row.name];
378
+ }));
379
+ }
380
+ /**
381
+ * The rows a filter selected. Never cached: the shared `rows:` key holds the whole
382
+ * collection, and one key per filter combination would evict it for no reuse.
383
+ */
384
+ async filteredCollectionRows(names) {
385
+ return await this.client.eponyme.findMany({
386
+ // The index knows nothing about the trash, so the live filter still has to apply.
387
+ where: { name: { in: [...names] }, deletedAt: null },
388
+ orderBy: [
389
+ { updatedAt: "desc" },
390
+ { name: "asc" }
391
+ ]
392
+ });
177
393
  }
178
394
  async get(name, version = "published") {
179
395
  return (await this.getResult(name, version))?.data;
@@ -218,11 +434,65 @@ export class EponymeService {
218
434
  async listCollection(name, version = "published", query = {}) {
219
435
  const definition = this.collections[name];
220
436
  if (!definition) return void 0;
221
- const rows = await this.liveCollectionRows(name, version === "published");
222
- const entries = rows.flatMap((row) => {
437
+ let names;
438
+ if (query.where && Object.keys(query.where).length) {
439
+ names = await this.resolveFilter(name, version, query.where);
440
+ if (!names.size) return { entries: [], total: 0 };
441
+ }
442
+ const where = {
443
+ name: names ? { in: [...names] } : { startsWith: `${name}/` },
444
+ deletedAt: null,
445
+ // The predicate that used to force everything into memory. It is a column now, so the
446
+ // database can apply it before counting, ordering and paginating.
447
+ ...version === "published" ? { publishedAt: { not: null } } : {}
448
+ };
449
+ const select = version === "published" ? { name: true, published: true, publishedAt: true, updatedAt: true } : { name: true, draft: true, status: true, publishedAt: true, updatedAt: true };
450
+ const pushdown = pushdownOrderBy(query.orderBy, query.order ?? "desc");
451
+ if (pushdown) {
452
+ const [rows2, total] = await Promise.all([
453
+ this.client.eponyme.findMany({
454
+ where,
455
+ orderBy: pushdown,
456
+ skip: Math.max(0, Math.trunc(query.skip ?? 0) || 0),
457
+ ...query.take === void 0 ? {} : { take: Math.max(0, Math.trunc(query.take) || 0) },
458
+ select
459
+ }),
460
+ this.client.eponyme.count({ where })
461
+ ]);
462
+ return { entries: this.toCollectionEntries(name, definition, version, rows2), total };
463
+ }
464
+ const rows = await this.collectionRows(name, version, where, select, names === void 0);
465
+ const entries = this.toCollectionEntries(name, definition, version, rows);
466
+ const sorted = query.orderBy ? sortCollectionEntries(entries, query.orderBy, query.order ?? "desc") : entries;
467
+ const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
468
+ const take = query.take === void 0 ? void 0 : Math.max(0, Math.trunc(query.take) || 0);
469
+ return {
470
+ entries: take === void 0 ? sorted.slice(skip) : sorted.slice(skip, skip + take),
471
+ total: sorted.length
472
+ };
473
+ }
474
+ /**
475
+ * The unpaginated read, still cached: an unfiltered listing of one collection is what a
476
+ * page hits repeatedly, and the key carries the version so a draft view cannot answer a
477
+ * public one.
478
+ */
479
+ async collectionRows(name, version, where, select, cache) {
480
+ const load = () => this.client.eponyme.findMany({
481
+ where,
482
+ orderBy: [{ updatedAt: "desc" }, { name: "asc" }],
483
+ select
484
+ });
485
+ return cache && version === "published" ? await this.cached(`rows:${name}:${version}`, load) : await load();
486
+ }
487
+ /**
488
+ * `findMany` already carries every payload, so the state is built here rather than re-read
489
+ * one entry at a time: a listing costs one query, not N+1.
490
+ */
491
+ toCollectionEntries(name, definition, version, rows) {
492
+ return rows.flatMap((row) => {
223
493
  const slug = row.name.slice(name.length + 1);
224
494
  if (!slug || slug.includes("/")) return [];
225
- const state = this.normalizeState(definition.fields, row.data);
495
+ const state = this.rowToState(definition.fields, row);
226
496
  return [{
227
497
  slug,
228
498
  title: String(state.__eponyme[version][definition.titleField] || slug),
@@ -232,14 +502,6 @@ export class EponymeService {
232
502
  updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
233
503
  }];
234
504
  });
235
- const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
236
- const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
237
- const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
238
- const take = query.take === void 0 ? void 0 : Math.max(0, Math.trunc(query.take) || 0);
239
- return {
240
- entries: take === void 0 ? sorted.slice(skip) : sorted.slice(skip, skip + take),
241
- total: sorted.length
242
- };
243
505
  }
244
506
  /**
245
507
  * Builds the public URL list configured by `previewPaths`.
@@ -256,13 +518,15 @@ export class EponymeService {
256
518
  }
257
519
  if (!path.includes(":slug"))
258
520
  throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
259
- const rows = await this.liveCollectionRows(name, true);
521
+ const rows = await this.cached(`rows:${name}:sitemap`, () => this.client.eponyme.findMany({
522
+ where: { name: { startsWith: `${name}/` }, deletedAt: null, publishedAt: { not: null } },
523
+ orderBy: [{ name: "asc" }],
524
+ select: { name: true, publishedAt: true }
525
+ }));
260
526
  return rows.flatMap((row) => {
261
527
  const slug = row.name.slice(name.length + 1);
262
528
  if (!slug || slug.includes("/")) return [];
263
- const state = this.normalizeState(collection.fields, row.data);
264
- if (!state.__eponyme.publishedAt) return [];
265
- return [withLastmod(applyPreviewSlug(path, slug), state.__eponyme.publishedAt)];
529
+ return [withLastmod(applyPreviewSlug(path, slug), toIsoOrNull(row.publishedAt))];
266
530
  });
267
531
  }));
268
532
  const entries = /* @__PURE__ */ new Map();
@@ -294,7 +558,7 @@ export class EponymeService {
294
558
  for (const row of rows) {
295
559
  const slug = row.name.slice(name.length + 1);
296
560
  if (!slug || slug.includes("/")) continue;
297
- entries.push({ name: row.name, collection: name, ...toExportEntry(this.normalizeState(definition.fields, row.data)) });
561
+ entries.push({ name: row.name, collection: name, ...toExportEntry(this.rowToState(definition.fields, row)) });
298
562
  }
299
563
  }
300
564
  return {
@@ -307,7 +571,9 @@ export class EponymeService {
307
571
  * upserted, everything else is left alone. Nothing is ever deleted.
308
572
  *
309
573
  * The schema check runs first and refuses the whole file, so an import can never
310
- * land half of its entries into a configuration that no longer matches.
574
+ * land half of its entries into a configuration that no longer matches. The writes then
575
+ * run in one transaction, so a failure partway through leaves nothing behind either —
576
+ * an interrupted import can simply be replayed.
311
577
  */
312
578
  async importContent(file, options = {}) {
313
579
  const parsed = parseExportFile(file);
@@ -327,6 +593,20 @@ export class EponymeService {
327
593
  schemaMismatch: mismatch
328
594
  };
329
595
  }
596
+ if (options.dryRun) return await this.applyImport(parsed, options, this.client, () => {
597
+ });
598
+ return await this.transaction(
599
+ (tx, invalidate) => this.applyImport(parsed, options, tx, invalidate),
600
+ // An import is one unit, so its transaction lasts as long as the file it carries —
601
+ // well past the five seconds Prisma allows by default.
602
+ { maxWait: IMPORT_TRANSACTION_MS, timeout: IMPORT_TRANSACTION_MS }
603
+ );
604
+ }
605
+ /**
606
+ * The body of an import, over whichever client it was handed: the live one for a dry
607
+ * run, a transaction for the real thing.
608
+ */
609
+ async applyImport(parsed, options, db, invalidate) {
330
610
  const result = { dryRun: Boolean(options.dryRun), created: 0, updated: 0, skipped: [] };
331
611
  for (const entry of parsed.entries) {
332
612
  const collectionEntry = this.getCollectionEntry(entry.name);
@@ -335,7 +615,7 @@ export class EponymeService {
335
615
  result.skipped.push({ name: entry.name, reason: "No schema is configured for this entry." });
336
616
  continue;
337
617
  }
338
- const row = await this.client.eponyme.findUnique({ where: { name: entry.name } });
618
+ const row = await db.eponyme.findUnique({ where: { name: entry.name } });
339
619
  if (row?.deletedAt) {
340
620
  result.skipped.push({ name: entry.name, reason: "An entry with this name is in the trash. Restore it or delete it permanently first." });
341
621
  continue;
@@ -356,9 +636,11 @@ export class EponymeService {
356
636
  }
357
637
  if (!options.dryRun) {
358
638
  const data = state;
359
- await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
360
- this.invalidate(entry.name);
361
- await this.client.eponymeVersion.create({
639
+ const columns = stateToColumns(state);
640
+ invalidate(entry.name);
641
+ await db.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, ...columns }, update: columns });
642
+ await this.reindexEntry(db, entry.name, state);
643
+ await db.eponymeVersion.create({
362
644
  data: {
363
645
  entryName: entry.name,
364
646
  data,
@@ -386,7 +668,7 @@ export class EponymeService {
386
668
  const taken = await this.slugTakenError(definition, entryName);
387
669
  if (taken) return taken;
388
670
  const defaults = createDefaultEponymeData(definition.fields);
389
- const data = { ...defaults, ...input, [definition.slugField]: slug };
671
+ const data = normalizeEponymeValues(definition.fields, { ...defaults, ...input, [definition.slugField]: slug });
390
672
  const errors = validateEponymePatch(definition.fields, data, data, "draft");
391
673
  if (Object.keys(errors).length) return { errors };
392
674
  const state = {
@@ -398,14 +680,19 @@ export class EponymeService {
398
680
  publishedAt: null
399
681
  }
400
682
  };
683
+ const stored = state;
684
+ const columns = stateToColumns(state);
401
685
  try {
402
- await this.client.eponyme.create({ data: { name: entryName, data: state } });
403
- this.invalidate(entryName);
686
+ await this.transaction(async (tx, invalidate) => {
687
+ invalidate(entryName);
688
+ await tx.eponyme.create({ data: { name: entryName, ...columns } });
689
+ await this.reindexEntry(tx, entryName, state);
690
+ await tx.eponymeVersion.create({ data: { entryName, data: stored, action: "draft", status: "draft", userId: actorId ?? null } });
691
+ });
404
692
  } catch (error) {
405
693
  if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
406
694
  throw error;
407
695
  }
408
- await this.client.eponymeVersion.create({ data: { entryName, data: state, action: "draft", status: "draft", userId: actorId ?? null } });
409
696
  return { slug, data, status: "draft", publishedAt: null };
410
697
  }
411
698
  /**
@@ -468,7 +755,7 @@ export class EponymeService {
468
755
  const entries = rows.flatMap((row) => {
469
756
  const slug = row.name.slice(name.length + 1);
470
757
  if (!slug || slug.includes("/")) return [];
471
- const state = this.normalizeState(definition.fields, row.data);
758
+ const state = this.rowToState(definition.fields, row);
472
759
  return [{
473
760
  slug,
474
761
  title: String(state.__eponyme.draft[definition.titleField] || slug),
@@ -494,8 +781,8 @@ export class EponymeService {
494
781
  if (submittedSlug !== collectionEntry.slug)
495
782
  return { errors: { [collectionEntry.definition.slugField]: ["The slug cannot be changed after creation."] } };
496
783
  }
497
- const data = { ...state.__eponyme.draft, ...payload };
498
- const patchErrors = validateEponymePatch(schema, payload, data, action);
784
+ const data = normalizeEponymeValues(schema, { ...state.__eponyme.draft, ...payload });
785
+ const patchErrors = validateEponymePatch(schema, normalizeEponymeValues(schema, payload), data, action);
499
786
  const errors = action === "publish" ? mergeErrors(patchErrors, validateEponymeData(schema, data, "publish")) : patchErrors;
500
787
  if (Object.keys(errors).length) return { errors };
501
788
  const publishedAt = action === "publish" ? (/* @__PURE__ */ new Date()).toISOString() : state.__eponyme.publishedAt;
@@ -508,16 +795,22 @@ export class EponymeService {
508
795
  publishedAt
509
796
  }
510
797
  };
511
- if (!await this.writeState(name, next, updatedAt)) return { conflict: true };
512
- await this.client.eponymeVersion.create({
513
- data: {
514
- entryName: name,
515
- data: next,
516
- action,
517
- status: next.__eponyme.status,
518
- userId: actorId ?? null
519
- }
798
+ const written = await this.transaction(async (tx, invalidate) => {
799
+ invalidate(name);
800
+ if (!await this.writeState(name, next, updatedAt, tx)) return false;
801
+ await this.reindexEntry(tx, name, next);
802
+ await tx.eponymeVersion.create({
803
+ data: {
804
+ entryName: name,
805
+ data: next,
806
+ action,
807
+ status: next.__eponyme.status,
808
+ userId: actorId ?? null
809
+ }
810
+ });
811
+ return true;
520
812
  });
813
+ if (!written) return { conflict: true };
521
814
  return { data, status: next.__eponyme.status, publishedAt };
522
815
  }
523
816
  async history(name, limit = 50) {
@@ -544,16 +837,22 @@ export class EponymeService {
544
837
  const current = await this.loadRow(name, { heal: true });
545
838
  if (!current) return void 0;
546
839
  const state = this.normalizeState(schema, version.data);
547
- if (!await this.writeState(name, state, current.updatedAt)) return { conflict: true };
548
- await this.client.eponymeVersion.create({
549
- data: {
550
- entryName: name,
551
- data: state,
552
- action: "restore",
553
- status: state.__eponyme.status,
554
- userId: actorId ?? null
555
- }
840
+ const written = await this.transaction(async (tx, invalidate) => {
841
+ invalidate(name);
842
+ if (!await this.writeState(name, state, current.updatedAt, tx)) return false;
843
+ await this.reindexEntry(tx, name, state);
844
+ await tx.eponymeVersion.create({
845
+ data: {
846
+ entryName: name,
847
+ data: state,
848
+ action: "restore",
849
+ status: state.__eponyme.status,
850
+ userId: actorId ?? null
851
+ }
852
+ });
853
+ return true;
556
854
  });
855
+ if (!written) return { conflict: true };
557
856
  return {
558
857
  data: state.__eponyme.draft,
559
858
  status: state.__eponyme.status,
@@ -643,8 +942,35 @@ function mergeErrors(...groups) {
643
942
  export function isPrismaError(error, code) {
644
943
  return Boolean(error && typeof error === "object" && error.code === code);
645
944
  }
646
- function sameData(left, right) {
647
- return isDeepStrictEqual(left, right);
945
+ function stateToColumns(state) {
946
+ const { draft, published, status, publishedAt } = state.__eponyme;
947
+ return { draft, published, status, publishedAt: publishedAt ? new Date(publishedAt) : null };
948
+ }
949
+ function toIsoOrNull(value) {
950
+ if (value === null || value === void 0) return null;
951
+ const date = new Date(value);
952
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
953
+ }
954
+ function sameRow(state, row) {
955
+ const stored = state.__eponyme;
956
+ return isDeepStrictEqual(stored.draft, row.draft) && isDeepStrictEqual(stored.published, row.published) && stored.status === row.status && stored.publishedAt === toIsoOrNull(row.publishedAt);
957
+ }
958
+ function splitCondition(condition) {
959
+ if (typeof condition === "string") return { positive: foldEponymeIndexValue(condition) };
960
+ if (Array.isArray(condition)) return { positive: { in: condition.map(foldEponymeIndexValue) } };
961
+ const positive = {};
962
+ if (condition.in?.length) positive.in = condition.in.map(foldEponymeIndexValue);
963
+ if (condition.contains) positive.contains = foldEponymeIndexValue(condition.contains);
964
+ for (const bound of ["gte", "lte", "gt", "lt"]) {
965
+ const value = condition[bound];
966
+ if (value !== void 0) positive[bound] = foldEponymeIndexValue(value);
967
+ }
968
+ return {
969
+ // An operator object carrying only `not` has no positive part, and an empty one filters
970
+ // nothing at all rather than matching nothing.
971
+ positive: Object.keys(positive).length ? positive : void 0,
972
+ negative: condition.not?.length ? { in: condition.not.map(foldEponymeIndexValue) } : void 0
973
+ };
648
974
  }
649
975
  function sortValue(entry, key) {
650
976
  if (COLLECTION_METADATA_KEYS.includes(key))
@@ -654,6 +980,12 @@ function sortValue(entry, key) {
654
980
  function isEmptyValue(value) {
655
981
  return value === void 0 || value === null || value === "";
656
982
  }
983
+ function pushdownOrderBy(orderBy, order) {
984
+ if (orderBy === void 0) return [{ updatedAt: "desc" }, { name: "asc" }];
985
+ if (orderBy === "updatedAt") return [{ updatedAt: order }, { name: "asc" }];
986
+ if (orderBy === "publishedAt") return [{ publishedAt: { sort: order, nulls: "last" } }, { name: "asc" }];
987
+ return void 0;
988
+ }
657
989
  function sortCollectionEntries(entries, key, direction) {
658
990
  const factor = direction === "asc" ? 1 : -1;
659
991
  return [...entries].sort((left, right) => {