@karibsen/eponyme 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +109 -45
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +109 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +19 -6
  6. package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
  7. package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
  9. package/dist/runtime/components/fields/FieldRenderer.vue +22 -0
  10. package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
  11. package/dist/runtime/components/fields/PhoneField.vue +61 -0
  12. package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  14. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  15. package/dist/runtime/components/fields/TagsField.vue +55 -0
  16. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  17. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  18. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  19. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  20. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  21. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  22. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  23. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  24. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  25. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  26. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  27. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  28. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  29. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  30. package/dist/runtime/composables/useEponymeForm.js +1 -8
  31. package/dist/runtime/fields/index.d.ts +4 -0
  32. package/dist/runtime/fields/index.js +3 -1
  33. package/dist/runtime/fields/phone.d.ts +2 -0
  34. package/dist/runtime/fields/phone.js +3 -0
  35. package/dist/runtime/fields/tags.d.ts +6 -0
  36. package/dist/runtime/fields/tags.js +3 -0
  37. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  38. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  39. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  40. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  41. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  42. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  43. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  44. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  45. package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
  46. package/dist/runtime/server/services/eponyme-store.js +405 -73
  47. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  48. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  49. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  50. package/dist/runtime/types/eponyme.d.ts +54 -6
  51. package/dist/runtime/types/field.d.ts +82 -4
  52. package/dist/runtime/types/index.d.ts +0 -1
  53. package/dist/runtime/types/index.js +0 -1
  54. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  55. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  56. package/dist/runtime/utils/get-field-default-value.js +2 -0
  57. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  58. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  59. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  60. package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
  61. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  62. package/dist/runtime/utils/normalize-phone.js +17 -0
  63. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  64. package/dist/runtime/utils/normalize-tags.js +17 -0
  65. package/dist/runtime/utils/validate-eponyme-data.js +45 -3
  66. package/dist/runtime/utils/variables.d.ts +4 -0
  67. package/dist/runtime/utils/variables.js +8 -1
  68. package/package.json +4 -2
  69. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  70. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  71. package/dist/runtime/types/captcha.d.ts +0 -16
  72. package/dist/runtime/types/captcha.js +0 -0
  73. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  74. package/dist/runtime/utils/no-captcha.js +0 -5
@@ -1,5 +1,6 @@
1
1
  import type { EponymeCollectionDefinitionBase, EponymeConfig, EponymeSchema } from '../../types/index.js';
2
2
  import { type ValidationErrors } from '../../utils/validate-eponyme-data.js';
3
+ import { type EponymeIndexRow } from '../../utils/eponyme-entry-index.js';
3
4
  export type EponymeAction = 'draft' | 'publish';
4
5
  export type EponymeVersion = 'draft' | 'published';
5
6
  /** Either the live draft/published content, or a numeric id from the version history. */
@@ -75,11 +76,31 @@ export interface EponymeImportRejection {
75
76
  schemaMismatch?: string[];
76
77
  }
77
78
  export type EponymeSortDirection = 'asc' | 'desc';
79
+ /** Comparisons a filter may ask for. Only fields stored in a sortable text form accept one. */
80
+ export interface EponymeFilterRange {
81
+ gte?: string;
82
+ lte?: string;
83
+ gt?: string;
84
+ lt?: string;
85
+ }
86
+ export interface EponymeFilterOperators extends EponymeFilterRange {
87
+ in?: string[];
88
+ /** Excluded values. Resolved by subtraction: the index only records what is there. */
89
+ not?: string[];
90
+ contains?: string;
91
+ }
92
+ /**
93
+ * One condition of a `where`. A list means "any of these", so the values of one key are
94
+ * ORed together while the keys themselves are ANDed.
95
+ */
96
+ export type EponymeFilterCondition = string | string[] | EponymeFilterOperators;
78
97
  export interface EponymeCollectionQuery {
79
98
  take?: number;
80
99
  skip?: number;
81
100
  orderBy?: string;
82
101
  order?: EponymeSortDirection;
102
+ /** Keyed by field name. Only the keys `collectionFilterKeys` reports can be used. */
103
+ where?: Record<string, EponymeFilterCondition>;
83
104
  }
