@karibsen/eponyme 0.7.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 (46) hide show
  1. package/README.md +59 -7
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +5 -0
  5. package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
  6. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  7. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  8. package/dist/runtime/components/fields/TagsField.vue +55 -0
  9. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  10. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  11. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  12. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  13. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  14. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  15. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  16. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  17. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  18. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  19. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  20. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  21. package/dist/runtime/fields/index.d.ts +2 -0
  22. package/dist/runtime/fields/index.js +2 -1
  23. package/dist/runtime/fields/tags.d.ts +6 -0
  24. package/dist/runtime/fields/tags.js +3 -0
  25. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  26. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  27. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  28. package/dist/runtime/server/services/eponyme-form-store.js +2 -2
  29. package/dist/runtime/server/services/eponyme-store.d.ts +278 -30
  30. package/dist/runtime/server/services/eponyme-store.js +373 -71
  31. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  32. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  33. package/dist/runtime/types/eponyme.d.ts +53 -2
  34. package/dist/runtime/types/field.d.ts +35 -2
  35. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  36. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  37. package/dist/runtime/utils/get-field-default-value.js +2 -0
  38. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  39. package/dist/runtime/utils/{normalize-eponyme-phones.js → normalize-eponyme-values.js} +4 -2
  40. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  41. package/dist/runtime/utils/normalize-tags.js +17 -0
  42. package/dist/runtime/utils/validate-eponyme-data.js +16 -0
  43. package/dist/runtime/utils/variables.d.ts +4 -0
  44. package/dist/runtime/utils/variables.js +8 -1
  45. package/package.json +3 -2
  46. package/dist/runtime/utils/normalize-eponyme-phones.d.ts +0 -9
@@ -6,8 +6,10 @@ import { getEponymeCollections, getEponymeSchemas } from "../../utils/get-eponym
6
6
  import { normalizeSlug } from "../../utils/normalize-slug.js";
7
7
  import { applyPreviewSlug } from "../../utils/preview.js";
8
8
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
9
- import { normalizeEponymePhones } from "../../utils/normalize-eponyme-phones.js";
9
+ import { normalizeEponymeValues } from "../../utils/normalize-eponyme-values.js";
10
+ import { buildEponymeIndexRows, describeEponymeIndexSchema, eponymeIndexKeys, foldEponymeIndexValue } from "../../utils/eponyme-entry-index.js";
10
11
  export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
12
+ const IMPORT_TRANSACTION_MS = 12e4;
11
13
  export class EponymeService {
12
14
  constructor(config, client, options = {}) {
13
15
  this.client = client;
@@ -45,7 +47,118 @@ export class EponymeService {
45
47
  invalidate(name) {
46
48
  this.cache.delete(`row:${name}`);
47
49
  const collection = this.getCollectionEntry(name)?.name;
48
- 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
+ }
49
162
  }
50
163
  getSchema(name) {
51
164
  return this.schemas[name] ?? this.getCollectionEntry(name)?.definition.fields;
@@ -114,6 +227,12 @@ export class EponymeService {
114
227
  }
115
228
  };
116
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
+ */
117
236
  normalizeState(schema, value) {
118
237
  const stored = getStoredState(value);
119
238
  if (!stored) return this.createState(schema, value);
@@ -127,6 +246,17 @@ export class EponymeService {
127
246
  }
128
247
  };
129
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
+ }
130
260
  /**
131
261
  * A singleton row is created on first read, but only when it is genuinely missing.
132
262
  * An `upsert` would send a write on every read instead, and each one costs a database
@@ -136,7 +266,7 @@ export class EponymeService {
136
266
  const row = await this.client.eponyme.findUnique({ where: { name } });
137
267
  if (row) return row;
138
268
  try {
139
- 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)) } });
140
270
  } catch (error) {
141
271
  if (isPrismaError(error, "P2002")) return await this.client.eponyme.findUnique({ where: { name } });
142
272
  throw error;
@@ -161,11 +291,11 @@ export class EponymeService {
161
291
  if (!schema) return void 0;
162
292
  const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.loadSingletonRow(name, schema);
163
293
  if (!row || row.deletedAt) return void 0;
164
- const state = this.normalizeState(schema, row.data);
165
- 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 };
166
296
  if (!heal && this.healedByRead.has(name)) return { state, updatedAt: row.updatedAt };
167
297
  this.healedByRead.add(name);
168
- 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) });
169
299
  this.invalidate(name);
170
300
  return { state, updatedAt: healed.updatedAt ?? row.updatedAt };
171
301
  }
@@ -176,34 +306,90 @@ export class EponymeService {
176
306
  * Writes a new state only if the row still carries the `updatedAt` we read.
177
307
  * Returns false when a concurrent editor won the race, so the caller can report a conflict
178
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.
179
312
  */
180
- async writeState(name, next, expectedUpdatedAt) {
181
- const data = next;
182
- try {
183
- if (!expectedUpdatedAt) {
184
- await this.client.eponyme.update({ where: { name }, data: { data } });
185
- return true;
186
- }
187
- const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
188
- return count > 0;
189
- } finally {
190
- 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;
191
318
  }
319
+ const { count } = await db.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data });
320
+ return count > 0;
192
321
  }
