@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.
- package/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- 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 };
|