84
105
  export interface EponymeCollectionPage<Data extends Record<string, unknown> = Record<string, unknown>> {
85
106
  entries: EponymeCollectionEntry<Data>[];
@@ -87,12 +108,65 @@ export interface EponymeCollectionPage<Data extends Record<string, unknown> = Re
87
108
  total: number;
88
109
  }
89
110
  export declare const COLLECTION_METADATA_KEYS: readonly ["updatedAt", "publishedAt", "title", "slug"];
111
+ /**
112
+ * A content row. Everything but the name is optional because a query narrows what it reads —
113
+ * a public listing selects `published` and never `draft` — and a narrowed row has to satisfy
114
+ * this type as it is, without an overload per shape.
115
+ */
90
116
  export type PrismaEponymeRow = {
91
117
  name: string;
92
- data: unknown;
118
+ draft?: unknown;
119
+ published?: unknown;
120
+ status?: string;
121
+ publishedAt?: Date | string | null;
93
122
  updatedAt?: Date | string;
94
123
  deletedAt?: Date | string | null;
95
124
  };
125
+ export type EponymeRowWrite = {
126
+ draft: Record<string, unknown>;
127
+ published: Record<string, unknown>;
128
+ status: EponymeStatus;
129
+ publishedAt: Date | null;
130
+ };
131
+ export type PrismaEponymeSelect = {
132
+ name?: true;
133
+ draft?: true;
134
+ published?: true;
135
+ status?: true;
136
+ publishedAt?: true;
137
+ updatedAt?: true;
138
+ };
139
+ export type PrismaEponymeQuery = {
140
+ where: {
141
+ /** `in` is what a filtered listing uses: the index resolves the names first. */
142
+ name: {
143
+ startsWith: string;
144
+ } | {
145
+ in: string[];
146
+ };
147
+ deletedAt?: PrismaDeletedAtFilter;
148
+ publishedAt?: {
149
+ not: null;
150
+ };
151
+ };
152
+ orderBy?: PrismaEponymeOrderBy[];
153
+ take?: number;
154
+ skip?: number;
155
+ };
156
+ /**
157
+ * `nulls` appears on `publishedAt` alone: Prisma refuses it on a column that cannot be null,
158
+ * and `updatedAt` never is. A test double cannot catch that — only the real client does.
159
+ */
160
+ export type PrismaEponymeOrderBy = {
161
+ name: 'asc';
162
+ } | {
163
+ updatedAt: EponymeSortDirection;
164
+ } | {
165
+ publishedAt: {
166
+ sort: EponymeSortDirection;
167
+ nulls: 'last';
168
+ };
169
+ };
96
170
  /** `null` keeps the live rows, `{ not: null }` the trashed ones. */
97
171
  export type PrismaDeletedAtFilter = null | {
98
172
  not: null;
@@ -110,27 +184,27 @@ export type PrismaEponymeVersionRow = {
110
184
  username: string;
111
185
  } | null;
112
186
  };
