@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.
- package/README.md +109 -45
- package/dist/eponyme.mjs +2 -3
- package/dist/module.d.mts +109 -13
- package/dist/module.json +1 -1
- package/dist/module.mjs +19 -6
- package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
- package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
- package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
- package/dist/runtime/components/fields/FieldRenderer.vue +22 -0
- package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
- package/dist/runtime/components/fields/PhoneField.vue +61 -0
- package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -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/EPLinkEditor.vue +2 -1
- 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/composables/useEponymeForm.d.ts +0 -4
- package/dist/runtime/composables/useEponymeForm.js +1 -8
- package/dist/runtime/fields/index.d.ts +4 -0
- package/dist/runtime/fields/index.js +3 -1
- package/dist/runtime/fields/phone.d.ts +2 -0
- package/dist/runtime/fields/phone.js +3 -0
- package/dist/runtime/fields/tags.d.ts +6 -0
- package/dist/runtime/fields/tags.js +3 -0
- package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
- package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
- package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
- package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-form-store.js +4 -3
- package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
- package/dist/runtime/server/services/eponyme-store.js +405 -73
- 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-config.d.ts +0 -7
- package/dist/runtime/types/eponyme.d.ts +54 -6
- package/dist/runtime/types/field.d.ts +82 -4
- package/dist/runtime/types/index.d.ts +0 -1
- package/dist/runtime/types/index.js +0 -1
- 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/middle-ellipsis.d.ts +1 -0
- package/dist/runtime/utils/middle-ellipsis.js +8 -0
- package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
- package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
- package/dist/runtime/utils/normalize-phone.d.ts +26 -0
- package/dist/runtime/utils/normalize-phone.js +17 -0
- 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 +45 -3
- package/dist/runtime/utils/variables.d.ts +4 -0
- package/dist/runtime/utils/variables.js +8 -1
- package/package.json +4 -2
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
- package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
- package/dist/runtime/types/captcha.d.ts +0 -16
- package/dist/runtime/types/captcha.js +0 -0
- package/dist/runtime/utils/no-captcha.d.ts +0 -7
- 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)
|
|
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 =
|
|
191
|
+
const persisted = isPlainObject(value) ? value : {};
|
|
68
192
|
const defaults = createDefaultEponymeData(schema);
|
|
69
193
|
return Object.fromEntries(Object.keys(schema).map((key) => {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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,
|
|
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.
|
|
135
|
-
if (
|
|
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:
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
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({
|
|
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.
|
|
403
|
-
|
|
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.
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
647
|
-
|
|
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) => {
|