@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.
- package/README.md +59 -7
- package/dist/module.d.mts +15 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
- package/dist/runtime/components/fields/RichTextField.vue +37 -1
- package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
- package/dist/runtime/components/fields/TagsField.vue +55 -0
- package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
- package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
- package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
- package/dist/runtime/components/ui/EPDialog.vue +6 -2
- package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
- package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
- package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
- package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
- package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
- package/dist/runtime/composables/useEponymeCollection.js +29 -4
- package/dist/runtime/fields/index.d.ts +2 -0
- package/dist/runtime/fields/index.js +2 -1
- package/dist/runtime/fields/tags.d.ts +6 -0
- package/dist/runtime/fields/tags.js +3 -0
- package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
- package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
- package/dist/runtime/server/services/eponyme-form-store.js +2 -2
- package/dist/runtime/server/services/eponyme-store.d.ts +278 -30
- package/dist/runtime/server/services/eponyme-store.js +373 -71
- package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
- package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
- package/dist/runtime/types/eponyme.d.ts +53 -2
- package/dist/runtime/types/field.d.ts +35 -2
- package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
- package/dist/runtime/utils/eponyme-entry-index.js +51 -0
- package/dist/runtime/utils/get-field-default-value.js +2 -0
- package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
- package/dist/runtime/utils/{normalize-eponyme-phones.js → normalize-eponyme-values.js} +4 -2
- package/dist/runtime/utils/normalize-tags.d.ts +10 -0
- package/dist/runtime/utils/normalize-tags.js +17 -0
- package/dist/runtime/utils/validate-eponyme-data.js +16 -0
- package/dist/runtime/utils/variables.d.ts +4 -0
- package/dist/runtime/utils/variables.js +8 -1
- package/package.json +3 -2
- 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 {
|
|
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)
|
|
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,
|
|
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.
|
|
165
|
-
if (
|
|
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:
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
await
|
|
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 =
|
|
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.
|
|
433
|
-
|
|
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.
|
|
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 =
|
|
528
|
-
const patchErrors = validateEponymePatch(schema,
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
677
|
-
|
|
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
|
+
}>;
|