rvlib-shipkit 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -0
  3. package/package.json +1 -1
  4. package/src/cli/cmd_Bump.ts +2 -0
  5. package/src/cli/cmd_Ci.ts +3 -1
  6. package/src/cli/cmd_Pb.ts +27 -0
  7. package/src/cli/cmd_Release.ts +1 -1
  8. package/src/cli/cmd_Wiki.ts +2 -2
  9. package/src/cli.ts +49 -39
  10. package/src/collections/providers.ts +7 -2
  11. package/src/contracts/contracts-once.ts +43 -0
  12. package/src/core/canonical-scripts.ts +33 -0
  13. package/src/core/check-ctx.ts +13 -0
  14. package/src/core/check-suite.ts +121 -53
  15. package/src/core/floor.ts +82 -0
  16. package/src/core/motherhome.ts +14 -1
  17. package/src/core/run-check.ts +34 -0
  18. package/src/core/run-ci.ts +63 -2
  19. package/src/core/run-fixers.ts +6 -1
  20. package/src/ledger/toolchain-identity.ts +134 -0
  21. package/src/pb/collection-scaffold.ts +299 -0
  22. package/src/pb/pb-types.ts +233 -0
  23. package/src/pb/run-pb.ts +157 -0
  24. package/src/release/git-facts.ts +3 -2
  25. package/src/release/release-scope.ts +52 -0
  26. package/src/release/releases-share.ts +85 -0
  27. package/src/release/run-bump.ts +31 -8
  28. package/src/release/run-release.ts +8 -1
  29. package/src/release/run-releases.ts +20 -6
  30. package/src/repo-config.ts +22 -65
  31. package/src/rules/SK001-package-json-shape.ts +65 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +2 -2
  33. package/src/rules/SK004-gitignore-policy.ts +27 -3
  34. package/src/rules/SK012-config-barrel-import.ts +60 -0
  35. package/src/rules/rule-registry.ts +2 -0
  36. package/src/rules/untrack-generated.ts +117 -0
  37. package/src/scaffold/project-scaffold.ts +1 -1
  38. package/src/serve/hub-port.ts +17 -0
  39. package/src/serve/run-serve.ts +2 -5
  40. package/src/wiki/run-wiki.ts +18 -8
