@nonext/husk 0.2.3

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 (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,1267 @@
1
+ import { C as CmsAdapter, q as SortSpec, E as EntryFilter, L as ListQuery, n as MigrationAdapter } from '../adapter-C_0mhENy.js';
2
+ export { D as DEFAULT_PAGE_SIZE, a as EntryInputData, F as FieldDataAdapter, b as FieldPurgeOptions, c as FieldPurgeResult, d as FilterOperator, M as MAX_FIELD_PURGE, e as MAX_PAGE_SIZE, f as MEDIA_USAGE_LIMIT, g as MediaAdapter, h as MediaInput, i as MediaQuery, j as MediaRefreshResult, k as MediaUsage, l as MediaUsageQuery, m as MediaUsageReference, o as MigrationWrite, P as Page, S as SchemaAdapter, p as SettingsAdapter, r as StoredEntryRecord, s as normalizeLimit } from '../adapter-C_0mhENy.js';
3
+ export { C as Cms, a as CmsConfig, b as CmsCountQuery, c as CmsQuery, d as CmsReadConfig, e as CollectionReader, f as ContentTypeMap, E as EntryOf, K as KnownSlug, M as MediaReader, S as SettingsReader, g as SingleReader, h as SlugOf, i as assertDeletable, j as assertListable, k as assertSingletonCreatable, l as assertSingletonEntryId, m as createCMS, n as isCollection, o as isSingleton, s as singletonEntryId } from '../cms-9fSA3uQV.js';
4
+ import { k as RemovedFieldMap, C as ContentType, c as ContentTypeLike, f as Entry, F as FieldError, h as EntryMeta, i as EntryStatus, b as ContentTypeKind, d as ContentTypeSource, j as RemovedField } from '../entry-D3kiE8T1.js';
5
+ import { c as FieldMap, a as FieldDefinition, V as ValidationRules, d as FieldType } from '../field-Diyf5siF.js';
6
+ import { f as FieldTypeRegistry } from '../field-type-D25Vojb0.js';
7
+ import { b as AuthUser, e as Role } from '../auth-B2gBtHl5.js';
8
+ export { A as ALLOWED_MEDIA_TYPES, C as Clock, F as FIRESTORE_DOCUMENT_BYTES, M as MAX_ARRAY_ITEMS, a as MAX_CONTENT_TYPE_FIELDS, b as MAX_ENTRY_FIELDS, c as MAX_IMAGE_BYTES, d as MAX_MEDIA_BYTES, e as MAX_NESTING_DEPTH, f as MAX_SETTINGS_FIELDS, g as MAX_WRITE_BYTES, h as MEDIA_ACCEPT_ATTRIBUTE, i as MIN_WRITE_INTERVAL_MS, j as MediaCategory, k as MediaFileFacts, l as MediaRejection, m as MediaRejectionCode, n as MediaTypeRule, P as PayloadLimits, W as WRITE_BURST_LIMIT, o as WRITE_BURST_WINDOW_MS, p as WriteLimitCode, q as WriteLimitRejection, r as WriteRateLimiter, s as approximateBytes, t as checkMediaFile, u as checkWritePayload, v as createWriteRateLimiter, w as fileExtension, x as mediaTypeRule, y as normalizeMediaType, z as safeFileName } from '../write-limits-Djmm6L3R.js';
9
+ import { R as Registry } from '../registry-CUC25qay.js';
10
+ export { I as InvalidContentType, a as RegistryEntry, b as assertSchemaWritable, c as buildRegistry, i as isSchemaReadOnly } from '../registry-CUC25qay.js';
11
+ import 'react';
12
+
13
+ /**
14
+ * The cache tag vocabulary, as plain strings.
15
+ *
16
+ * A cached read and the thing that invalidates it have to agree on a name, and they are
17
+ * almost never the same file: one is a page in the customer's app, the other is a route
18
+ * handler, a script, or a person typing into a terminal. Two hand-written strings that must
19
+ * match is exactly the kind of agreement that rots, so the names are computed here and both
20
+ * sides import the same function.
21
+ *
22
+ * Nothing in this module imports Next. The functions are pure, which is what lets the same
23
+ * names be used with `cacheTag` under Cache Components, with `unstable_cache({ tags })`
24
+ * without it, with a CDN's surrogate keys, or with nothing at all. Husk does not decide the
25
+ * caching strategy for a customer's website; it only makes the names available.
26
+ *
27
+ * What Husk cannot do with them is call `revalidateTag` by itself. The admin writes from the
28
+ * browser through the client SDK, so there is no server-side write hook in this architecture
29
+ * to hang an invalidation on. See `docs/caching.md` and
30
+ * `.docs/decisions/2026-09-19-caching-and-revalidation.md` for the seam and what closes it.
31
+ */
32
+ /**
33
+ * The namespace every Husk tag starts with.
34
+ *
35
+ * A customer's own cache tags share the tag space with these, so they carry a prefix that
36
+ * says where they came from. It also means a customer can reason about "everything Husk"
37
+ * when reading a list of tags in a log.
38
+ */
39
+ declare const HUSK_TAG_PREFIX = "husk";
40
+ /**
41
+ * Everything of one content type: the list, the counts, every entry of it.
42
+ *
43
+ * This is the tag a list page uses. It is also the one an editor's publish has to clear,
44
+ * because a new entry has no entry tag anything could have cached under yet.
45
+ */
46
+ declare function contentTypeTag(slug: string): string;
47
+ /**
48
+ * One entry.
49
+ *
50
+ * Narrow on purpose: editing one project should not have to discard the cached page of
51
+ * every other project. A detail page tags both this and the content type, so either level
52
+ * of invalidation reaches it.
53
+ */
54
+ declare function entryTag(slug: string, entryId: string): string;
55
+ /**
56
+ * One entry, named by the value of its slug field rather than by its id.
57
+ *
58
+ * A URL-addressed page knows the slug and does not know the id until it has read the entry,
59
+ * which is one read too late to tag a cache entry with. The publish side knows both, so the
60
+ * two meet: a page caches under the slug it was asked for, and an invalidation clears the
61
+ * slug the entry carries. Renaming a slug moves the page to a new URL and leaves the old
62
+ * tag holding a page nothing links to any more, which expires on its own.
63
+ */
64
+ declare function entrySlugTag(slug: string, value: string): string;
65
+ /** The pair a detail page caches under. Order is stable, so it is comparable. */
66
+ declare function entryTags(slug: string, entryId: string): readonly string[];
67
+ /**
68
+ * One media item.
69
+ *
70
+ * Replacing a file keeps the media id and mints a new download URL, so a page that cached
71
+ * the old URL is stale in a way no entry-level tag describes.
72
+ */
73
+ declare function mediaTag(mediaId: string): string;
74
+ /** One settings document, by key. */
75
+ declare function settingsTag(key: string): string;
76
+ /** Whether a string is one of the names above, for a revalidation endpoint to check. */
77
+ declare function isHuskTag(value: string): boolean;
78
+
79
+ /**
80
+ * Declares a content type and infers its entry shape from its field map.
81
+ *
82
+ * The `const` type parameter is what makes inference work: without it, `required: true`
83
+ * widens to `boolean` and every field would read as nullable, and select options would
84
+ * widen to `string` instead of the union of the declared values.
85
+ *
86
+ * Structural mistakes throw here rather than at first write, so a code-defined type fails
87
+ * at module load and never reaches Firestore.
88
+ */
89
+ declare function defineContentType<const TFields extends FieldMap>(definition: ContentType<TFields>): ContentType<TFields>;
90
+ /**
91
+ * The structural checks, separated from `defineContentType` so that a database-defined type
92
+ * read out of Firestore goes through exactly the same guards as a code-defined one. The
93
+ * registry calls this; it must never trust a stored definition more than a written one.
94
+ */
95
+ declare function assertValidContentType(definition: {
96
+ slug: string;
97
+ kind: unknown;
98
+ fields: FieldMap;
99
+ removedFields?: RemovedFieldMap | undefined;
100
+ titleField?: string | undefined;
101
+ defaultSort?: {
102
+ field: string;
103
+ direction: "asc" | "desc";
104
+ } | undefined;
105
+ permissions?: unknown;
106
+ }): void;
107
+
108
+ /**
109
+ * The phrase a destructive confirmation asks the user to type, and what counts as typing
110
+ * it. Canonical source: .docs/styleguide/04-components.md section 12, "Destructive
111
+ * confirmation".
112
+ *
113
+ * Two failure modes bound this, and they pull in opposite directions. A phrase the user
114
+ * cannot produce is a trap: the delete becomes impossible and the only way out is the
115
+ * cancel button. A phrase any keypress satisfies is theatre: it costs a gesture and buys
116
+ * no attention. So the phrase is always printed in the dialog, always typeable, and always
117
+ * specific to what is about to be destroyed.
118
+ *
119
+ * None of it renders, so all of it is testable without a DOM, and the dialog and any
120
+ * future bulk flow cannot disagree about what "the name" is.
121
+ */
122
+ /**
123
+ * What to type to delete one entry.
124
+ *
125
+ * The entry's title, when it has one. When it does not (no `titleField`, a non-string
126
+ * value, or a blank string) `entryTitle` answers "Untitled", and "Untitled" is a
127
+ * placeholder rather than a name: it is the same word for every untitled entry in the
128
+ * collection, so typing it proves nothing about which entry is meant. The document id is
129
+ * used instead. It is ugly and it is unique, and the dialog prints it, so it is typeable.
130
+ *
131
+ * Two entries can still legitimately share a title. That ambiguity is harmless here,
132
+ * because the dialog is already pointed at one entry: the phrase is a gate on attention,
133
+ * not a lookup key.
134
+ */
135
+ declare function deleteConfirmationName(type: ContentTypeLike, entry: Entry): string;
136
+ /**
137
+ * What to type to delete several entries at once.
138
+ *
139
+ * Not a list of names: ten names is a transcription exercise, and an editor would paste it
140
+ * or give up. The count and the content type is the fact worth confirming, because the
141
+ * mistake a bulk delete actually makes is about how many rows were selected.
142
+ */
143
+ declare function deleteConfirmationPhrase(type: ContentTypeLike, count: number): string;
144
+ /**
145
+ * The comparison form of a confirmation phrase.
146
+ *
147
+ * Forgiving of whitespace: leading and trailing space, and any run of spaces, tabs or
148
+ * newlines inside, collapse to one space. None of that is visible in the input, so
149
+ * refusing it would fail a user who typed the phrase correctly and cannot see why it was
150
+ * rejected. Unicode is normalized to NFC for the same reason: an accented character typed
151
+ * on one keyboard and stored from another can be two byte sequences that read identically.
152
+ *
153
+ * Deliberately not forgiving of case. Case is visible, the phrase is printed right above
154
+ * the input, and matching it is the deliberate act the gate exists to require. This is the
155
+ * same choice GitHub makes for deleting a repository, and it is the line between a
156
+ * confirmation and a formality.
157
+ */
158
+ declare function normalizeConfirmation(value: string): string;
159
+ /**
160
+ * Whether what was typed confirms the phrase.
161
+ *
162
+ * An empty expected phrase never matches. A caller that cannot name what it is about to
163
+ * delete has no business unlocking the button, and an empty input would otherwise satisfy
164
+ * an empty phrase on an empty dialog.
165
+ */
166
+ declare function confirmationMatches(expected: string, typed: string): boolean;
167
+
168
+ /**
169
+ * The entry form as data: which fields it renders, what it starts from, which error
170
+ * belongs to which control, and whether anything has changed.
171
+ *
172
+ * None of it renders, so all of it is testable in the node project, and the create screen
173
+ * and the edit screen cannot drift apart by deriving the same answers twice. The field
174
+ * type registry arrives as an argument rather than as an import, because `src/fields`
175
+ * pulls in React and core must not.
176
+ */
177
+ interface FormField {
178
+ key: string;
179
+ field: FieldDefinition;
180
+ }
181
+ /**
182
+ * The fields the form renders, in the order the schema declares them.
183
+ *
184
+ * A hidden field is skipped: it still stores and still validates (see the note on
185
+ * `hidden` in types/field.ts), it simply has no control. That is exactly why an error on
186
+ * a hidden field is reported at form level by `unroutableIssues` instead of vanishing.
187
+ */
188
+ declare function formFields(type: ContentTypeLike): readonly FormField[];
189
+ /** What a new entry starts with: every field at its own field type's default. */
190
+ declare function initialFormValues(type: ContentTypeLike, registry: FieldTypeRegistry): Record<string, unknown>;
191
+ /**
192
+ * The values of a stored entry, narrowed to the fields the schema declares.
193
+ *
194
+ * Metadata is deliberately dropped: `status`, `createdBy` and the timestamps belong to the
195
+ * adapter, and a form that carried them in its value map could write one back. A field the
196
+ * entry predates falls back to the field type's default rather than to `undefined`, so the
197
+ * control is not handed a value it has no state for.
198
+ */
199
+ declare function formValuesFromEntry(type: ContentTypeLike, entry: Entry, registry: FieldTypeRegistry): Record<string, unknown>;
200
+ /**
201
+ * The errors belonging to one field and everything nested under it.
202
+ *
203
+ * Path-based, which is what lets a server error reach the control that owns it: the
204
+ * validator and the adapter both report `blocks.1.items.0.text`, and passing that whole
205
+ * subtree to the `blocks` input lets the repeater hand it down row by row. A prefix match
206
+ * on the dot is required, or `title` would also claim `titleImage`.
207
+ */
208
+ declare function errorsUnder<TError extends FieldError>(errors: readonly TError[], path: string): TError[];
209
+ /** Where an error came from. The two are told apart because they expire differently. */
210
+ type ErrorOrigin = "client" | "server";
211
+ interface FormIssue extends FieldError {
212
+ origin: ErrorOrigin;
213
+ }
214
+ /**
215
+ * The errors on screen, from the two sources that produce them.
216
+ *
217
+ * A client error is recomputed from the current values on every keystroke, so it is always
218
+ * about what is in the form right now. A server error is a verdict about the values that
219
+ * were submitted, and only another submission can retract it: uniqueness is the clearest
220
+ * case, because nothing in the browser can know that a slug is free.
221
+ *
222
+ * So they cannot simply be concatenated. A client error wins its own path, because it is
223
+ * the more current statement about that value; a server error survives on every path the
224
+ * client has nothing to say about, which is what keeps "that slug is taken" on screen
225
+ * while the editor types the next character.
226
+ */
227
+ declare function mergeIssues(client: readonly FieldError[], server: readonly FieldError[]): readonly FormIssue[];
228
+ /**
229
+ * Issues no control will render, which is the only case where a toast or a form-level
230
+ * message is the right answer.
231
+ *
232
+ * Three ways an error gets here: it names a hidden field, it names a field the schema no
233
+ * longer declares, or it names nothing at all. In each case the form has to say it
234
+ * somewhere, and saying it next to an unrelated control would be worse than saying it at
235
+ * the top.
236
+ */
237
+ declare function unroutableIssues<TError extends FieldError>(issues: readonly TError[], type: ContentTypeLike): readonly TError[];
238
+ /**
239
+ * Whether the form holds anything the baseline does not.
240
+ *
241
+ * Structural rather than by reference, because every field input replaces the value it
242
+ * owns on each edit, and a repeater rebuilds its whole array to move one row. Reference
243
+ * equality would call an entry dirty for having been touched and untouched again, and the
244
+ * unsaved-changes guard is only worth having if it is quiet when nothing changed.
245
+ */
246
+ declare function valuesChanged(baseline: Record<string, unknown>, current: Record<string, unknown>): boolean;
247
+ /**
248
+ * Whether the stored entry moved since the editor read it.
249
+ *
250
+ * `updatedAt` is stamped by the adapter on every write, so a difference means somebody
251
+ * else saved. The editor asks this immediately before its own write rather than
252
+ * subscribing: a live listener would be a second read path, a second failure mode, and an
253
+ * interruption in the middle of typing. It closes the wide window, not the narrow one; two
254
+ * writes in the same second still resolve last-write-wins, which the adapter's document
255
+ * semantics already decide.
256
+ */
257
+ declare function entryChangedSince(baseline: Pick<EntryMeta, "updatedAt">, current: Pick<EntryMeta, "updatedAt">): boolean;
258
+
259
+ /**
260
+ * What the status control may offer, and to whom.
261
+ *
262
+ * Status is the one piece of entry metadata an editor writes, so it is not a display: it
263
+ * is a set of transitions out of the status the entry is in, each one answered by `can()`
264
+ * against the entry itself. A denied transition keeps its reason rather than disappearing,
265
+ * because "you cannot publish this" is information and an empty panel is not.
266
+ *
267
+ * Hiding or disabling a transition here is presentation. The adapter validates the write
268
+ * and the security rules check the same matrix again, so a user who reaches past this list
269
+ * gains nothing.
270
+ */
271
+ interface StatusTransition {
272
+ to: EntryStatus;
273
+ /** Imperative label for the control: "Publish", "Unpublish", "Archive". */
274
+ label: string;
275
+ /** What it does to the public site, in one sentence. */
276
+ effect: string;
277
+ allowed: boolean;
278
+ /** Why it is not offered. Null when it is. */
279
+ reason: string | null;
280
+ }
281
+ /** Just enough of an entry to answer a transition question. */
282
+ type StatusSubject = Pick<EntryMeta, "status" | "createdBy">;
283
+ /** How a status reads in the sidebar, under the badge. */
284
+ declare const STATUS_EFFECT: Readonly<Record<EntryStatus, string>>;
285
+ /**
286
+ * The transitions for one entry, in a fixed order.
287
+ *
288
+ * `entry` is null for an entry that has not been created yet. A new entry is a draft that
289
+ * does not exist, so the only move it has is the one that brings it into existence
290
+ * published, and the permission question is `create` rather than `update`.
291
+ */
292
+ declare function statusTransitions(user: AuthUser, type: ContentTypeLike, entry: StatusSubject | null): readonly StatusTransition[];
293
+ /**
294
+ * Whether this user may save the values of this entry at all.
295
+ *
296
+ * Separate from the transitions because saving without changing the status is its own
297
+ * action, and because the create screen asks the question before an entry exists.
298
+ */
299
+ declare function canSaveEntry(user: AuthUser, type: ContentTypeLike, entry: StatusSubject | null): boolean;
300
+
301
+ /**
302
+ * The live uniqueness answer the form hands to the field layer.
303
+ *
304
+ * `unique` is the one validation rule that cannot be answered without reading other
305
+ * entries, so the shared validator leaves it to the adapter, which checks it again on
306
+ * every write. This is only the hint in front of that check, and it is deliberately narrow.
307
+ *
308
+ * It answers for the content type's slug field and for nothing else. That is not a
309
+ * shortcut: `getBySlug` is a single equality query that Firestore serves from an automatic
310
+ * single-field index, while asking the same question about an arbitrary field would need a
311
+ * composite index per field, generated per content type, for a hint. Any other path is
312
+ * rejected rather than answered "free", because telling an editor a value is available
313
+ * when nobody looked is worse than telling them nothing. The rejection is what the slug
314
+ * input reads as "no answer", and it then shows no availability hint at all.
315
+ */
316
+ interface EntryUniqueOptions {
317
+ adapter: CmsAdapter;
318
+ type: ContentTypeLike;
319
+ /** The entry being edited, so its own value does not count as taken. Null on create. */
320
+ entryId: string | null;
321
+ }
322
+ declare function entryUniqueCheck(options: EntryUniqueOptions): (path: string, value: string) => Promise<boolean>;
323
+
324
+ /** What an entry is called when its title field is empty or was never declared. */
325
+ declare const UNTITLED_ENTRY = "Untitled";
326
+ /**
327
+ * The one-line name of an entry, resolved from the content type rather than guessed.
328
+ *
329
+ * Every screen that lists entries needs this: the dashboard, the collection list, a
330
+ * relation picker, a delete confirmation. Resolving it in one place is what keeps an entry
331
+ * from being called two different things on two screens.
332
+ *
333
+ * `titleField` is optional and its stored value is untrusted, so a missing field, a
334
+ * non-string value and a blank string all fall back to `UNTITLED_ENTRY`. An entry with no
335
+ * usable title still has to be nameable, or it cannot be listed at all.
336
+ */
337
+ declare function entryTitle(type: ContentTypeLike, entry: Entry): string;
338
+
339
+ /**
340
+ * The one error taxonomy the admin renders from.
341
+ *
342
+ * Before this existed the treatments were real but scattered: the entry editor routed a
343
+ * `ValidationError` to the offending field and fell back to a form-level message, the
344
+ * Schema Builder put every failure in one banner, the media screens put every failure in a
345
+ * toast, and a permission refusal read as "something went wrong" in all three. Each of
346
+ * those was defensible on its own and none of them agreed with the others.
347
+ *
348
+ * This module is not a second system layered over them. It is the first one, factored out:
349
+ * the M6 rule that a validation error lands on the field and only an unroutable path
350
+ * reaches the form is preserved exactly, as the `field` treatment, and every other screen
351
+ * now asks the same question instead of answering it privately.
352
+ *
353
+ * Five categories, one treatment each:
354
+ *
355
+ * | category | treatment | what the user sees |
356
+ * |--------------|-----------|-----------------------------------------------------------|
357
+ * | `validation` | `field` | the message on the control that owns the path; never a bare toast |
358
+ * | `permission` | `banner` | a form-level notice naming the action, the subject and the role |
359
+ * | `not-found` | `state` | an empty state with a way back; no alert, no red |
360
+ * | `conflict` | `resolve` | a banner that names both resolutions and offers them |
361
+ * | `transport` | `retry` | a banner or state carrying the one action that can help |
362
+ *
363
+ * `unknown` is the sixth, for a failure that fits none of them. It takes the `banner`
364
+ * treatment and says plainly that the cause is unknown, rather than guessing at a category
365
+ * and offering a retry that cannot work.
366
+ *
367
+ * The toast is deliberately not a treatment. It is the announcement channel for a failure
368
+ * whose surface has already closed (a dialog that dismissed itself, a background upload),
369
+ * and when it is used it prints this same `title` and `message`, so a failure can never be
370
+ * worded twice.
371
+ *
372
+ * No React and no Firebase here, so the classification is testable in the node project and
373
+ * callable from the adapter as well as from a render.
374
+ */
375
+ declare const ERROR_CATEGORIES: readonly ["validation", "permission", "not-found", "conflict", "transport", "unknown"];
376
+ type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
377
+ /** The chrome a category is rendered in. Exactly one per category; see the table above. */
378
+ type ErrorTreatment = "field" | "banner" | "state" | "resolve" | "retry";
379
+ /** The treatment a category must be rendered in. The single mapping, used by every screen. */
380
+ declare function treatmentFor(category: ErrorCategory): ErrorTreatment;
381
+ interface ClassifiedError {
382
+ category: ErrorCategory;
383
+ treatment: ErrorTreatment;
384
+ /** A short heading. Used as the banner's first line and as a toast title. */
385
+ title: string;
386
+ /** One or two sentences: what happened, and what the user can do about it. */
387
+ message: string;
388
+ /**
389
+ * The field errors, for `validation` only. Every other category leaves this empty, which
390
+ * is what stops a non-validation failure from being routed onto a control.
391
+ */
392
+ fieldErrors: readonly FieldError[];
393
+ /** True only for `transport`. A retry of anything else repeats the same refusal. */
394
+ retryable: boolean;
395
+ /** What was thrown, kept for a boundary that wants to log it. Never rendered. */
396
+ cause: unknown;
397
+ }
398
+ interface ErrorContext {
399
+ /** The signed-in role, so a permission refusal can name who was refused. */
400
+ role?: Role | undefined;
401
+ /**
402
+ * What the user was doing, as a verb phrase that completes "could not ...": "save this
403
+ * entry", "delete these entries". Used for transport and unknown failures, where the
404
+ * error itself says nothing a reader can act on.
405
+ */
406
+ action?: string | undefined;
407
+ /** What was acted on, named the way the UI names it: a content type label, a file name. */
408
+ subject?: string | undefined;
409
+ }
410
+ /**
411
+ * Classifies anything that was thrown into exactly one category with its treatment.
412
+ *
413
+ * Order matters and is by certainty. A class the package defines is conclusive, a
414
+ * Firestore code is strong evidence, and a message match is the last resort.
415
+ */
416
+ declare function classifyError(thrown: unknown, context?: ErrorContext): ClassifiedError;
417
+ /**
418
+ * The conflict the entry editor detects for itself.
419
+ *
420
+ * A stale write is noticed by comparing timestamps before the write, not by catching
421
+ * something, so there is nothing to classify. It still has to arrive as a `ClassifiedError`
422
+ * or the editor would be writing its own sentence for a category this module owns, which is
423
+ * exactly the split this module exists to close.
424
+ */
425
+ declare function staleWriteError(subject: string): ClassifiedError;
426
+ /** The other locally detected case: the thing being edited is gone. A state, not an error. */
427
+ declare function missingError(subject: string): ClassifiedError;
428
+ /** The locally detected permission refusal, for a screen that asked `can()` and got false. */
429
+ declare function deniedError(what: string, role: Role | undefined): ClassifiedError;
430
+
431
+ /**
432
+ * A content type, field map, or registry is malformed. Thrown at definition time, which
433
+ * for a code-defined type means at module load, so the mistake surfaces before any data
434
+ * is written.
435
+ */
436
+ declare class SchemaError extends Error {
437
+ readonly name = "SchemaError";
438
+ constructor(message: string);
439
+ }
440
+ /** Entry data failed the compiled validator. Carries one error per offending field path. */
441
+ declare class ValidationError extends Error {
442
+ readonly name = "ValidationError";
443
+ readonly errors: readonly FieldError[];
444
+ constructor(errors: readonly FieldError[]);
445
+ }
446
+ /** A schema write was attempted against a content type that does not accept one. */
447
+ declare class LockedTypeError extends Error {
448
+ readonly name = "LockedTypeError";
449
+ readonly slug: string;
450
+ constructor(slug: string, reason?: string);
451
+ }
452
+ /** A singleton was asked to hold more than the one entry it is allowed. */
453
+ declare class SingletonError extends Error {
454
+ readonly name = "SingletonError";
455
+ readonly slug: string;
456
+ constructor(slug: string, message: string);
457
+ }
458
+ /**
459
+ * The caller is not allowed to do this. Thrown by the adapter, from the same `can()` the
460
+ * UI asked before it drew the button and the same matrix firestore.rules enforces.
461
+ *
462
+ * It exists as its own class because "you may not" and "that is malformed" need different
463
+ * answers: a permission refusal is never fixed by editing the value. Carrying the action
464
+ * and the content type lets a caller say which one without parsing the message.
465
+ */
466
+ declare class PermissionError extends Error {
467
+ readonly name = "PermissionError";
468
+ readonly action: string;
469
+ /** Content type slug. Absent for an action that is not scoped to one. */
470
+ readonly contentType: string | undefined;
471
+ constructor(action: string, contentType?: string, message?: string);
472
+ }
473
+ /**
474
+ * A write was refused for its size, its shape, or its timing rather than its content.
475
+ * See core/write-limits.ts for which of these the security rules enforce as well and
476
+ * which are the adapter's alone.
477
+ */
478
+ declare class WriteLimitError extends Error {
479
+ readonly name = "WriteLimitError";
480
+ readonly code: string;
481
+ readonly path: string;
482
+ constructor(rejection: {
483
+ code: string;
484
+ path: string;
485
+ message: string;
486
+ });
487
+ }
488
+
489
+ /**
490
+ * The composite indexes a collection list needs, derived from the content types.
491
+ *
492
+ * `.docs/rules/firestore-indexes.md` requires every query to ship with its index, and the
493
+ * list view's queries are generated, so the index entries have to be generated too. A
494
+ * filter on a field plus an ordering on another field is a composite index, and the field
495
+ * paths come from the customer's schema, which no hand-written file can know in advance.
496
+ *
497
+ * Entries of every content type live in a subcollection called `entries`, so one
498
+ * `COLLECTION`-scoped index serves every type that uses the same field path. That is why
499
+ * the result is deduplicated across types rather than emitted per type.
500
+ *
501
+ * What is deliberately not emitted: a query with no filter at all. Ordering by one field
502
+ * with `__name__` as the tiebreaker in the same direction is served by Firestore's
503
+ * automatic single-field indexes, and emitting it would waste an index slot.
504
+ */
505
+ type IndexOrder = "ASCENDING" | "DESCENDING";
506
+ interface FirestoreIndexField {
507
+ fieldPath: string;
508
+ order: IndexOrder;
509
+ }
510
+ interface FirestoreIndex {
511
+ collectionGroup: string;
512
+ queryScope: "COLLECTION";
513
+ fields: readonly FirestoreIndexField[];
514
+ }
515
+ declare const ENTRY_COLLECTION_GROUP = "entries";
516
+ /**
517
+ * Firestore allows 200 composite indexes per database. Passing it is not a warning to be
518
+ * ignored later: the deploy fails. Better to fail here, where the schema that caused it is
519
+ * in hand, than in a customer's deploy log.
520
+ */
521
+ declare const MAX_ENTRY_INDEXES = 200;
522
+ /**
523
+ * Every index the generated list view can ask for, across the given content types.
524
+ *
525
+ * The shape of each query it covers is: an optional `status` equality, zero or more field
526
+ * equalities, then the ordering field and `__name__` in the same direction. A prefix
527
+ * search needs no extra entry: it ranges over the title field and orders by it, which is
528
+ * the ordering already emitted for the title column.
529
+ */
530
+ declare function requiredEntryIndexes(types: readonly ContentTypeLike[]): readonly FirestoreIndex[];
531
+ /** Whether a checked-in index file already covers one required index. */
532
+ declare function indexCovered(file: {
533
+ indexes?: readonly FirestoreIndex[];
534
+ }, required: FirestoreIndex): boolean;
535
+ /** The required indexes missing from a checked-in index file, for a drift test. */
536
+ declare function missingEntryIndexes(file: {
537
+ indexes?: readonly FirestoreIndex[];
538
+ }, types: readonly ContentTypeLike[]): readonly FirestoreIndex[];
539
+
540
+ /**
541
+ * What a collection list is, derived from a content type.
542
+ *
543
+ * The screen renders this; it never decides it. Columns, the sortable set, the filter
544
+ * controls and the query that reads a page are all computed here from the schema, which is
545
+ * what makes a new content type a data change and not a screen change.
546
+ *
547
+ * Nothing in this file imports React, Firebase or the field registry, so it is domain
548
+ * logic: it runs in the node test project, in the SDK, and in a server component.
549
+ */
550
+ /** How a column's cells are typeset. The cell itself never inspects the field type. */
551
+ type ColumnAlign = "text" | "numeric" | "date";
552
+ type ColumnKind = "title" | "field" | "status" | "updated";
553
+ interface ListColumn {
554
+ /** Field key, or `status` / `updatedAt` for the two metadata columns. */
555
+ key: string;
556
+ label: string;
557
+ kind: ColumnKind;
558
+ align: ColumnAlign;
559
+ /**
560
+ * Whether the header offers sorting. Bounded on purpose: every sortable column costs a
561
+ * pair of composite indexes per filter combination, so the list sorts by title, by the
562
+ * type's declared default sort field, and by last update, and not by every column it
563
+ * shows. See `sortableFieldKeys` and core/indexes.ts.
564
+ */
565
+ sortable: boolean;
566
+ }
567
+ interface ListFilterOption {
568
+ value: string;
569
+ label: string;
570
+ }
571
+ interface ListFilterDescriptor {
572
+ /** Field key the filter applies to. */
573
+ key: string;
574
+ label: string;
575
+ options: readonly ListFilterOption[];
576
+ /**
577
+ * `array-contains` for a multi-select, whose stored value is an array; `==` otherwise.
578
+ * The screen passes this straight through, so it never decides an operator.
579
+ */
580
+ op: EntryFilter["op"];
581
+ /** Filter values are strings in the UI; a boolean field converts back on the way out. */
582
+ valueType: "string" | "boolean";
583
+ }
584
+ /** Everything the reader has narrowed the list to, as one serializable value. */
585
+ interface ListViewState {
586
+ /** Prefix term for the title field. Empty means no search. */
587
+ search: string;
588
+ status: EntryStatus | "any";
589
+ /** Field key to selected option value. A key absent means that filter is off. */
590
+ filters: Readonly<Record<string, string>>;
591
+ sort: SortSpec;
592
+ /** Opaque, from a previous page. The UI stores it and never builds one. */
593
+ cursor: string | null;
594
+ }
595
+ /** Last touched first: what an editor who just saved something expects to see at the top. */
596
+ declare const FALLBACK_SORT: SortSpec;
597
+ declare function defaultListSort(type: ContentTypeLike): SortSpec;
598
+ declare function initialListState(type: ContentTypeLike): ListViewState;
599
+ /** A content type can only be searched when it names the field to match against. */
600
+ declare function canSearch(type: ContentTypeLike): boolean;
601
+ /** True while a term is narrowing the list, which is also when sorting is not on offer. */
602
+ declare function isSearching(state: ListViewState): boolean;
603
+ /**
604
+ * The keys a list may sort by: the title, the type's own default sort field, and the last
605
+ * update. Kept small deliberately; see `ListColumn.sortable`.
606
+ */
607
+ declare function sortableFieldKeys(type: ContentTypeLike): readonly string[];
608
+ /** How many schema fields get their own column before the row would stop being readable. */
609
+ declare const MAX_FIELD_COLUMNS = 2;
610
+ interface ListColumnOptions {
611
+ maxFieldColumns?: number;
612
+ }
613
+ /**
614
+ * The columns, in reading order: title, up to two field columns, status, updated.
615
+ *
616
+ * Hidden fields are skipped because the form does not show them either, and the title
617
+ * field is not repeated as a field column. A type with no `titleField` still gets a title
618
+ * column: `entryTitle` names the entry "Untitled" and the row remains clickable, which is
619
+ * better than a list whose rows have no handle at all.
620
+ */
621
+ declare function listColumns(type: ContentTypeLike, { maxFieldColumns }?: ListColumnOptions): readonly ListColumn[];
622
+ /** Whether a field definition can back a filter control. */
623
+ declare function isFilterableField(field: FieldDefinition): boolean;
624
+ /**
625
+ * The field filters a content type offers, in schema order.
626
+ *
627
+ * Status is not among them: it is entry metadata rather than a field, every content type
628
+ * has it, and the adapter takes it as its own argument.
629
+ */
630
+ declare function listFilters(type: ContentTypeLike): readonly ListFilterDescriptor[];
631
+ /**
632
+ * The active filters as adapter filters.
633
+ *
634
+ * A stored value the schema no longer offers is dropped rather than sent: a filter for a
635
+ * select option that was removed from the content type would return nothing and read as
636
+ * "there are no entries".
637
+ */
638
+ declare function listFilterQuery(type: ContentTypeLike, filters: Readonly<Record<string, string>>): readonly EntryFilter[];
639
+ interface BuildListQueryOptions {
640
+ pageSize?: number;
641
+ }
642
+ /**
643
+ * The read for one page.
644
+ *
645
+ * The sort is omitted while a search is active rather than sent and ignored: the adapter
646
+ * forces title order for a prefix range, and a query object that still claimed another
647
+ * order would be a lie the caller could act on.
648
+ */
649
+ declare function buildListQuery(type: ContentTypeLike, state: ListViewState, { pageSize }?: BuildListQueryOptions): ListQuery;
650
+ /** The same narrowing as a count: no order, no cursor, no page size. */
651
+ declare function buildCountQuery(type: ContentTypeLike, state: ListViewState): Pick<ListQuery, "filters" | "status" | "search">;
652
+ /** Whether anything is narrowing the list, which is what tells an empty result apart. */
653
+ declare function isNarrowed(state: ListViewState): boolean;
654
+ /**
655
+ * The next sort after a header is clicked.
656
+ *
657
+ * Clicking the sorted column flips it. Clicking another column starts in the direction
658
+ * that column is usually read in: newest first for a date, A to Z for everything else.
659
+ */
660
+ declare function nextSort(current: SortSpec, column: ListColumn, key?: string): SortSpec;
661
+
662
+ /**
663
+ * A registry that can change while the admin is running.
664
+ *
665
+ * Creating a content type in the Schema Builder has to put it in the sidebar, give it
666
+ * routes and give it working CRUD without a rebuild and without a reload. The code half of
667
+ * the registry is fixed for the lifetime of the process; only the database half moves, so
668
+ * this holds the code half and swaps the database half.
669
+ *
670
+ * **How the change arrives, and what it costs.** `SchemaAdapter.watch` opens one Firestore
671
+ * listener on the whole `schemas` collection and hands every snapshot to `setDatabaseTypes`.
672
+ * One listener per open admin tab. It bills the documents of the initial snapshot once, then
673
+ * one document read per changed schema document, and nothing at all while nobody is editing
674
+ * structure. `schemas` holds one small document per database-defined content type, so a
675
+ * customer with twenty of them pays twenty reads when an admin tab opens.
676
+ *
677
+ * The alternative considered was refetching the collection on every navigation inside the
678
+ * admin. It needs no listener, but it bills the whole collection again on every screen
679
+ * change, which for one editor clicking through twenty screens in an hour is already an
680
+ * order of magnitude more reads than the listener, and it is still stale in the tab where
681
+ * nobody navigated. The listener is both cheaper in the steady state and the only one of
682
+ * the two that actually refreshes without an interaction. See
683
+ * .docs/decisions/2026-09-18-schema-field-removal.md.
684
+ *
685
+ * A snapshot that produces an identical registry is dropped rather than published, because
686
+ * Firestore replays a local write as a second snapshot once the server acknowledges it and
687
+ * a re-render of every admin screen for a change nobody made is a visible cost.
688
+ */
689
+ interface RegistryStore {
690
+ /** The current registry. Replaced, never mutated, so a React snapshot stays stable. */
691
+ readonly registry: Registry;
692
+ /**
693
+ * Swaps the database half. Returns true when the registry actually changed, which is what
694
+ * the caller subscribes for.
695
+ */
696
+ setDatabaseTypes(types: readonly ContentTypeLike[]): boolean;
697
+ /** Notified after every real change. Returns the unsubscribe. */
698
+ subscribe(listener: (registry: Registry) => void): () => void;
699
+ /**
700
+ * Changes counter, monotonic. `useSyncExternalStore` compares it instead of deep
701
+ * comparing the registry, and a server render can send it to the client to detect that
702
+ * the two disagree.
703
+ */
704
+ readonly revision: number;
705
+ }
706
+ declare function createRegistryStore(code: readonly ContentTypeLike[], initialDatabase?: readonly ContentTypeLike[]): RegistryStore;
707
+
708
+ /**
709
+ * The migration runner: enough to move stored entries from one schema version to the next,
710
+ * and deliberately not a migration framework.
711
+ *
712
+ * `content/{slug}.schemaVersion` says what shape the type declares today and every entry
713
+ * carries the version it was last written at (see core/schema-version.ts). A migration is
714
+ * the transform between two consecutive versions of one content type. This module owns the
715
+ * registry of those transforms, the plan that chains them, and the loop that applies them.
716
+ *
717
+ * Four properties, because a migration that has any of them wrong is worse than none:
718
+ *
719
+ * 1. **Idempotent.** An entry is selected only while its stored version is behind, and the
720
+ * write is conditional on that version still being what was read. Running twice migrates
721
+ * nothing the first run finished, and two runners racing cannot both apply a step.
722
+ * 2. **Resumable.** There is no run state to keep. A run that is interrupted leaves every
723
+ * entry either fully at the old version or fully at the new one, so the next run is
724
+ * simply the same run again over what is left.
725
+ * 3. **Bounded.** It works in batches and returns what remains rather than walking a
726
+ * collection of unknown size inside one call.
727
+ * 4. **Honest when it fails.** A failing entry is named and the run stops rather than
728
+ * grinding through thousands of entries repeating the same error. Nothing is rolled
729
+ * back, because rolling back a partially migrated collection would need a second
730
+ * migration to be correct; the entries that succeeded are at the new version and the
731
+ * rest are untouched, which is a state the next run understands.
732
+ *
733
+ * What it is not: it does not create a migration for you, does not infer one from a schema
734
+ * diff, and does not run automatically. A schema change that needs one says so (the Schema
735
+ * Builder warns on a storage shape change) and somebody writes the transform.
736
+ */
737
+ /** The context a transform gets besides the values. */
738
+ interface MigrationContext {
739
+ entryId: string;
740
+ slug: string;
741
+ /** The version the stored entry is at, which is this migration's `from`. */
742
+ fromVersion: number;
743
+ }
744
+ /**
745
+ * One step, from one version to the next.
746
+ *
747
+ * `from` and `to` are consecutive by construction: a transform that jumped two versions
748
+ * could not be composed with one that filled the gap, and the registry would have no way
749
+ * to migrate an entry that is stuck in between.
750
+ */
751
+ interface EntryMigration {
752
+ slug: string;
753
+ from: number;
754
+ to: number;
755
+ /** One line, shown before a run so somebody can decide whether to start it. */
756
+ describe: string;
757
+ /**
758
+ * Returns the new values. Pure: no IO, no clock, no randomness, because it may be
759
+ * replayed on a later run over a different slice of the same collection and has to give
760
+ * the same answer.
761
+ */
762
+ migrate(values: Record<string, unknown>, context: MigrationContext): Record<string, unknown>;
763
+ }
764
+ interface MigrationRegistry {
765
+ /** Every registered migration for one content type, in version order. */
766
+ forType(slug: string): readonly EntryMigration[];
767
+ /**
768
+ * The steps that take an entry from `fromVersion` to the type's current version.
769
+ *
770
+ * A version with no registered step is an identity step, not an error. The stamp moves
771
+ * on any change to the stored shape, and most of those changes (adding a field, removing
772
+ * one) need no transform at all, so requiring a migration per version would mean writing
773
+ * an empty one after every schema edit. What a gap costs is stated rather than hidden: a
774
+ * transform that was needed and never written is not detected here, and the place that
775
+ * flags one is the Schema Builder's breaking-change warning at the moment of the edit.
776
+ */
777
+ plan(type: ContentTypeLike, fromVersion: number): readonly EntryMigration[];
778
+ }
779
+ declare function createMigrationRegistry(migrations: readonly EntryMigration[]): MigrationRegistry;
780
+ /** Applies a plan to one entry's values. Exported so a dry run can show the result. */
781
+ declare function applyPlan(plan: readonly EntryMigration[], values: Record<string, unknown>, context: Omit<MigrationContext, "fromVersion">): Record<string, unknown>;
782
+ interface MigrationFailure {
783
+ entryId: string;
784
+ /** The version the entry was at when the attempt was made. */
785
+ fromVersion: number;
786
+ reason: string;
787
+ }
788
+ interface MigrationRunResult {
789
+ slug: string;
790
+ /** The version every entry is being moved to: the type's current schema version. */
791
+ targetVersion: number;
792
+ /** Entries looked at. */
793
+ scanned: number;
794
+ /** Entries whose values were rewritten and whose version now equals the target. */
795
+ migrated: number;
796
+ /**
797
+ * Entries another runner had already moved between the read and the write. Not an error:
798
+ * it is the conditional write doing its job.
799
+ */
800
+ skipped: number;
801
+ failed: readonly MigrationFailure[];
802
+ /** True when entries are still behind, either because the cap was hit or a step failed. */
803
+ remaining: boolean;
804
+ }
805
+ interface MigrationRunOptions {
806
+ /** Entries per batch. Bounded so one call cannot become an unbounded read. */
807
+ batchSize?: number;
808
+ /** Batches per call. The run returns `remaining: true` when this is reached. */
809
+ maxBatches?: number;
810
+ /** Called after each batch, so a long run can show progress. */
811
+ onProgress?: (progress: {
812
+ scanned: number;
813
+ migrated: number;
814
+ }) => void;
815
+ }
816
+ declare const MIGRATION_BATCH_SIZE = 50;
817
+ declare const MIGRATION_MAX_BATCHES = 20;
818
+ interface MigrationRunDeps {
819
+ migrations: MigrationAdapter;
820
+ registry: MigrationRegistry;
821
+ type: ContentTypeLike;
822
+ }
823
+ /**
824
+ * Moves every entry of one content type up to its current schema version.
825
+ *
826
+ * The plan is computed per entry, from the version that entry is actually at, because a
827
+ * collection can hold entries at several versions at once: one written before a change,
828
+ * one written after, one left behind by an interrupted run.
829
+ *
830
+ * Failure behaviour, stated exactly. A transform that throws and a write that is refused
831
+ * are the same thing here: the entry is named in `failed`, its stored document is
832
+ * untouched, and the batch finishes so one bad entry does not hide the rest. The run then
833
+ * stops, because the selection is "everything still behind" and a failing entry would be
834
+ * picked up again on the next batch forever. `remaining` is true, the caller fixes the
835
+ * transform or the data, and runs again; everything that already moved stays moved.
836
+ */
837
+ declare function runMigration(deps: MigrationRunDeps, options?: MigrationRunOptions): Promise<MigrationRunResult>;
838
+
839
+ /**
840
+ * Moving one item of a list, as a pure function.
841
+ *
842
+ * It lives in core rather than beside either of its callers because both of them reorder
843
+ * for the same reason and must behave identically: the repeater field moves a row, and the
844
+ * Schema Builder moves a field. A drag and an arrow key are two ways to call this, never
845
+ * two implementations of it.
846
+ */
847
+ /**
848
+ * Returns a new list with the item at `from` placed at `to`.
849
+ *
850
+ * An out-of-range index returns the list unchanged rather than throwing. Every caller is an
851
+ * event handler (the last row pressing ArrowDown, a drop outside the list), and none of
852
+ * them has anything useful to do with an exception.
853
+ */
854
+ declare function moveItem<TItem>(items: readonly TItem[], from: number, to: number): TItem[];
855
+
856
+ /**
857
+ * The field editor as data: the order fields are shown in, what a new field starts as,
858
+ * whether a key may be used, and which options are still unanswered.
859
+ *
860
+ * Nothing here renders and nothing here writes. The Schema Builder screen calls it to
861
+ * decide what to show and what to enable, and the same functions are callable from a test
862
+ * or a script without a DOM.
863
+ *
864
+ * None of it switches on a field type. The per-type answers come from each field type's own
865
+ * `editor` spec through the registry, which is why adding a thirteenth field type adds it
866
+ * to the Schema Builder with no edit in this file and none in any screen.
867
+ */
868
+ interface FieldRow {
869
+ key: string;
870
+ field: FieldDefinition;
871
+ }
872
+ /**
873
+ * The field keys of a content type, in display order.
874
+ *
875
+ * `fieldOrder` leads, then any field it does not mention, in map order. Both halves are
876
+ * needed: a stored type's map order is whatever came back from storage, which is not a
877
+ * guarantee to build on, and a `fieldOrder` written before a field was added does not
878
+ * mention that field. An unknown key in `fieldOrder` is skipped rather than trusted, so a
879
+ * stale entry cannot conjure a field.
880
+ */
881
+ declare function orderedFieldKeys(type: Pick<ContentTypeLike, "fields" | "fieldOrder">): readonly string[];
882
+ /** The fields of a content type as an ordered list, which is what a form renders. */
883
+ declare function orderedFields(type: Pick<ContentTypeLike, "fields" | "fieldOrder">): readonly FieldRow[];
884
+ /** Moves one field. An out-of-range index is a no-op; see `moveItem`. */
885
+ declare function moveFieldRow(rows: readonly FieldRow[], from: number, to: number): FieldRow[];
886
+ /**
887
+ * Rebuilds a content type from an ordered list of fields.
888
+ *
889
+ * Writes both halves: the map, because that is what every consumer reads, and `fieldOrder`,
890
+ * because a map's key order carries no promise across a round trip. Validated here, so the
891
+ * editor fails on the form rather than on the round trip.
892
+ */
893
+ declare function withFieldRows(type: ContentTypeLike, rows: readonly FieldRow[]): ContentTypeLike;
894
+ /**
895
+ * Deep equality for two stored definitions, independent of key order.
896
+ *
897
+ * `JSON.stringify` is not enough on its own, and the reason is the same one `fieldOrder`
898
+ * exists for: a field map is an object, the editor rebuilds it in display order, and the
899
+ * stored copy is in whatever order it came back in. Comparing the two literally would
900
+ * report a difference after a load that reordered nothing, and leave the Save button
901
+ * enabled on a draft nobody has touched.
902
+ *
903
+ * Arrays keep their order, which is what makes a reorder, an option list and a
904
+ * `fieldOrder` change all register as real differences.
905
+ */
906
+ declare function sameDefinition(left: ContentTypeLike, right: ContentTypeLike): boolean;
907
+ type FieldKeyRejection = "invalid" | "reserved" | "live" | "removed";
908
+ interface FieldKeyAvailability {
909
+ available: boolean;
910
+ reason?: FieldKeyRejection;
911
+ /** Ready to print, and it names what is in the way. */
912
+ message?: string;
913
+ }
914
+ /**
915
+ * May this content type take this field key?
916
+ *
917
+ * The removed case is the one that matters. A key whose values are still stored is not
918
+ * free, and saying only "already used" would send an editor looking for a field that is not
919
+ * on the screen; the message points at the restore instead.
920
+ */
921
+ declare function checkFieldKey(type: ContentTypeLike, key: string): FieldKeyAvailability;
922
+ /**
923
+ * The same question one level down, inside a repeater's nested map.
924
+ *
925
+ * Reserved keys are not checked here, and deliberately so: `status` is entry metadata at
926
+ * the top level and an ordinary key inside a row, and the validator agrees. Nothing is
927
+ * parked at this level either, because `removedFields` describes top-level keys only.
928
+ */
929
+ declare function checkNestedFieldKey(fields: FieldMap, key: string): FieldKeyAvailability;
930
+ /**
931
+ * A field key suggested from a label, so an editor types "Job title" and gets "jobTitle".
932
+ *
933
+ * A suggestion only: the editor can overwrite it, and `checkFieldKey` answers whether what
934
+ * ends up in the box is usable. A label with nothing usable in it yields an empty string
935
+ * rather than a made-up key, and the form then asks for one.
936
+ */
937
+ declare function suggestFieldKey(label: string): string;
938
+ /**
939
+ * What a newly added field of this type starts as.
940
+ *
941
+ * The per-type part comes from the field type's own `editor.defaults`, so a select starts
942
+ * with one option and a repeater with one nested field without this function knowing that
943
+ * either of them exists. An unregistered type is refused rather than guessed at.
944
+ */
945
+ declare function newFieldDefinition(registry: FieldTypeRegistry, type: FieldType, label: string): FieldDefinition;
946
+ interface FieldOptionIssue {
947
+ /** The option key the issue belongs to, so the control can show it. */
948
+ option: string;
949
+ message: string;
950
+ }
951
+ /**
952
+ * The options of one field that are still unanswered, named by option.
953
+ *
954
+ * Generic: a descriptor marked `required` whose value is empty is an issue, and that is the
955
+ * whole rule. A relation with no target and a select with no options are both reported by
956
+ * it, and a field type added later gets the same treatment from its own descriptors.
957
+ */
958
+ declare function fieldOptionIssues(registry: FieldTypeRegistry, field: FieldDefinition): readonly FieldOptionIssue[];
959
+ /** Every unanswered option across a whole draft, each carrying the field it belongs to. */
960
+ declare function draftIssues(registry: FieldTypeRegistry, rows: readonly FieldRow[]): readonly (FieldOptionIssue & {
961
+ key: string;
962
+ })[];
963
+ type ValidationRuleName = keyof ValidationRules;
964
+ interface ValidationRuleControl {
965
+ label: string;
966
+ control: "boolean" | "number" | "text";
967
+ help: string;
968
+ }
969
+ /**
970
+ * How each shared validation rule is edited, and what it means.
971
+ *
972
+ * Keyed by the rule rather than by the field type: `maxLength` is the same rule and the
973
+ * same control wherever it is offered. Which of them a given field type offers is that
974
+ * field type's own answer, on `editor.rules`.
975
+ */
976
+ declare const VALIDATION_RULE_CONTROLS: {
977
+ readonly [TRule in ValidationRuleName]: ValidationRuleControl;
978
+ };
979
+ /** Sets or clears one rule, dropping `validation` entirely once nothing is left in it. */
980
+ declare function withValidationRule(field: FieldDefinition, rule: ValidationRuleName, value: string | number | boolean | undefined): FieldDefinition;
981
+ /** Sets or clears one per-type option, dropping the key when the value is empty. */
982
+ declare function withFieldOption(field: FieldDefinition, option: string, value: unknown): FieldDefinition;
983
+
984
+ /**
985
+ * Creating a database-defined content type: the domain half.
986
+ *
987
+ * Everything here is pure. The Schema Builder screen calls it to decide what to enable and
988
+ * what to say; the adapter calls the same functions again before it writes, so a request
989
+ * that skipped the screen is answered identically.
990
+ */
991
+ /**
992
+ * Slugs a content type may not take.
993
+ *
994
+ * Under the layout in .docs/decisions/2026-09-18-firestore-layout.md a content type slug
995
+ * never becomes a top-level collection, so none of these can actually collide today. They
996
+ * are reserved anyway: the names are the CMS's own vocabulary, a type called `users` or
997
+ * `settings` would read as the admin's own section in every sidebar and URL, and a future
998
+ * layout change must not be blocked by a customer having taken the name.
999
+ *
1000
+ * `src/firebase/paths.ts` re-exports this as `RESERVED_COLLECTIONS`, so the list is written
1001
+ * once and the storage layer and the Schema Builder cannot drift apart.
1002
+ */
1003
+ declare const RESERVED_TYPE_SLUGS: readonly string[];
1004
+ type SlugRejection = "invalid" | "reserved" | "taken-by-code" | "taken-by-database";
1005
+ interface SlugAvailability {
1006
+ available: boolean;
1007
+ reason?: SlugRejection;
1008
+ /** Ready to print. Names the source, because "that slug is taken" is a dead end. */
1009
+ message?: string;
1010
+ /** Which source holds the slug already. Absent for `invalid` and `reserved`. */
1011
+ source?: ContentTypeSource;
1012
+ }
1013
+ /**
1014
+ * Is this slug free for a new database-defined content type?
1015
+ *
1016
+ * Checked against **both** sources. A slug defined in code is not available even though
1017
+ * `schemas/` is empty and the Schema Builder's own list shows nothing, which is precisely
1018
+ * why the message has to say where the collision is: an editor looking at an empty list and
1019
+ * being told the name is taken has nowhere to go.
1020
+ */
1021
+ declare function checkTypeSlug(registry: Registry, slug: string): SlugAvailability;
1022
+ /** Throws the message `checkTypeSlug` would have printed. The adapter's create guard. */
1023
+ declare function assertTypeSlugAvailable(registry: Registry, slug: string): void;
1024
+ interface NewContentTypeInput {
1025
+ name: string;
1026
+ slug: string;
1027
+ kind: ContentTypeKind;
1028
+ /** Lucide icon name from the vocabulary in .docs/styleguide/05-icons.md. */
1029
+ icon?: string;
1030
+ namePlural?: string;
1031
+ description?: string;
1032
+ }
1033
+ /**
1034
+ * The single field every new content type starts with.
1035
+ *
1036
+ * A content type with no fields is invalid, so creation has to seed one. A required title
1037
+ * is the one field that is right for every content type: it is what the list view, the
1038
+ * relation picker and the entry header all print, and an editor who wanted something else
1039
+ * renames it rather than starting from an unusable empty type.
1040
+ */
1041
+ declare const INITIAL_FIELD_KEY = "title";
1042
+ /**
1043
+ * Builds the content type document a create writes, stamped at version 1.
1044
+ *
1045
+ * Validated here rather than at the write, so the Schema Builder fails on the form instead
1046
+ * of on the round trip.
1047
+ */
1048
+ declare function newContentType(input: NewContentTypeInput): ContentTypeLike;
1049
+ /**
1050
+ * The create path in one call: collision check against both sources, then the draft.
1051
+ *
1052
+ * The adapter runs this again before writing, which is what makes a bypassed UI change
1053
+ * nothing.
1054
+ */
1055
+ declare function createContentType(registry: Registry, input: NewContentTypeInput): ContentTypeLike;
1056
+
1057
+ /**
1058
+ * Adding, removing, re-adding and purging one field of a database-defined content type.
1059
+ *
1060
+ * The rule the whole file exists for: **removing a field never destroys its values.** The
1061
+ * definition moves from `fields` to `removedFields` and the stored values stay on every
1062
+ * entry until somebody explicitly purges them. Two properties elsewhere are what make that
1063
+ * work, and both are asserted by tests rather than assumed:
1064
+ *
1065
+ * - `schema/validate.ts` only validates keys the schema declares, so a value left behind by
1066
+ * a removed field cannot make an entry unsaveable.
1067
+ * - `firebase/converters.ts#valuesToStored` carries keys it does not recognise through
1068
+ * untouched, so saving an entry after a removal rewrites the declared fields and leaves
1069
+ * the parked one exactly where it was.
1070
+ *
1071
+ * Every function here is pure and returns a new content type. Nothing writes.
1072
+ */
1073
+ interface RemovalContext {
1074
+ /** uid recorded on the parked definition. */
1075
+ removedBy: string;
1076
+ /** ISO 8601. Injected so a test and a migration can both be deterministic. */
1077
+ removedAt?: string;
1078
+ }
1079
+ /** What a key currently is on a content type. Drives what the field editor may offer. */
1080
+ type FieldKeyState = "free" | "live" | "removed";
1081
+ declare function fieldKeyState(type: ContentTypeLike, key: string): FieldKeyState;
1082
+ declare function removedFieldEntries(type: ContentTypeLike): readonly (RemovedField & {
1083
+ key: string;
1084
+ })[];
1085
+ /**
1086
+ * Removes a field and parks its definition.
1087
+ *
1088
+ * Refuses to remove the last field, because a content type with no fields is invalid, and
1089
+ * refuses to remove the field the type names as its title or its default sort: a list view
1090
+ * that sorts by a field nobody can edit any more is a broken screen, and pointing the type
1091
+ * elsewhere silently would be a change the editor did not ask for. Repoint the type first.
1092
+ */
1093
+ declare function removeField(type: ContentTypeLike, key: string, context: RemovalContext): ContentTypeLike;
1094
+ /** Adds a field. Rejects a key that is live, and routes a removed key to `restoreField`. */
1095
+ declare function addField(type: ContentTypeLike, key: string, field: FieldDefinition): ContentTypeLike;
1096
+ interface RestoreOptions {
1097
+ /**
1098
+ * A definition to restore under, instead of the one that was parked.
1099
+ *
1100
+ * Only accepted when it stores values the same way, which in practice means the same
1101
+ * `type` and the same cardinality. Restoring a `text` key as a `number` would leave every
1102
+ * preserved value the wrong shape, and the entry would fail validation the next time
1103
+ * anyone opened it, on data they never touched. Purge first, then add it fresh.
1104
+ */
1105
+ field?: FieldDefinition;
1106
+ }
1107
+ /**
1108
+ * Re-adds a removed key, and with it the values that were preserved.
1109
+ *
1110
+ * This is the whole point of parking the definition rather than deleting it: the values
1111
+ * were never dropped, so declaring the key again makes them readable exactly as they were.
1112
+ * Nothing is written to any entry.
1113
+ */
1114
+ declare function restoreField(type: ContentTypeLike, key: string, options?: RestoreOptions): ContentTypeLike;
1115
+ /**
1116
+ * Drops the parked definition, after the values have been purged from the entries.
1117
+ *
1118
+ * The definition side of a purge only. The data side is `CmsAdapter.fieldData.purge`, and
1119
+ * the adapter runs both in that order: values first, then this, so an interrupted purge
1120
+ * leaves the field still listed as removed with some values gone, which is recoverable,
1121
+ * rather than unlisted with values nobody can see, which is not.
1122
+ */
1123
+ declare function forgetRemovedField(type: ContentTypeLike, key: string): ContentTypeLike;
1124
+ /**
1125
+ * Do two definitions put the same shape in Firestore?
1126
+ *
1127
+ * Deliberately about storage, not about presentation: a label, a description, a
1128
+ * placeholder or a validation rule may change freely across a restore, because none of them
1129
+ * changes what is already in the document.
1130
+ */
1131
+ declare function storesTheSameWay(left: FieldDefinition, right: FieldDefinition): boolean;
1132
+ /**
1133
+ * The storage-relevant part of one field definition, as a string.
1134
+ *
1135
+ * What is in it: the type, and every option that changes the stored value. `multiple` and
1136
+ * `many` turn a scalar into an array. `to` decides which content type a stored relation id
1137
+ * points at. A repeater's nested map is the shape of each stored row. A date's precision
1138
+ * decides between `YYYY-MM-DD` and a full instant.
1139
+ *
1140
+ * What is deliberately not in it: label, description, placeholder, hidden, readonly,
1141
+ * validation, select option labels, and the order of the keys. Changing any of those
1142
+ * changes the form, not the document, and a version stamp that moved for them would tell a
1143
+ * migration runner nothing.
1144
+ */
1145
+ declare function storageSignatureOf(field: FieldDefinition): string;
1146
+ declare function fieldMapSignature(fields: FieldMap): string;
1147
+
1148
+ /**
1149
+ * The schema version stamp, and the one question it answers.
1150
+ *
1151
+ * `content/{slug}.schemaVersion` is what the M10 migration runner reads, and every entry
1152
+ * carries the version it was last written at. The runner's only question is: *is this entry
1153
+ * stored in the shape the type declares today?* So the stamp has to move exactly when the
1154
+ * answer can change, and not otherwise. A stamp that moves on every save tells the runner
1155
+ * nothing, because every entry is then behind; a stamp that never moves tells it nothing
1156
+ * either, because no entry is ever behind.
1157
+ *
1158
+ * **Bumps:** adding a field, removing a field, changing a field's type, changing a select's
1159
+ * option values or its `multiple`, changing a relation's target or its `many`, changing a
1160
+ * date's precision, changing a repeater's nested field map.
1161
+ *
1162
+ * **Does not bump:** label, plural, description, icon, placeholder, hidden, readonly,
1163
+ * validation rules, select option labels, field order, title field, default sort,
1164
+ * permissions, and lock state. None of them changes a stored document, so an entry written
1165
+ * before the change is still stored exactly as the type describes.
1166
+ *
1167
+ * Purging a removed field's values does not bump either. The purge deletes data that the
1168
+ * type had already stopped declaring, so the declared shape is the same before and after.
1169
+ */
1170
+ /** Everything the stamp is computed from. Anything absent from here cannot move it. */
1171
+ declare function storageSignature(type: Pick<ContentTypeLike, "kind" | "fields">): string;
1172
+ declare const INITIAL_SCHEMA_VERSION = 1;
1173
+ declare function schemaVersionOf(type: Pick<ContentTypeLike, "schemaVersion">): number;
1174
+ /**
1175
+ * The version the next write should carry.
1176
+ *
1177
+ * Computed from the stored definition and the new one, never supplied by a caller, so a
1178
+ * client cannot stamp a version that does not describe what it is writing.
1179
+ */
1180
+ declare function nextSchemaVersion(previous: ContentTypeLike | null, next: ContentTypeLike): number;
1181
+ /** True when the two definitions would be stamped differently. Drives the "this is a breaking change" warning. */
1182
+ declare function storageShapeChanged(previous: ContentTypeLike, next: ContentTypeLike): boolean;
1183
+
1184
+ /**
1185
+ * The field a content type is addressed by in a URL, if it has one.
1186
+ *
1187
+ * It lives in core rather than in the adapter because two callers need it and only one of
1188
+ * them may import Firebase: the adapter looks an entry up by its slug value, and the entry
1189
+ * form asks whether a field is the one it can answer a uniqueness question about without a
1190
+ * composite index. A second copy of this rule would let the two disagree about which field
1191
+ * the slug is.
1192
+ */
1193
+ declare function slugFieldKey(type: ContentTypeLike): string | null;
1194
+
1195
+ /**
1196
+ * The reverse index that answers "which entries use this media item".
1197
+ *
1198
+ * Firestore has no reverse lookup and no join. A media reference lives inside an entry's
1199
+ * field values, possibly several levels down inside a repeater row, and nothing about the
1200
+ * media document names the entries that point at it. Two ways out of that were available:
1201
+ *
1202
+ * 1. **Scan.** On every usage question, read every entry of every content type and look
1203
+ * inside each one. Correct for all data, past and present, and priced at one document
1204
+ * read per entry per question. A customer with 20,000 entries pays 20,000 reads to
1205
+ * learn whether one photo may be deleted, and pays it again for the next photo. That is
1206
+ * not a cost a CMS may hide inside a delete button.
1207
+ * 2. **Index.** Every entry write also writes the flat list of media ids that entry
1208
+ * references, into a `mediaRefs` array on the entry document itself. The question then
1209
+ * becomes one indexed collection-group query whose cost is the number of answers, not
1210
+ * the size of the database.
1211
+ *
1212
+ * Husk takes the index. It is written in the same document write as the values it
1213
+ * summarizes, so it cannot drift from them: there is no second document, no transaction
1214
+ * spanning two collections, and no background job that can fall behind. Deleting an entry
1215
+ * deletes its index with it.
1216
+ *
1217
+ * Both functions below walk the stored data and recognize a reference by its shape: an
1218
+ * object carrying a string `mediaId`. That is deliberate, and it is the reason neither of
1219
+ * them takes a content type, a field map or a field type registry.
1220
+ *
1221
+ * - The shape is the contract. `types/media.ts` defines `MediaReference` as exactly this,
1222
+ * and nothing else in the product stores an object with a `mediaId` key.
1223
+ * - A walk over the data cannot be wrong about what the data contains. A schema walk would
1224
+ * have to know which field types can hold media, recurse through repeater definitions,
1225
+ * and it would still miss a reference stored under a field the schema no longer declares.
1226
+ * - It needs no registry, so the adapter indexes every entry it writes, including one
1227
+ * written by a script that never loads the admin's React field components.
1228
+ *
1229
+ * **Where the bound is, stated rather than implied.** The index exists only on entries
1230
+ * written by a version that writes it. An entry stored before this change, or written
1231
+ * straight into the Firestore console, carries no `mediaRefs` field and is therefore
1232
+ * invisible to every usage question until it is saved once. The consequence is named in
1233
+ * the UI rather than hidden: the delete warning says what the count is based on. See
1234
+ * .docs/decisions/2026-09-18-media-usage-index.md.
1235
+ */
1236
+ /** The entry-document field the reverse index is written to. */
1237
+ declare const MEDIA_REFS_KEY = "mediaRefs";
1238
+ /**
1239
+ * The ceiling on indexed ids per entry.
1240
+ *
1241
+ * An array field is part of the document and a document is capped at 1 MiB, but the real
1242
+ * reason for a limit is the index itself: Firestore writes one index entry per array
1243
+ * element, so an entry holding a thousand references would cost a thousand index writes on
1244
+ * every save. A hundred is far above any real page and low enough that the write stays
1245
+ * ordinary. Beyond it the list is truncated rather than the save refused, because a save
1246
+ * that fails over a bookkeeping field would be the worse outcome; the consequence is that
1247
+ * usage for the truncated ids is under-reported, which is why the number is generous.
1248
+ */
1249
+ declare const MAX_MEDIA_REFS = 100;
1250
+ /**
1251
+ * Every media id an entry's stored values reference, deduplicated and ordered.
1252
+ *
1253
+ * Ordered because two arrays holding the same ids in a different order are the same index,
1254
+ * and a stable order is what lets a caller compare one write against the last. Deduplicated
1255
+ * because the same photo used twice on one page is one reference.
1256
+ */
1257
+ declare function collectMediaIds(data: Record<string, unknown>): string[];
1258
+ /**
1259
+ * Rewrites the denormalized `url` of every reference to one media item, anywhere in a
1260
+ * stored entry document.
1261
+ *
1262
+ * Returns a new object when anything changed and `null` when nothing did, so a caller can
1263
+ * skip a write instead of touching an entry's `updatedAt` for no reason.
1264
+ */
1265
+ declare function retargetMediaUrls(data: Record<string, unknown>, mediaId: string, url: string): Record<string, unknown> | null;
1266
+
1267
+ export { type BuildListQueryOptions, type ClassifiedError, CmsAdapter, type ColumnAlign, type ColumnKind, ENTRY_COLLECTION_GROUP, ERROR_CATEGORIES, EntryFilter, type EntryMigration, type EntryUniqueOptions, type ErrorCategory, type ErrorContext, type ErrorOrigin, type ErrorTreatment, FALLBACK_SORT, type FieldKeyAvailability, type FieldKeyRejection, type FieldKeyState, type FieldOptionIssue, type FieldRow, type FirestoreIndex, type FirestoreIndexField, type FormField, type FormIssue, HUSK_TAG_PREFIX, INITIAL_FIELD_KEY, INITIAL_SCHEMA_VERSION, type IndexOrder, type ListColumn, type ListColumnOptions, type ListFilterDescriptor, type ListFilterOption, ListQuery, type ListViewState, LockedTypeError, MAX_ENTRY_INDEXES, MAX_FIELD_COLUMNS, MAX_MEDIA_REFS, MEDIA_REFS_KEY, MIGRATION_BATCH_SIZE, MIGRATION_MAX_BATCHES, MigrationAdapter, type MigrationContext, type MigrationFailure, type MigrationRegistry, type MigrationRunDeps, type MigrationRunOptions, type MigrationRunResult, type NewContentTypeInput, PermissionError, RESERVED_TYPE_SLUGS, Registry, type RegistryStore, type RemovalContext, type RestoreOptions, STATUS_EFFECT, SchemaError, SingletonError, type SlugAvailability, type SlugRejection, SortSpec, type StatusSubject, type StatusTransition, UNTITLED_ENTRY, VALIDATION_RULE_CONTROLS, ValidationError, type ValidationRuleControl, type ValidationRuleName, WriteLimitError, addField, applyPlan, assertTypeSlugAvailable, assertValidContentType, buildCountQuery, buildListQuery, canSaveEntry, canSearch, checkFieldKey, checkNestedFieldKey, checkTypeSlug, classifyError, collectMediaIds, confirmationMatches, contentTypeTag, createContentType, createMigrationRegistry, createRegistryStore, defaultListSort, defineContentType, deleteConfirmationName, deleteConfirmationPhrase, deniedError, draftIssues, entryChangedSince, entrySlugTag, entryTag, entryTags, entryTitle, entryUniqueCheck, errorsUnder, fieldKeyState, fieldMapSignature, fieldOptionIssues, forgetRemovedField, formFields, formValuesFromEntry, indexCovered, initialFormValues, initialListState, isFilterableField, isHuskTag, isNarrowed, isSearching, listColumns, listFilterQuery, listFilters, mediaTag, mergeIssues, missingEntryIndexes, missingError, moveFieldRow, moveItem, newContentType, newFieldDefinition, nextSchemaVersion, nextSort, normalizeConfirmation, orderedFieldKeys, orderedFields, removeField, removedFieldEntries, requiredEntryIndexes, restoreField, retargetMediaUrls, runMigration, sameDefinition, schemaVersionOf, settingsTag, slugFieldKey, sortableFieldKeys, staleWriteError, statusTransitions, storageShapeChanged, storageSignature, storageSignatureOf, storesTheSameWay, suggestFieldKey, treatmentFor, unroutableIssues, valuesChanged, withFieldOption, withFieldRows, withValidationRule };