193
322
  /**
194
- * The live rows of a collection, shared by the public listing and the sitemap so both
195
- * read them once. The trash and the export deliberately query around it: one wants the
196
- * 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.
197
326
  */
198
- async liveCollectionRows(name, cache = false) {
199
- const load = () => this.client.eponyme.findMany({
327
+ async liveCollectionRows(name) {
328
+ return await this.client.eponyme.findMany({
200
329
  where: { name: { startsWith: `${name}/` }, deletedAt: null },
201
330
  orderBy: [
202
331
  { updatedAt: "desc" },
203
332
  { name: "asc" }
204
333
  ]
205
334
  });
206
- 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
+ });
207
393
  }
208
394
  async get(name, version = "published") {
209
395
  return (await this.getResult(name, version))?.data;
@@ -248,11 +434,65 @@ export class EponymeService {
248
434
  async listCollection(name, version = "published", query = {}) {
249
435
  const definition = this.collections[name];
250
436
  if (!definition) return void 0;
251
- const rows = await this.liveCollectionRows(name, version === "published");
252
- 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) => {
253
493
  const slug = row.name.slice(name.length + 1);
254
494
  if (!slug || slug.includes("/")) return [];
255
- const state = this.normalizeState(definition.fields, row.data);
495
+ const state = this.rowToState(definition.fields, row);
256
496
  return [{
257
497
  slug,
258
498
  title: String(state.__eponyme[version][definition.titleField] || slug),
@@ -262,14 +502,6 @@ export class EponymeService {
262
502
  updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
263
503
  }];
264
504
  });
265
- const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
266
- const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
267
- const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
268
- const take = query.take === void 0 ? void 0 : Math.max(0, Math.trunc(query.take) || 0);
269
- return {
270
- entries: take === void 0 ? sorted.slice(skip) : sorted.slice(skip, skip + take),
271
- total: sorted.length
272
- };
273
505
  }
274
506
  /**
275
507
  * Builds the public URL list configured by `previewPaths`.
@@ -286,13 +518,15 @@ export class EponymeService {
286
518
  }
287
519
  if (!path.includes(":slug"))
288
520
  throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
289
- 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
+ }));
290
526
  return rows.flatMap((row) => {
291
527
  const slug = row.name.slice(name.length + 1);
292
528
  if (!slug || slug.includes("/")) return [];
293
- const state = this.normalizeState(collection.fields, row.data);
294
- if (!state.__eponyme.publishedAt) return [];
295
- return [withLastmod(applyPreviewSlug(path, slug), state.__eponyme.publishedAt)];
529
+ return [withLastmod(applyPreviewSlug(path, slug), toIsoOrNull(row.publishedAt))];
296
530
  });
297
531
  }));
298
532
  const entries = /* @__PURE__ */ new Map();
@@ -324,7 +558,7 @@ export class EponymeService {
324
558
  for (const row of rows) {
325
559
  const slug = row.name.slice(name.length + 1);
326
560
  if (!slug || slug.includes("/")) continue;
327
- 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)) });
328
562
  }