@@ -0,0 +1,299 @@
1
+ // the file bodies `shipkit pb collection <name>` writes into an app's
2
+ // src/collections/<name>/.
3
+ //
4
+ // THE SHAPE IS NOT THIS FILE'S TO INVENT. It reproduces the house collection
5
+ // folder: `X.` Mantine globals rather than @mantine/core imports, `observer`
6
+ // around anything reading observables, a form that is
7
+ // `ComponentType<{ record?, onClose }>` writing through `db` fire and forget,
8
+ // and an index that CALLS defineCollectionUI as a statement (it returns void).
9
+ // A generator that emits a second dialect is worse than no generator: the code
10
+ // it writes is what the next developer copies.
11
+ //
12
+ // Pure: schema in, {rel, body} out, so the emitter is testable and the fs half
13
+ // stays in run-pb.ts. Anything the schema cannot decide (how a relation reads,
14
+ // what a file url is) is emitted COMMENTED with the reason, never guessed and
15
+ // never dropped: a missing column is invisible, a commented one takes ten
16
+ // seconds to accept or delete.
17
+ import type { PbCollection, PbField } from './pb-types.ts'
18
+
19
+ export type ScaffoldedFile = { rel: string; body: string }
20
+
21
+ /** the app's own modules, relative to src/collections/<name>/ */
22
+ const TYPES = '../../pb.types'
23
+ const PB = '../../pb'
24
+
25
+ /** `client_name` → `Client name`, `emailVisibility` → `Email visibility` */
26
+ export function humanLabel(name: string): string {
27
+ const words = name.replace(/_/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim().toLowerCase()
28
+ return words.charAt(0).toUpperCase() + words.slice(1)
29
+ }
30
+
31
+ const isIdent = (s: string): boolean => /^[A-Za-z_$][\w$]*$/.test(s)
32
+ const prop = (s: string): string => (isIdent(s) ? `.${s}` : `[${JSON.stringify(s)}]`)
33
+
34
+ /** the label a list row shows, by convention over the schema */
35
+ const LABEL_PREFERENCE = ['name', 'label', 'title', 'number', 'code', 'email']
36
+
37
+ export function labelField(col: PbCollection): PbField {
38
+ for (const want of LABEL_PREFERENCE) {
39
+ const hit = col.fields.find((f) => f.name === want && !f.secret)
40
+ if (hit != null) return hit
41
+ }
42
+ const id = col.fields.find((f) => f.name === 'id')
43
+ if (id == null) throw new Error(`collection '${col.name}' has no id field`)
44
+ return id
45
+ }
46
+
47
+ /** a value already a string needs no String() around it */
48
+ const readsAsString = (f: PbField): boolean =>
49
+ !f.array && (f.kind === 'string' || f.kind === 'select' || f.kind === 'date' || f.kind === 'autodate')
50
+
51
+ /** columns whose value the schema alone cannot turn into a cell */
52
+ const NOT_RENDERABLE: Partial<Record<PbField['kind'], string>> = {
53
+ relation: 'relation: <RefCell> it, or render the expanded record',
54
+ file: 'file: build a url with pb.files.getURL(r, …)',
55
+ json: 'json: pick the fields you want out of the blob',
56
+ html: 'html markup: render it deliberately, or strip the tags',
57
+ unknown: 'unrecognised generated type',
58
+ }
59
+
60
+ /** system plumbing that is never a column worth showing */
61
+ const NOISE = new Set(['collectionId', 'collectionName', 'expand'])
62
+
63
+ export type ColumnPlan = { field: PbField; render: string | null; sortable: boolean; why: string }
64
+
65
+ export function planColumns(col: PbCollection): ColumnPlan[] {
66
+ const plans: ColumnPlan[] = []
67
+ for (const f of col.fields) {
68
+ if (f.secret) continue
69
+ const acc = `r${prop(f.name)}`
70
+ const push = (render: string | null, why: string, sortable = false): void => void plans.push({ field: f, render, sortable, why })
71
+ if (NOISE.has(f.name)) { push(null, 'system field, rarely worth a column', true); continue }
72
+ const blocked = NOT_RENDERABLE[f.kind]
73
+ if (blocked != null) { push(null, blocked, !f.array); continue }
74
+ // `key` means the SERVER sorts this column, so a derived cell drops it:
75
+ // a multi-value field sorts lexically on its raw storage, which is not
76
+ // what the joined text on screen says
77
+ if (f.array) { push(`${acc}.join(", ")`, ''); continue }
78
+ // react renders a boolean as nothing at all, so it has to be spelled out
79
+ if (f.kind === 'boolean') { push(`String(${acc})`, '', true); continue }
80
+ push(acc, '', true)
81
+ }
82
+ return plans
83
+ }
84
+
85
+ /** the writable scalar fields a Mantine form can carry on its own */
86
+ export function planForm(col: PbCollection): PbField[] {
87
+ return col.fields.filter((f) => {
88
+ if (f.readOnly || f.secret) return false
89
+ // a multi-value field is editable when its values are free text or a
90
+ // named union; an array of relations or files still needs a picker
91
+ if (f.array) return f.kind === 'select' || f.kind === 'string'
92
+ return f.kind === 'string' || f.kind === 'number' || f.kind === 'boolean' || f.kind === 'date' || f.kind === 'select' || f.kind === 'html'
93
+ })
94
+ }
95
+
96
+ function columnsFile(col: PbCollection): string {
97
+ const lines: string[] = []
98
+ lines.push(`// what a row of \`${col.name}\` looks like in a grid, and in an export. ONE`)
99
+ lines.push('// definition, so the table, the card and the csv can never disagree.')
100
+ lines.push('// `key` is the SERVER sort field: present means the header sorts, absent')
101
+ lines.push('// means the cell is derived and the database cannot order by it.')
102
+ lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
103
+ lines.push('// app carries a dict.')
104
+ lines.push(`import type { Column } from "rvlib-pb-shell"`)
105
+ lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
106
+ lines.push('')
107
+ lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
108
+ for (const p of planColumns(col)) {
109
+ const key = p.sortable ? `key: ${JSON.stringify(p.field.name)}, ` : ''
110
+ const align = p.field.kind === 'number' && !p.field.array ? 'align: "right", ' : ''
111
+ const entry = `{ ${key}label: ${JSON.stringify(humanLabel(p.field.name))}, ${align}render: (r) => ${p.render ?? `r${prop(p.field.name)}`} },`
112
+ if (p.render == null) {
113
+ lines.push(` // ${p.why}`)
114
+ lines.push(` // ${entry}`)
115
+ } else {
116
+ lines.push(` ${entry}`)
117
+ }
118
+ }
119
+ lines.push(']')
120
+ return `${lines.join('\n')}\n`
121
+ }
122
+
123
+ function cardFile(col: PbCollection, cardFields: PbField[]): string {
124
+ const [first, second, third] = cardFields
125
+ const show = (f: PbField): string =>
126
+ f.array ? `{r${prop(f.name)}.join(", ")}` : readsAsString(f) ? `{r${prop(f.name)}}` : `{String(r${prop(f.name)})}`
127
+ const lines: string[] = []
128
+ lines.push('// the same record as a card, for a LiveCards view or a mobile layout')
129
+ lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
130
+ lines.push('')
131
+ lines.push(`export const ${col.prefix}Card = (r: ${col.prefix}Response) => (`)
132
+ lines.push(' <X.Paper withBorder p="md" radius="md">')
133
+ lines.push(' <X.Group justify="space-between" wrap="nowrap">')
134
+ if (first != null) lines.push(` <X.Text fw={600} truncate>${show(first)}</X.Text>`)
135
+ if (second != null) lines.push(` <X.Text size="sm" c="dimmed">${show(second)}</X.Text>`)
136
+ lines.push(' </X.Group>')
137
+ if (third != null) lines.push(` <X.Text size="sm">${show(third)}</X.Text>`)
138
+ lines.push(' </X.Paper>')
139
+ lines.push(')')
140
+ return `${lines.join('\n')}\n`
141
+ }
142
+
143
+ function gridFile(col: PbCollection): string {
144
+ return [
145
+ `// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
146
+ '// this; a page that wants extra columns passes its own to LiveGrid directly.',
147
+ `import type { LiveList } from "rvlib-pb-mobx"`,
148
+ `import { LiveGrid } from "rvlib-pb-shell"`,
149
+ `import type { ${col.prefix}Response } from "${TYPES}"`,
150
+ `import { columns } from "./columns"`,
151
+ '',
152
+ `export const ${col.prefix}Grid = (p: { list: LiveList<${col.prefix}Response>; selection?: boolean }) => (`,
153
+ ' <LiveGrid list={p.list} columns={columns} selection={p.selection} />',
154
+ ')',
155
+ '',
156
+ ].join('\n')
157
+ }
158
+
159
+ function inputFor(f: PbField): string {
160
+ const label = JSON.stringify(humanLabel(f.name))
161
+ const key = JSON.stringify(f.name)
162
+ // a select whose options union typegen did not name has no data source, so
163
+ // it falls through to a free text input rather than to a broken reference
164
+ if (f.kind === 'select' && f.optionsType != null) {
165
+ const control = f.array ? 'MultiSelect' : 'Select'
166
+ return `<X.${control} label=${label} data={Object.values(${f.optionsType})} {...form.getInputProps(${key})} />`
167
+ }
168
+ if (f.array && f.kind === 'string') return `<X.TagsInput label=${label} {...form.getInputProps(${key})} />`
169
+ if (f.kind === 'boolean') return `<X.Checkbox label=${label} {...form.getInputProps(${key}, { type: "checkbox" })} />`
170
+ if (f.kind === 'number') return `<X.NumberInput label=${label} {...form.getInputProps(${key})} />`
171
+ if (f.kind === 'html') return `<X.Textarea label=${label} autosize minRows={3} {...form.getInputProps(${key})} />`
172
+ if (f.kind === 'date') return `<X.TextInput label=${label} placeholder="YYYY-MM-DD" {...form.getInputProps(${key})} />`
173
+ return `<X.TextInput label=${label} {...form.getInputProps(${key})} />`
174
+ }
175
+
176
+ /** what an unset field is, in the app's own terms */
177
+ function emptyValue(f: PbField): string {
178
+ if (f.array) return '[]'
179
+ if (f.kind === 'boolean') return 'false'
180
+ if (f.kind === 'number') return '0'
181
+ // an optional select starts EMPTY, which is pocketbase's own default, so a
182
+ // new record never gets a value nobody picked. The cast is the price of the
183
+ // generated union not admitting the unset state it actually stores
184
+ if (f.kind === 'select' && f.optionsType != null) {
185
+ return f.optional ? `("" as ${f.optionsType})` : `Object.values(${f.optionsType})[0]!`
186
+ }
187
+ return '""'
188
+ }
189
+
190
+ function formFile(col: PbCollection, fields: PbField[], left: PbField[]): string {
191
+ const options = [...new Set(fields.filter((f) => f.kind === 'select').map((f) => f.optionsType).filter((o): o is string => o != null))].sort()
192
+ const lines: string[] = []
193
+ lines.push('// create and edit, as a modal. Validators mirror the server rules verbatim:')
194
+ lines.push("// the UI is a courtesy, the collection's API rules are the enforcement.")
195
+ lines.push(`import { useForm } from "@mantine/form"`)
196
+ lines.push(`import { observer } from "rvlib-mobx/src/obs.ts"`)
197
+ lines.push(`import { db } from "${PB}"`)
198
+ if (options.length > 0) lines.push(`import { ${options.join(', ')} } from "${TYPES}"`)
199
+ lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
200
+ lines.push('')
201
+ if (left.length > 0) {
202
+ lines.push(`// no input for ${left.map((f) => `${f.name} (${f.kind})`).join(', ')}:`)
203
+ lines.push('// those need a picker or an uploader of their own.')
204
+ }
205
+ lines.push(`type Draft = Pick<`)
206
+ lines.push(` ${col.prefix}Response,`)
207
+ lines.push(fields.map((f) => ` | ${JSON.stringify(f.name)}`).join('\n'))
208
+ lines.push('>')
209
+ lines.push('')
210
+ lines.push(`export const ${col.prefix}Form = observer(function ${col.prefix}Form_(p: { record?: ${col.prefix}Response; onClose: () => void }) {`)
211
+ lines.push(' const form = useForm<Draft>({')
212
+ lines.push(' initialValues: {')
213
+ for (const f of fields) {
214
+ const key = isIdent(f.name) ? f.name : JSON.stringify(f.name)
215
+ lines.push(` ${key}: p.record?${prop(f.name)} ?? ${emptyValue(f)},`)
216
+ }
217
+ lines.push(' },')
218
+ lines.push(' })')
219
+ lines.push('')
220
+ lines.push(' // fire and forget: the data layer notifies and rolls back on rejection')
221
+ lines.push(' const submit = form.onSubmit((values) => {')
222
+ lines.push(` if (p.record) db${prop(col.name)}.update(p.record.id, values)`)
223
+ lines.push(` else db${prop(col.name)}.create(values)`)
224
+ lines.push(' p.onClose()')
225
+ lines.push(' })')
226
+ lines.push('')
227
+ lines.push(' return (')
228
+ lines.push(` <X.Modal opened onClose={p.onClose} title=${JSON.stringify(humanLabel(col.name))}>`)
229
+ lines.push(' <form onSubmit={submit}>')
230
+ lines.push(' <X.Stack>')
231
+ for (const f of fields) lines.push(` ${inputFor(f)}`)
232
+ lines.push(' <X.Group justify="flex-end">')
233
+ lines.push(' <X.Button variant="default" onClick={p.onClose}>Cancel</X.Button>')
234
+ lines.push(' <X.Button type="submit">Save</X.Button>')
235
+ lines.push(' </X.Group>')
236
+ lines.push(' </X.Stack>')
237
+ lines.push(' </form>')
238
+ lines.push(' </X.Modal>')
239
+ lines.push(' )')
240
+ lines.push('})')
241
+ return `${lines.join('\n')}\n`
242
+ }
243
+
244
+ function indexFile(col: PbCollection, hasForm: boolean): string {
245
+ const label = labelField(col)
246
+ const lines: string[] = []
247
+ lines.push('// registration: after this import, a LiveGrid or LiveCards over')
248
+ lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
249
+ lines.push('// relation cell pointing AT one knows how to name it.')
250
+ lines.push(`import { defineCollectionUI } from "rvlib-pb-shell"`)
251
+ lines.push(`import { db } from "${PB}"`)
252
+ lines.push(`import { ${col.prefix}Card } from "./card"`)
253
+ lines.push(`import { columns } from "./columns"`)
254
+ if (hasForm) lines.push(`import { ${col.prefix}Form } from "./form"`)
255
+ lines.push('')
256
+ lines.push(`defineCollectionUI(db${prop(col.name)}, {`)
257
+ lines.push(' columns,')
258
+ lines.push(` card: ${col.prefix}Card,`)
259
+ lines.push(` label: (r) => ${readsAsString(label) ? `r${prop(label.name)}` : `String(r${prop(label.name)})`},`)
260
+ if (hasForm) lines.push(` form: ${col.prefix}Form,`)
261
+ lines.push('})')
262
+ lines.push('')
263
+ lines.push(`export { columns, ${col.prefix}Card${hasForm ? `, ${col.prefix}Form` : ''} }`)
264
+ lines.push(`export { ${col.prefix}Grid } from "./grid"`)
265
+ return `${lines.join('\n')}\n`
266
+ }
267
+
268
+ /** what a card leads with. Generated record blocks are ALPHABETICAL, so the
269
+ * first three fields of a schema are meaningless: rank by how much a human
270
+ * reads a value instead, and keep `id` as the last resort. */
271
+ export function rankForCard(col: PbCollection, plans: ColumnPlan[]): PbField[] {
272
+ const label = labelField(col).name
273
+ const tier = (f: PbField): number => {
274
+ if (f.name === 'id') return 9
275
+ if (f.name === label) return 0
276
+ if (f.kind === 'string' || f.kind === 'select') return 1
277
+ if (f.kind === 'date') return 2
278
+ return 3
279
+ }
280
+ return plans.map((p) => p.field).map((f, i) => ({ f, i, t: tier(f) })).sort((a, b) => a.t - b.t || a.i - b.i).map((x) => x.f)
281
+ }
282
+
283
+ /** every file the collection folder gets, in write order */
284
+ export function scaffoldCollectionUI(col: PbCollection): ScaffoldedFile[] {
285
+ const dir = `src/collections/${col.name}`
286
+ const shown = planColumns(col).filter((p) => p.render != null && !NOISE.has(p.field.name) && p.field.kind !== 'autodate')
287
+ const cardFields = rankForCard(col, shown).slice(0, 3)
288
+ const formFields = planForm(col)
289
+ const left = col.fields.filter((f) => !f.readOnly && !f.secret && !formFields.includes(f))
290
+ const files: ScaffoldedFile[] = [
291
+ { rel: `${dir}/columns.tsx`, body: columnsFile(col) },
292
+ { rel: `${dir}/card.tsx`, body: cardFile(col, cardFields) },
293
+ { rel: `${dir}/grid.tsx`, body: gridFile(col) },
294
+ ]
295
+ // a collection with nothing writable gets NO form rather than an empty one
296
+ if (formFields.length > 0) files.push({ rel: `${dir}/form.tsx`, body: formFile(col, formFields, left) })
297
+ files.push({ rel: `${dir}/index.ts`, body: indexFile(col, formFields.length > 0) })
298
+ return files
299
+ }
@@ -0,0 +1,233 @@
1
+ // reading a PocketBase schema out of an app's GENERATED types file
2
+ // (pocketbase-typegen output).
3
+ //
4
+ // CONSTRAINT: this is a LINE parse of generated code, not a TypeScript AST.
5
+ // shipkit carries no typescript dependency, and the input is machine written
6
+ // with exactly one field per line, so a line parse is exact for the shape
7
+ // typegen emits and worthless for anything else. Two consequences the callers
8
+ // depend on: a file that yields zero collections is reported as "not typegen
9
+ // output" rather than as an empty schema, and every line inside a record block
10
+ // that does not parse is returned in `unparsed` so it can be shown, never
11
+ // dropped.
12
+
13
+ /** what a generated field type maps to, which decides how it renders and
14
+ * whether a form can edit it at all */
15
+ export type PbFieldKind =
16
+ | 'string'
17
+ | 'number'
18
+ | 'boolean'
19
+ | 'date'
20
+ | 'autodate'
21
+ | 'relation'
22
+ | 'file'
23
+ | 'select'
24
+ | 'json'
25
+ | 'html'
26
+ | 'unknown'
27
+
28
+ export type PbField = {
29
+ name: string
30
+ /** the type expression exactly as generated, `[]` included */
31
+ type: string
32
+ kind: PbFieldKind
33
+ /** the generated field carried a `?` */
34
+ optional: boolean
35
+ array: boolean
36
+ /** select fields: the generated `<Prefix><Field>Options` union to import */
37
+ optionsType?: string
38
+ /** written by the server or the auth layer, never by a form */
39
+ readOnly: boolean
40
+ /** a credential the scaffold must never put on screen */
41
+ secret: boolean
42
+ /** added from the API's response shape, not declared in the record block */
43
+ system: boolean
44
+ }
45
+
46
+ export type PbCollection = {
47
+ /** the name PocketBase knows, `order_lines` */
48
+ name: string
49
+ /** the generated type prefix, `OrderLines` */
50
+ prefix: string
51
+ /** an auth collection, so the response carries email/username/verified */
52
+ auth: boolean
53
+ /** false when the `<Prefix>Record` block is absent or unreadable, which is
54
+ * a PARSE failure and not an empty collection */
55
+ readable: boolean
56
+ fields: PbField[]
57
+ /** record-block lines this parser could not read */
58
+ unparsed: string[]
59
+ }
60
+
61
+ /** an auth collection's identity fields do not move through a plain patch:
62
+ * `verified` is superuser-only, and email and username have their own
63
+ * endpoints, so a form control over them submits into the void */
64
+ const AUTH_MANAGED = new Set(['email', 'emailVisibility', 'username', 'verified'])
65
+
66
+ /** pocketbase's own internal collections, never an app surface */
67
+ export const isSystemCollection = (name: string): boolean => name.startsWith('_')
68
+
69
+ /** fields the API adds to every response, absent from the record block */
70
+ const BASE_SYSTEM: [string, PbFieldKind][] = [
71
+ ['id', 'string'],
72
+ ['collectionId', 'string'],
73
+ ['collectionName', 'string'],
74
+ ]
75
+ const AUTH_SYSTEM: [string, PbFieldKind][] = [
76
+ ['email', 'string'],
77
+ ['emailVisibility', 'boolean'],
78
+ ['username', 'string'],
79
+ ['verified', 'boolean'],
80
+ ]
81
+
82
+ const SYSTEM_NAMES = new Set(['id', 'collectionId', 'collectionName', 'expand'])
83
+ const SECRET_NAMES = new Set(['password', 'passwordConfirm', 'oldPassword', 'tokenKey'])
84
+
85
+ /** the body between a `{` and its matching `}`, or null when unbalanced */
86
+ function braceBlock(src: string, openIdx: number): string | null {
87
+ let depth = 0
88
+ for (let i = openIdx; i < src.length; i++) {
89
+ const c = src[i]
90
+ if (c === '{') depth++
91
+ else if (c === '}') {
92
+ depth--
93
+ if (depth === 0) return src.slice(openIdx + 1, i)
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ /** the body of `export type <name><generics?> = { … }`. The open brace is
100
+ * found by scanning forward rather than by regex, because typegen
101
+ * parameterises a record carrying a json field (`<Tdata = unknown>`) and the
102
+ * default value swallows any `=\\s*{` pattern */
103
+ function typeBlock(src: string, name: string): string | null {
104
+ const head = new RegExp(`export\\s+(?:type|interface)\\s+${name}\\b`).exec(src)
105
+ if (head == null) return null
106
+ const from = head.index + head[0].length
107
+ const open = src.indexOf('{', from)
108
+ if (open === -1) return null
109
+ // a declaration with no object body at all (an alias) must not borrow the
110
+ // braces of whatever is declared next
111
+ const nextDecl = src.indexOf('\nexport ', from)
112
+ if (nextDecl !== -1 && nextDecl < open) return null
113
+ return braceBlock(src, open)
114
+ }
115
+
116
+ export function kindOf(typeExpr: string): { kind: PbFieldKind; array: boolean; optionsType?: string } {
117
+ const stripped = typeExpr.replace(/\s+/g, ' ').trim()
118
+ // typegen writes a json field as `null | Tdata`, the generic the record
119
+ // type is parameterised by
120
+ if (/\bnull\s*\|/.test(stripped)) return { kind: 'json', array: /\[\]/.test(stripped) }
121
+ const array = /\[\]$/.test(stripped)
122
+ const base = stripped.replace(/\[\]$/, '').trim()
123
+ if (base === 'IsoAutoDateString') return { kind: 'autodate', array }
124
+ if (base === 'IsoDateString') return { kind: 'date', array }
125
+ if (base === 'RecordIdString') return { kind: 'relation', array }
126
+ if (base === 'FileNameString') return { kind: 'file', array }
127
+ if (base === 'HTMLString') return { kind: 'html', array }
128
+ if (base === 'GeoPoint') return { kind: 'json', array }
129
+ if (base === 'number') return { kind: 'number', array }
130
+ if (base === 'boolean') return { kind: 'boolean', array }
131
+ if (base === 'string') return { kind: 'string', array }
132
+ if (/^[A-Za-z_$][\w$]*Options$/.test(base)) return { kind: 'select', array, optionsType: base }
133
+ return { kind: 'unknown', array }
134
+ }
135
+
136
+ /** one `name?: Type` line of a record block */
137
+ function parseFieldLine(line: string): PbField | null {
138
+ const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))(\?)?\s*:\s*(.+?)\s*[,;]?\s*$/.exec(line)
139
+ if (m == null) return null
140
+ const name = m[1] ?? m[2]
141
+ if (name == null) return null
142
+ const type = m[4]
143
+ if (type == null) return null
144
+ const { kind, array, optionsType } = kindOf(type)
145
+ return {
146
+ name,
147
+ type,
148
+ kind,
149
+ optional: m[3] === '?',
150
+ array,
151
+ ...(optionsType == null ? {} : { optionsType }),
152
+ readOnly: kind === 'autodate' || SYSTEM_NAMES.has(name) || SECRET_NAMES.has(name),
153
+ secret: SECRET_NAMES.has(name),
154
+ system: false,
155
+ }
156
+ }
157
+
158
+ /** collection name → generated type prefix, from `CollectionRecords` when the
159
+ * file has one and from the `Collections` const or enum otherwise */
160
+ function collectionPrefixes(src: string): Map<string, string> {
161
+ const out = new Map<string, string>()
162
+ const recordsHead = /export\s+type\s+CollectionRecords\s*=\s*\{/.exec(src)
163
+ if (recordsHead != null) {
164
+ const body = braceBlock(src, recordsHead.index + recordsHead[0].length - 1) ?? ''
165
+ for (const line of body.split('\n')) {
166
+ const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*:\s*([A-Za-z_$][\w$]*)Record\b/.exec(line)
167
+ const name = m?.[1] ?? m?.[2]
168
+ const prefix = m?.[3]
169
+ if (name != null && prefix != null) out.set(name, prefix)
170
+ }
171
+ if (out.size > 0) return out
172
+ }
173
+ const constHead = /export\s+(?:const|enum)\s+Collections\s*=?\s*\{/.exec(src)
174
+ if (constHead != null) {
175
+ const body = braceBlock(src, constHead.index + constHead[0].length - 1) ?? ''
176
+ for (const line of body.split('\n')) {
177
+ const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*[:=]\s*"([^"]+)"/.exec(line)
178
+ const prefix = m?.[1] ?? m?.[2]
179
+ const name = m?.[3]
180
+ if (name != null && prefix != null) out.set(name, prefix)
181
+ }
182
+ }
183
+ return out
184
+ }
185
+
186
+ /** every collection the generated file describes, in declaration order */
187
+ export function parsePbTypes(src: string): PbCollection[] {
188
+ const out: PbCollection[] = []
189
+ for (const [name, prefix] of collectionPrefixes(src)) {
190
+ const block = typeBlock(src, `${prefix}Record`)
191
+ const auth0 = new RegExp(`export\\s+type\\s+${prefix}Response\\b[^\\n]*AuthSystemFields`).test(src)
192
+ if (block == null) {
193
+ out.push({ name, prefix, auth: auth0, readable: false, fields: [], unparsed: [] })
194
+ continue
195
+ }
196
+ const fields: PbField[] = []
197
+ const unparsed: string[] = []
198
+ const seen = new Set<string>()
199
+ for (const raw of block.split('\n')) {
200
+ const line = raw.trim()
201
+ if (line === '' || line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) continue
202
+ const field = parseFieldLine(raw)
203
+ if (field == null) {
204
+ unparsed.push(line)
205
+ continue
206
+ }
207
+ if (seen.has(field.name)) continue
208
+ seen.add(field.name)
209
+ fields.push(field)
210
+ }
211
+ const auth = auth0
212
+ // the record block holds the collection's OWN fields, the response adds
213
+ // the API's, so a scaffold built off the record alone would miss half
214
+ // the columns the list actually has
215
+ for (const [sysName, sysKind] of [...BASE_SYSTEM, ...(auth ? AUTH_SYSTEM : [])]) {
216
+ if (seen.has(sysName)) continue
217
+ seen.add(sysName)
218
+ fields.push({
219
+ name: sysName,
220
+ type: sysKind === 'boolean' ? 'boolean' : 'string',
221
+ kind: sysKind,
222
+ optional: false,
223
+ array: false,
224
+ readOnly: SYSTEM_NAMES.has(sysName) || SECRET_NAMES.has(sysName) || (auth && AUTH_MANAGED.has(sysName)),
225
+ secret: SECRET_NAMES.has(sysName),
226
+ system: true,
227
+ })
228
+ }
229
+ if (auth) for (const f of fields) if (AUTH_MANAGED.has(f.name)) f.readOnly = true
230
+ out.push({ name, prefix, auth, readable: true, fields, unparsed })
231
+ }
232
+ return out
233
+ }