113
- export type PrismaEponymeClient = {
187
+ /**
188
+ * The model delegates, which a transaction exposes just like the client does.
189
+ * They are named on their own because Prisma hands an interactive transaction a client
190
+ * stripped of `$transaction`, so the callback cannot be typed as the full client.
191
+ */
192
+ export type PrismaEponymeDelegates = {
114
193
  eponyme: {
115
194
  upsert(args: {
116
195
  where: {
117
196
  name: string;
118
197
  };
119
- create: {
198
+ create: EponymeRowWrite & {
120
199
  name: string;
121
- data: Record<string, unknown>;
122
- };
123
- update: {
124
- data?: Record<string, unknown>;
125
200
  };
201
+ update: Partial<EponymeRowWrite>;
126
202
  }): Promise<PrismaEponymeRow>;
127
203
  update(args: {
128
204
  where: {
129
205
  name: string;
130
206
  };
131
- data: {
132
- data: Record<string, unknown>;
133
- };
207
+ data: EponymeRowWrite;
134
208
  }): Promise<PrismaEponymeRow>;
135
209
  /** Used as a compare-and-swap: the `updatedAt` guard makes concurrent writes fail instead of overwriting. */
136
210
  updateMany(args: {
@@ -139,17 +213,15 @@ export type PrismaEponymeClient = {
139
213
  updatedAt?: Date | string;
140
214
  deletedAt?: PrismaDeletedAtFilter;
141
215
  };
142
- data: {
143
- data?: Record<string, unknown>;
216
+ data: Partial<EponymeRowWrite> & {
144
217
  deletedAt?: Date | null;
145
218
  };
146
219
  }): Promise<{
147
220
  count: number;
148
221
  }>;
149
222
  create(args: {
150
- data: {
223
+ data: EponymeRowWrite & {
151
224
  name: string;
152
- data: Record<string, unknown>;
153
225
  };
154
226
  }): Promise<PrismaEponymeRow>;
155
227
  findUnique(args: {
@@ -157,19 +229,14 @@ export type PrismaEponymeClient = {
157
229
  name: string;
158
230
  };
159
231
  }): Promise<PrismaEponymeRow | null>;
160
- findMany(args: {
161
- where: {
162
- name: {
163
- startsWith: string;
164
- };
165
- deletedAt?: PrismaDeletedAtFilter;
166
- };
167
- orderBy: Array<{
168
- updatedAt: 'desc';
169
- } | {
170
- name: 'asc';
171
- }>;
232
+ /**
233
+ * One signature rather than an overload per `select`: every column of
234
+ * `PrismaEponymeRow` but `name` is optional, so a narrowed row satisfies it as it is.
235
+ */
236
+ findMany(args: PrismaEponymeQuery & {
237
+ select?: PrismaEponymeSelect;
172
238
  }): Promise<PrismaEponymeRow[]>;
239
+ count(args: PrismaEponymeQuery): Promise<number>;
173
240
  delete(args: {
174
241
  where: {
175
242
  name: string;
@@ -204,6 +271,94 @@ export type PrismaEponymeClient = {
204
271
  };
205
272
  }): Promise<PrismaEponymeVersionRow | null>;
206
273
  };
274
+ eponymeEntryIndex: {
275
+ /** The rows of an entry are replaced as a whole, so a removed value cannot survive. */
276
+ deleteMany(args: {
277
+ where: {
278
+ entryName: string | {
279
+ startsWith: string;
280
+ };
281
+ };
282
+ }): Promise<{
283
+ count: number;
284
+ }>;
285
+ createMany(args: {
286
+ data: EponymeIndexRow[];
287
+ }): Promise<{
288
+ count: number;
289
+ }>;
290
+ findMany(args: {
291
+ where: {
292
+ entryName: {
293
+ startsWith: string;
294
+ };
295
+ version: 'draft' | 'published';
296
+ key: string;
297
+ value: PrismaStringFilter;
298
+ };
299
+ select: {
300
+ entryName: true;
301
+ };
302
+ }): Promise<Array<{
303
+ entryName: string;
304
+ }>>;
305
+ };
306
+ eponymeIndexState: {
307
+ findMany(): Promise<Array<{
308
+ name: string;
309
+ fingerprint: string;
310
+ }>>;
311
+ upsert(args: {
312
+ where: {
313
+ name: string;
314
+ };
315
+ create: {
316
+ name: string;
317
+ fingerprint: string;
318
+ };
319
+ update: {
320
+ fingerprint: string;
321
+ };
322
+ }): Promise<{
323
+ name: string;
324
+ fingerprint: string;
325
+ }>;
326
+ deleteMany(args: {
327
+ where: {
328
+ name: {
329
+ in: string[];
330
+ };
331
+ };
332
+ }): Promise<{
333
+ count: number;
334
+ }>;
335
+ };
336
+ };
337
+ /**
338
+ * What one condition asks the index for. Text order is the point: a range only makes sense
339
+ * because every indexed value is stored in a form whose alphabetical order is its natural
340
+ * one — see `buildEponymeIndexRows`.
341
+ */
342
+ export type PrismaStringFilter = string | {
343
+ in?: string[];
344
+ /** Substring match. A leading wildcard cannot use the index's ordering, so this scans. */
345
+ contains?: string;
346
+ gte?: string;
347
+ lte?: string;
348
+ gt?: string;
349
+ lt?: string;
350
+ };
351
+ export type PrismaEponymeClient = PrismaEponymeDelegates & {
352
+ /**
353
+ * Prisma's interactive transaction. Writes that only make sense together — an entry and
354
+ * its history version, an import and every version it writes — run inside one, so a
355
+ * failure between the two rolls back instead of leaving the history disagreeing with
356
+ * the content it describes.
357
+ */
358
+ $transaction<T>(fn: (tx: PrismaEponymeDelegates) => Promise<T>, options?: {
359
+ maxWait?: number;
360
+ timeout?: number;
361
+ }): Promise<T>;
207
362
  };
208
363
  export declare class EponymeService {
209
364
  private readonly client;
@@ -228,14 +383,81 @@ export declare class EponymeService {
228
383
  * collection that contains it, whose row set just changed.
229
384
  */
230
385
  private invalidate;
386
+ /**
387
+ * Replaces an entry's filterable values, in the same transaction as the write that
388
+ * changed them.
389
+ *
390
+ * Deleting before inserting rather than diffing: an entry carries a handful of rows, the
391
+ * two statements are cheap, and a diff is one more place for the index to drift from the
392
+ * content it describes — a failure mode that returns wrong listings without erroring.
393
+ */
394
+ private reindexEntry;
395
+ collectionFilterKeys(name: string): string[] | undefined;
396
+ /**
397
+ * One transaction per entry rather than one for the whole collection: a collection has no
398
+ * bound, and a single transaction over it would exceed Prisma's timeout on anything real.
399
+ * The unit that has to be atomic is the entry, and it is.
400
+ */
401
+ private reindexName;
402
+ private indexedNames;
403
+ /**
404
+ * The unconditional rebuild, for a first backfill or a manual repair. It refreshes the
405
+ * recorded fingerprints too, so a repair does not leave the next boot with work to redo.
406
+ */
407
+ reindexAll(): Promise<{
408
+ entries: number;
409
+ }>;
410
+ /**
411
+ * Rebuilds only what a configuration change invalidated, and nothing when nothing changed.
412
+ *
413
+ * This is what makes the index self-maintaining. Ordinary writes keep it current, but a
414
+ * newly filterable field leaves the entries already stored without any row for that key —
415
+ * and a filter on them then answers "none" rather than failing, which is the worst way for
416
+ * this to break.
417
+ *
418
+ * Unchanged configuration costs one read of a table with one row per collection.
419
+ */
420
+ syncIndexState(): Promise<{
421
+ reindexed: string[];
422
+ }>;
423
+ private indexFingerprint;
424
+ private writeIndexState;
425
+ /**
426
+ * Runs writes that have to land together, then drops what they invalidated.
427
+ *
428
+ * The callback reports the entries it touched rather than invalidating them itself:
429
+ * inside the transaction the write has not landed yet, and a read racing an
430
+ * uncommitted change would re-cache the old row and hold it for the whole TTL. The
431
+ * drop is deliberate on a rollback too — losing a cache entry costs one query, while
432
+ * keeping a wrong one costs the TTL.
433
+ */
434
+ private transaction;
231
435
  getSchema(name: string): EponymeSchema | undefined;
232
436
  getCollection(name: string): EponymeCollectionDefinitionBase | undefined;
233
437
  private getCollectionEntry;
234
438
  /** Reconcile every configured eponyme at application startup. This is the one place that heals. */
235
439
  syncAll(): Promise<void>;
440
+ /**
441
+ * Brings a stored document back in line with the schema: keys the schema no longer declares
442
+ * are dropped, and a key it declares but the document lacks takes its default.
443
+ *
444
+ * Recursive, because a field added inside a section, a tab or an array item is otherwise
445
+ * never filled in: the container it lives in already exists and is still valid, so a
446
+ * top-level pass keeps it untouched and the new field reads as missing forever. That was
447
+ * silent — `defaultValue` simply did nothing for anything but a root field.
448
+ */
236
449
  private reconcile;
450
+ /** Descends into the containers; a leaf is returned untouched for `reconcile` to judge. */
451
+ private reconcileField;
237
452
  private createState;
453
+ /**
454
+ * Reads a state out of an envelope. Live content is in columns now, so this serves the two
455
+ * places an envelope still exists: a history row, which deliberately keeps the old shape so
456
+ * that versions written before this change stay restorable, and an import, which rebuilds
457
+ * one from the portable file.
458
+ */
238
459
  private normalizeState;
460
+ private rowToState;
239
461
  /**
240
462
  * A singleton row is created on first read, but only when it is genuinely missing.
241
463
  * An `upsert` would send a write on every read instead, and each one costs a database
@@ -259,14 +481,33 @@ export declare class EponymeService {
259
481
  * Writes a new state only if the row still carries the `updatedAt` we read.
260
482
  * Returns false when a concurrent editor won the race, so the caller can report a conflict
261
483
  * instead of silently discarding their changes.
484
+ *
485
+ * Dropping the cached row is left to `transaction`, which does it once the write has
486
+ * actually committed rather than while it is still in flight.
262
487
  */
263
488
  private writeState;
264
489
  /**
265
- * The live rows of a collection, shared by the public listing and the sitemap so both
266
- * read them once. The trash and the export deliberately query around it: one wants the
267
- * rows this filter excludes, the other must not see a cached view of the content.
490
+ * Every live row of a collection, payload included. The reindex needs both versions of the
491
+ * content, so this is the one read that still selects everything; a listing goes through
492
+ * `collectionRows` with a narrower select.
268
493
  */
269
494
  private liveCollectionRows;
495
+ /**
496
+ * The names a `where` keeps, resolved from the index table before a single row of content
497
+ * is read. This is the point of the index: a filtered listing loads and normalizes the
498
+ * entries it returns, not the whole collection.
499
+ *
500
+ * One query per key, intersected: the values of a key are ORed by the query itself, and
501
+ * the keys are ANDed by narrowing the set. An empty set short-circuits the whole listing.
502
+ */
503
+ private resolveFilter;
504
+ private indexNames;
505
+ private collectionEntryNames;
506
+ /**
507
+ * The rows a filter selected. Never cached: the shared `rows:` key holds the whole
508
+ * collection, and one key per filter combination would evict it for no reuse.
509
+ */
510
+ private filteredCollectionRows;
270
511
  get(name: string, version?: EponymeVersionSelector): Promise<Record<string, unknown> | undefined>;
271
512
  getResult(name: string, version?: EponymeVersionSelector): Promise<EponymeResult | undefined>;
272
513
  /** Content as it was in a given version, for previewing without restoring it. */
@@ -275,6 +516,17 @@ export declare class EponymeService {
275
516
  /** Metadata keys that can be sorted on, alongside every field of the collection. */
276
517
  collectionSortKeys(name: string): string[] | undefined;
277
518
  listCollection(name: string, version?: EponymeVersion, query?: EponymeCollectionQuery): Promise<EponymeCollectionPage | undefined>;
519
+ /**
520
+ * The unpaginated read, still cached: an unfiltered listing of one collection is what a
521
+ * page hits repeatedly, and the key carries the version so a draft view cannot answer a
522
+ * public one.
523
+ */
524
+ private collectionRows;
525
+ /**
526
+ * `findMany` already carries every payload, so the state is built here rather than re-read
527
+ * one entry at a time: a listing costs one query, not N+1.
528
+ */
529
+ private toCollectionEntries;
278
530
  /**
279
531
  * Builds the public URL list configured by `previewPaths`.
280
532
  * Collections are resolved directly from their stored rows so this performs
@@ -291,12 +543,19 @@ export declare class EponymeService {
291
543
  * upserted, everything else is left alone. Nothing is ever deleted.
292
544
  *
293
545
  * The schema check runs first and refuses the whole file, so an import can never
294
- * land half of its entries into a configuration that no longer matches.
546
+ * land half of its entries into a configuration that no longer matches. The writes then
547
+ * run in one transaction, so a failure partway through leaves nothing behind either —
548
+ * an interrupted import can simply be replayed.
295
549
  */
296
550
  importContent(file: unknown, options?: {
297
551
  dryRun?: boolean;
298
552
  actorId?: string;
299
553
  }): Promise<EponymeImportResult | EponymeImportRejection>;
554
+ /**
555
+ * The body of an import, over whichever client it was handed: the live one for a dry
556
+ * run, a transaction for the real thing.
557
+ */
558
+ private applyImport;
300
559
  createCollectionEntry(name: string, payload: unknown, actorId?: string): Promise<(EponymeResult & {
301
560
  slug: string;
302
561
  errors?: never;