329
563
  }
330
564
  return {
@@ -337,7 +571,9 @@ export class EponymeService {
337
571
  * upserted, everything else is left alone. Nothing is ever deleted.
338
572
  *
339
573
  * The schema check runs first and refuses the whole file, so an import can never
340
- * 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.
341
577
  */
342
578
  async importContent(file, options = {}) {
343
579
  const parsed = parseExportFile(file);
@@ -357,6 +593,20 @@ export class EponymeService {
357
593
  schemaMismatch: mismatch
358
594
  };
359
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) {
360
610
  const result = { dryRun: Boolean(options.dryRun), created: 0, updated: 0, skipped: [] };
361
611
  for (const entry of parsed.entries) {
362
612
  const collectionEntry = this.getCollectionEntry(entry.name);
@@ -365,7 +615,7 @@ export class EponymeService {
365
615
  result.skipped.push({ name: entry.name, reason: "No schema is configured for this entry." });
366
616
  continue;
367
617
  }
368
- const row = await this.client.eponyme.findUnique({ where: { name: entry.name } });
618
+ const row = await db.eponyme.findUnique({ where: { name: entry.name } });
369
619
  if (row?.deletedAt) {
370
620
  result.skipped.push({ name: entry.name, reason: "An entry with this name is in the trash. Restore it or delete it permanently first." });
371
621
  continue;
@@ -386,9 +636,11 @@ export class EponymeService {
386
636
  }
387
637
  if (!options.dryRun) {
388
638
  const data = state;
389
- await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
390
- this.invalidate(entry.name);
391
- 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({
392
644
  data: {
393
645
  entryName: entry.name,
394
646
  data,
@@ -416,7 +668,7 @@ export class EponymeService {
416
668
  const taken = await this.slugTakenError(definition, entryName);
417
669
  if (taken) return taken;
418
670
  const defaults = createDefaultEponymeData(definition.fields);
419
- const data = normalizeEponymePhones(definition.fields, { ...defaults, ...input, [definition.slugField]: slug });
671
+ const data = normalizeEponymeValues(definition.fields, { ...defaults, ...input, [definition.slugField]: slug });
420
672
  const errors = validateEponymePatch(definition.fields, data, data, "draft");
421
673
  if (Object.keys(errors).length) return { errors };
422
674
  const state = {
@@ -428,14 +680,19 @@ export class EponymeService {
428
680
  publishedAt: null
429
681
  }
430
682
  };
683
+ const stored = state;
684
+ const columns = stateToColumns(state);
431
685
  try {
432
- await this.client.eponyme.create({ data: { name: entryName, data: state } });
433
- 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
+ });
434
692
  } catch (error) {
435
693
  if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
436
694
  throw error;
437
695
  }
438
- await this.client.eponymeVersion.create({ data: { entryName, data: state, action: "draft", status: "draft", userId: actorId ?? null } });
439
696
  return { slug, data, status: "draft", publishedAt: null };
440
697
  }
441
698
  /**
@@ -498,7 +755,7 @@ export class EponymeService {
498
755
  const entries = rows.flatMap((row) => {
499
756
  const slug = row.name.slice(name.length + 1);
500
757
  if (!slug || slug.includes("/")) return [];
501
- const state = this.normalizeState(definition.fields, row.data);
758
+ const state = this.rowToState(definition.fields, row);
502
759
  return [{
503
760
  slug,
504
761
  title: String(state.__eponyme.draft[definition.titleField] || slug),
@@ -524,8 +781,8 @@ export class EponymeService {
524
781
  if (submittedSlug !== collectionEntry.slug)
525
782
  return { errors: { [collectionEntry.definition.slugField]: ["The slug cannot be changed after creation."] } };
526
783
  }
527
- const data = normalizeEponymePhones(schema, { ...state.__eponyme.draft, ...payload });
528
- const patchErrors = validateEponymePatch(schema, normalizeEponymePhones(schema, payload), data, action);
784
+ const data = normalizeEponymeValues(schema, { ...state.__eponyme.draft, ...payload });
785
+ const patchErrors = validateEponymePatch(schema, normalizeEponymeValues(schema, payload), data, action);
529
786
  const errors = action === "publish" ? mergeErrors(patchErrors, validateEponymeData(schema, data, "publish")) : patchErrors;
530
787
  if (Object.keys(errors).length) return { errors };
531
788
  const publishedAt = action === "publish" ? (/* @__PURE__ */ new Date()).toISOString() : state.__eponyme.publishedAt;
@@ -538,16 +795,22 @@ export class EponymeService {
538
795
  publishedAt
539
796
  }
540
797
  };
541
- if (!await this.writeState(name, next, updatedAt)) return { conflict: true };
542
- await this.client.eponymeVersion.create({
543
- data: {
544
- entryName: name,
545
- data: next,
546
- action,
547
- status: next.__eponyme.status,
548
- userId: actorId ?? null
549
- }
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;
550
812
  });
813
+ if (!written) return { conflict: true };
551
814
  return { data, status: next.__eponyme.status, publishedAt };
552
815
  }
553
816
  async history(name, limit = 50) {
@@ -574,16 +837,22 @@ export class EponymeService {
574
837
  const current = await this.loadRow(name, { heal: true });
575
838
  if (!current) return void 0;
576
839
  const state = this.normalizeState(schema, version.data);
577
- if (!await this.writeState(name, state, current.updatedAt)) return { conflict: true };
578
- await this.client.eponymeVersion.create({
579
- data: {
580
- entryName: name,
581
- data: state,
582
- action: "restore",
583
- status: state.__eponyme.status,
584
- userId: actorId ?? null
585
- }
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;
586
854
  });
855
+ if (!written) return { conflict: true };
587
856
  return {
588
857
  data: state.__eponyme.draft,
589
858
  status: state.__eponyme.status,
@@ -673,8 +942,35 @@ function mergeErrors(...groups) {
673
942
  export function isPrismaError(error, code) {
674
943
  return Boolean(error && typeof error === "object" && error.code === code);
675
944
  }
676
- function sameData(left, right) {
677
- 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
+ };
678
974
  }
679
975
  function sortValue(entry, key) {
680
976
  if (COLLECTION_METADATA_KEYS.includes(key))
@@ -684,6 +980,12 @@ function sortValue(entry, key) {
684
980
  function isEmptyValue(value) {
685
981
  return value === void 0 || value === null || value === "";
686
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
+ }
687
989
  function sortCollectionEntries(entries, key, direction) {
688
990
  const factor = direction === "asc" ? 1 : -1;
689
991
  return [...entries].sort((left, right) => {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Rebuilds the filterable index of every entry, unconditionally.
3
+ *
4
+ * Rarely needed: startup already rebuilds whatever a configuration change invalidated, so
5
+ * adding a `field.tags()` to a collection that already holds entries is handled on its own.
6
+ * This is the repair for what a fingerprint cannot see — editing a custom `validate` so that
7
+ * it now rejects values already stored, or an index suspected of having drifted.
8
+ *
9
+ * Auto-imported, so a host can expose it on a route or call it from a script.
10
+ */
11
+ export declare function reindexEponymeEntries(): Promise<{
12
+ entries: number;
13
+ }>;
@@ -0,0 +1,4 @@
1
+ import { useEponymeService } from "../services/eponyme-service.js";
2
+ export async function reindexEponymeEntries() {
3
+ return useEponymeService().reindexAll();
4
+ }