@crombolo/data 0.0.0-stage → 0.9.1

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Culture and hostnames (Umbraco's `umbracoDomain`): hostnames that make a
3
+ * document a site root, each with a culture, plus an optional wildcard that only
4
+ * sets the culture of a branch. The wildcard is stored as Umbraco stores it, a
5
+ * row named `*<node id>`.
6
+ */
7
+ import type { Db } from '../database.ts'
8
+
9
+ export interface DomainAssignment {
10
+ defaultIsoCode: string | null
11
+ domains: Array<{ domainName: string; isoCode: string }>
12
+ }
13
+
14
+ export interface DomainRow {
15
+ nodeId: number
16
+ domainName: string
17
+ isoCode: string | null
18
+ isWildcard: boolean
19
+ sortOrder: number
20
+ }
21
+
22
+ export type SetDomainsResult =
23
+ | { status: 'ok' }
24
+ | { status: 'invalidLanguage'; isoCode: string }
25
+ | { status: 'invalidName'; domainName: string }
26
+ | { status: 'conflict'; domainName: string }
27
+
28
+ /** `https://Example.com/en/` → `example.com/en`; what matching and uniqueness compare. */
29
+ export function normaliseDomainName(name: string): string {
30
+ return name
31
+ .trim()
32
+ .replace(/^[a-z]+:\/\//i, '')
33
+ .replace(/\/+$/, '')
34
+ .toLowerCase()
35
+ }
36
+
37
+ const VALID_DOMAIN = /^[a-z0-9.-]+(:\d+)?(\/[a-z0-9._~%-]+)*$/i
38
+
39
+ export class DomainRepository {
40
+ #db: Db
41
+
42
+ constructor(db: Db) {
43
+ this.#db = db
44
+ }
45
+
46
+ async all(): Promise<DomainRow[]> {
47
+ const rows = await this.#db.query(
48
+ `SELECT d.node_id, d.domain_name, d.sort_order, l.iso_code FROM domain d
49
+ LEFT JOIN language l ON l.id = d.language_id ORDER BY d.node_id, d.sort_order, d.id`,
50
+ )
51
+ return rows.map((row) => ({
52
+ nodeId: Number(row.node_id),
53
+ domainName: String(row.domain_name),
54
+ isoCode: (row.iso_code as string | null) ?? null,
55
+ isWildcard: String(row.domain_name).startsWith('*'),
56
+ sortOrder: Number(row.sort_order),
57
+ }))
58
+ }
59
+
60
+ async forNode(nodeId: number): Promise<DomainAssignment> {
61
+ const rows = (await this.all()).filter((row) => row.nodeId === nodeId)
62
+ return {
63
+ defaultIsoCode: rows.find((row) => row.isWildcard)?.isoCode ?? null,
64
+ domains: rows
65
+ .filter((row) => !row.isWildcard)
66
+ .map((row) => ({ domainName: row.domainName, isoCode: row.isoCode ?? '' })),
67
+ }
68
+ }
69
+
70
+ /** Replaces a node's domains. Names are unique across the site, and each culture must exist. */
71
+ async setForNode(nodeId: number, assignment: DomainAssignment): Promise<SetDomainsResult> {
72
+ const languages = await this.#db.query<{ id: number; iso_code: string }>(
73
+ 'SELECT id, iso_code FROM language',
74
+ )
75
+ const languageId = (iso: string) =>
76
+ languages.find((l) => String(l.iso_code).toLowerCase() === iso.toLowerCase())?.id
77
+ if (assignment.defaultIsoCode && languageId(assignment.defaultIsoCode) === undefined)
78
+ return { status: 'invalidLanguage', isoCode: assignment.defaultIsoCode }
79
+ const names: string[] = []
80
+ for (const domain of assignment.domains) {
81
+ if (languageId(domain.isoCode) === undefined)
82
+ return { status: 'invalidLanguage', isoCode: domain.isoCode }
83
+ const name = normaliseDomainName(domain.domainName)
84
+ if (!VALID_DOMAIN.test(name)) return { status: 'invalidName', domainName: domain.domainName }
85
+ if (names.includes(name)) return { status: 'conflict', domainName: domain.domainName }
86
+ names.push(name)
87
+ }
88
+ const taken = (await this.all()).filter(
89
+ (row) => !row.isWildcard && row.nodeId !== nodeId && names.includes(row.domainName),
90
+ )
91
+ if (taken[0]) return { status: 'conflict', domainName: taken[0].domainName }
92
+
93
+ await this.#db.transaction(async (tx) => {
94
+ await tx.exec('DELETE FROM domain WHERE node_id = ?', [nodeId])
95
+ if (assignment.defaultIsoCode)
96
+ await tx.exec(
97
+ 'INSERT INTO domain (node_id, language_id, domain_name, sort_order) VALUES (?, ?, ?, ?)',
98
+ [nodeId, languageId(assignment.defaultIsoCode), `*${nodeId}`, 0],
99
+ )
100
+ for (const [index, domain] of assignment.domains.entries())
101
+ await tx.exec(
102
+ 'INSERT INTO domain (node_id, language_id, domain_name, sort_order) VALUES (?, ?, ?, ?)',
103
+ [nodeId, languageId(domain.isoCode), names[index], index],
104
+ )
105
+ })
106
+ return { status: 'ok' }
107
+ }
108
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Folders in the settings trees: nodes of a container object type whose only
3
+ * data is a name. Files place types in them by `folder = "A/B"`; the sync makes
4
+ * the path exist; the backoffice's folder actions rewrite the files.
5
+ */
6
+ import type { Page } from '@crombolo/core'
7
+ import { SystemNodes } from '@crombolo/core'
8
+ import type { Db } from '../database.ts'
9
+ import { NodeRepository, type NodeRow } from './nodes.ts'
10
+
11
+ export interface FolderModel {
12
+ key: string
13
+ name: string
14
+ parentKey: string | null
15
+ }
16
+
17
+ export class FolderRepository {
18
+ #db: Db
19
+ #nodes: NodeRepository
20
+ #objectType: string
21
+ /** The object type of the things the folders hold, for emptiness checks. */
22
+ #itemType: string
23
+
24
+ constructor(db: Db, containerObjectType: string, itemObjectType: string) {
25
+ this.#db = db
26
+ this.#nodes = new NodeRepository(db)
27
+ this.#objectType = containerObjectType
28
+ this.#itemType = itemObjectType
29
+ }
30
+
31
+ async byKey(key: string): Promise<FolderModel | undefined> {
32
+ const node = await this.#nodes.byKey(key)
33
+ if (!node || node.objectType !== this.#objectType) return undefined
34
+ return this.#toModel(node)
35
+ }
36
+
37
+ async #toModel(node: NodeRow): Promise<FolderModel> {
38
+ const parent = node.parentId > 0 ? await this.#nodes.byId(node.parentId) : undefined
39
+ return {
40
+ key: node.key,
41
+ name: node.text ?? '',
42
+ parentKey: parent && parent.objectType === this.#objectType ? parent.key : null,
43
+ }
44
+ }
45
+
46
+ async create(init: {
47
+ key?: string
48
+ name: string
49
+ parentKey: string | null
50
+ }): Promise<FolderModel> {
51
+ const parentId = init.parentKey
52
+ ? ((await this.#nodes.byKey(init.parentKey))?.id ?? SystemNodes.Root)
53
+ : SystemNodes.Root
54
+ const node = await this.#nodes.create({
55
+ key: init.key,
56
+ parentId,
57
+ objectType: this.#objectType,
58
+ text: init.name,
59
+ })
60
+ return this.#toModel(node)
61
+ }
62
+
63
+ async rename(key: string, name: string): Promise<boolean> {
64
+ const node = await this.#nodes.byKey(key)
65
+ if (!node || node.objectType !== this.#objectType) return false
66
+ await this.#nodes.rename(node.id, name)
67
+ return true
68
+ }
69
+
70
+ /** Deletes an empty folder; 'not-empty' when anything is still inside. */
71
+ async delete(key: string): Promise<'deleted' | 'not-empty' | 'not-found'> {
72
+ const node = await this.#nodes.byKey(key)
73
+ if (!node || node.objectType !== this.#objectType) return 'not-found'
74
+ const inside = await this.#nodes.childrenOf(node.id, [this.#objectType, this.#itemType], 0, 1)
75
+ if (inside.total > 0) return 'not-empty'
76
+ await this.#nodes.delete(node.id)
77
+ return 'deleted'
78
+ }
79
+
80
+ /** Makes `A/B/C` exist, creating what is missing; returns the leaf's key, or null for an empty path. */
81
+ async ensurePath(names: readonly string[]): Promise<string | null> {
82
+ let parentId: number = SystemNodes.Root
83
+ let key: string | null = null
84
+ for (const name of names) {
85
+ const found = await this.#db.query<{ unique_id: string; id: number }>(
86
+ 'SELECT unique_id, id FROM node WHERE parent_id = ? AND node_object_type = ? AND text = ?',
87
+ [parentId, this.#objectType, name],
88
+ )
89
+ if (found[0]) {
90
+ parentId = Number(found[0].id)
91
+ key = String(found[0].unique_id).toLowerCase()
92
+ } else {
93
+ const created = await this.#nodes.create({
94
+ parentId,
95
+ objectType: this.#objectType,
96
+ text: name,
97
+ })
98
+ parentId = created.id
99
+ key = created.key
100
+ }
101
+ }
102
+ return key
103
+ }
104
+
105
+ /** The folder names above a node, outermost first. */
106
+ async pathOf(nodeKey: string): Promise<string[]> {
107
+ const ancestors = await this.#nodes.ancestors(nodeKey)
108
+ return ancestors.filter((a) => a.objectType === this.#objectType).map((a) => a.text ?? '')
109
+ }
110
+
111
+ children(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
112
+ return this.#childrenWhere(parentKey, [this.#objectType], skip, take)
113
+ }
114
+
115
+ async #childrenWhere(parentKey: string | null, types: string[], skip: number, take: number) {
116
+ const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
117
+ if (parentId === undefined) return { total: 0, items: [] }
118
+ return this.#nodes.childrenOf(parentId, types, skip, take)
119
+ }
120
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Form entries: what visitors submitted. `docs/18-forms.md`.
3
+ *
4
+ * Keyed by the form's UUID rather than a foreign key, because the definition is
5
+ * a file. An entry outlives its definition on purpose.
6
+ *
7
+ * Values are stored as text whatever the field collects — a number as its
8
+ * decimal string, a checkbox as `true`/`false`, a multiple choice as one row
9
+ * per selection. The field type in the definition says how to read it back,
10
+ * which keeps this table free of a type column that could disagree with the
11
+ * file.
12
+ */
13
+ import { randomUUID } from 'node:crypto'
14
+ import type { Db } from '../database.ts'
15
+ import { DbDate, fromDbBool } from '../dialect.ts'
16
+
17
+ /** Where an entry is in its life: approval is the only transition. */
18
+ export type FormEntryState = 'submitted' | 'approved' | 'rejected'
19
+
20
+ export const FORM_ENTRY_STATES: readonly FormEntryState[] = ['submitted', 'approved', 'rejected']
21
+
22
+ export interface FormEntryValue {
23
+ fieldAlias: string
24
+ /** One row per selection for a multi-value field, in the order submitted. */
25
+ values: string[]
26
+ }
27
+
28
+ export interface FormEntry {
29
+ id: string
30
+ formKey: string
31
+ /** The alias the form had when this was submitted, for an orphaned entry. */
32
+ formAlias: string
33
+ state: FormEntryState
34
+ culture: string | null
35
+ /** The page the form was submitted from, when it was on one. */
36
+ pageKey: string | null
37
+ /** A salted hash, never an address: enough to rate-limit, not a liability. */
38
+ ipHash: string | null
39
+ userAgent: string | null
40
+ spam: boolean
41
+ createDate: Date
42
+ updateDate: Date
43
+ values: FormEntryValue[]
44
+ }
45
+
46
+ export type FormEntryInput = Omit<
47
+ FormEntry,
48
+ 'id' | 'createDate' | 'updateDate' | 'state' | 'spam'
49
+ > & {
50
+ state?: FormEntryState
51
+ spam?: boolean
52
+ }
53
+
54
+ export interface FormEntryQuery {
55
+ formKey: string
56
+ state?: FormEntryState
57
+ /** Inclusive; the entries view defaults to the last month. */
58
+ from?: Date
59
+ to?: Date
60
+ /** Matched against any value, which is what the list's search box does. */
61
+ search?: string
62
+ includeSpam?: boolean
63
+ skip?: number
64
+ take?: number
65
+ }
66
+
67
+ interface EntryRow {
68
+ id: string
69
+ form_key: string
70
+ form_alias: string
71
+ state: string
72
+ culture: string | null
73
+ page_key: string | null
74
+ ip_hash: string | null
75
+ user_agent: string | null
76
+ spam: unknown
77
+ create_date: unknown
78
+ update_date: unknown
79
+ }
80
+
81
+ interface ValueRow {
82
+ entry_id: string
83
+ field_alias: string
84
+ sort_order: number
85
+ value: string | null
86
+ }
87
+
88
+ const SELECT = `SELECT id, form_key, form_alias, state, culture, page_key, ip_hash,
89
+ user_agent, spam, create_date, update_date
90
+ FROM form_entry`
91
+
92
+ const state = (value: string): FormEntryState =>
93
+ FORM_ENTRY_STATES.includes(value as FormEntryState) ? (value as FormEntryState) : 'submitted'
94
+
95
+ const hydrate = (row: EntryRow, values: FormEntryValue[]): FormEntry => ({
96
+ id: row.id,
97
+ formKey: row.form_key,
98
+ formAlias: row.form_alias,
99
+ state: state(row.state),
100
+ culture: row.culture,
101
+ pageKey: row.page_key,
102
+ ipHash: row.ip_hash,
103
+ userAgent: row.user_agent,
104
+ spam: fromDbBool(row.spam),
105
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
106
+ updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
107
+ values,
108
+ })
109
+
110
+ /** Value rows into one entry per id, each field's selections in order. */
111
+ function group(rows: readonly ValueRow[]): Map<string, FormEntryValue[]> {
112
+ const byEntry = new Map<string, Map<string, string[]>>()
113
+ for (const row of rows) {
114
+ const fields = byEntry.get(row.entry_id) ?? new Map<string, string[]>()
115
+ byEntry.set(row.entry_id, fields)
116
+ const values = fields.get(row.field_alias) ?? []
117
+ fields.set(row.field_alias, values)
118
+ values.push(row.value ?? '')
119
+ }
120
+ const out = new Map<string, FormEntryValue[]>()
121
+ for (const [id, fields] of byEntry)
122
+ out.set(
123
+ id,
124
+ [...fields].map(([fieldAlias, values]) => ({ fieldAlias, values })),
125
+ )
126
+ return out
127
+ }
128
+
129
+ export class FormEntryRepository {
130
+ #db: Db
131
+ constructor(db: Db) {
132
+ this.#db = db
133
+ }
134
+
135
+ /** The values for a page of entries, in one query rather than one each. */
136
+ async #valuesFor(ids: readonly string[]): Promise<Map<string, FormEntryValue[]>> {
137
+ if (ids.length === 0) return new Map()
138
+ const placeholders = ids.map(() => '?').join(', ')
139
+ const rows = await this.#db.query<ValueRow>(
140
+ `SELECT entry_id, field_alias, sort_order, value
141
+ FROM form_entry_value
142
+ WHERE entry_id IN (${placeholders})
143
+ ORDER BY field_alias, sort_order`,
144
+ [...ids],
145
+ )
146
+ return group(rows)
147
+ }
148
+
149
+ async byId(id: string): Promise<FormEntry | undefined> {
150
+ const rows = await this.#db.query<EntryRow>(`${SELECT} WHERE id = ?`, [id])
151
+ const row = rows[0]
152
+ if (!row) return undefined
153
+ return hydrate(row, (await this.#valuesFor([id])).get(id) ?? [])
154
+ }
155
+
156
+ async list(query: FormEntryQuery): Promise<{ total: number; items: FormEntry[] }> {
157
+ const where: string[] = ['form_key = ?']
158
+ const params: unknown[] = [query.formKey]
159
+ if (query.state) {
160
+ where.push('state = ?')
161
+ params.push(query.state)
162
+ }
163
+ if (query.from) {
164
+ where.push('create_date >= ?')
165
+ params.push(DbDate.toDb(query.from))
166
+ }
167
+ if (query.to) {
168
+ where.push('create_date <= ?')
169
+ params.push(DbDate.toDb(query.to))
170
+ }
171
+ // Spam is stored and flagged rather than dropped — a false positive that
172
+ // silently discarded an enquiry is worse than one to review — so it is
173
+ // hidden from the list by default rather than absent from the table.
174
+ if (!query.includeSpam) {
175
+ where.push('spam = ?')
176
+ params.push(this.#db.dialect.boolValue(false))
177
+ }
178
+ if (query.search) {
179
+ where.push(`id IN (SELECT entry_id FROM form_entry_value WHERE LOWER(value) LIKE ?)`)
180
+ params.push(`%${query.search.toLowerCase()}%`)
181
+ }
182
+ const clause = `WHERE ${where.join(' AND ')}`
183
+
184
+ const counted = await this.#db.query<{ total: number }>(
185
+ `SELECT COUNT(*) AS total FROM form_entry ${clause}`,
186
+ params,
187
+ )
188
+ const rows = await this.#db.query<EntryRow>(
189
+ `${SELECT} ${clause} ORDER BY create_date DESC, id LIMIT ? OFFSET ?`,
190
+ [...params, query.take ?? 100, query.skip ?? 0],
191
+ )
192
+ const values = await this.#valuesFor(rows.map((row) => row.id))
193
+ return {
194
+ total: Number(counted[0]?.total ?? 0),
195
+ items: rows.map((row) => hydrate(row, values.get(row.id) ?? [])),
196
+ }
197
+ }
198
+
199
+ /** How many entries a form has, for a form with a hard cap. */
200
+ async count(formKey: string, options: { includeSpam?: boolean } = {}): Promise<number> {
201
+ const rows = options.includeSpam
202
+ ? await this.#db.query<{ total: number }>(
203
+ 'SELECT COUNT(*) AS total FROM form_entry WHERE form_key = ?',
204
+ [formKey],
205
+ )
206
+ : await this.#db.query<{ total: number }>(
207
+ 'SELECT COUNT(*) AS total FROM form_entry WHERE form_key = ? AND spam = ?',
208
+ [formKey, this.#db.dialect.boolValue(false)],
209
+ )
210
+ return Number(rows[0]?.total ?? 0)
211
+ }
212
+
213
+ async create(input: FormEntryInput, id: string = randomUUID()): Promise<FormEntry> {
214
+ const now = new Date()
215
+ await this.#db.exec(
216
+ `INSERT INTO form_entry
217
+ (id, form_key, form_alias, state, culture, page_key, ip_hash, user_agent,
218
+ spam, create_date, update_date)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
220
+ [
221
+ id,
222
+ input.formKey,
223
+ input.formAlias,
224
+ input.state ?? 'submitted',
225
+ input.culture,
226
+ input.pageKey,
227
+ input.ipHash,
228
+ input.userAgent,
229
+ this.#db.dialect.boolValue(input.spam ?? false),
230
+ DbDate.toDb(now),
231
+ DbDate.toDb(now),
232
+ ],
233
+ )
234
+ await this.#writeValues(id, input.values)
235
+ return (await this.byId(id)) as FormEntry
236
+ }
237
+
238
+ async #writeValues(id: string, values: readonly FormEntryValue[]): Promise<void> {
239
+ for (const value of values) {
240
+ // An empty list still means "this field was answered with nothing", which
241
+ // is different from the field being absent; one empty row records it.
242
+ const list = value.values.length === 0 ? [''] : value.values
243
+ for (const [sort, text] of list.entries())
244
+ await this.#db.exec(
245
+ `INSERT INTO form_entry_value (entry_id, field_alias, sort_order, value)
246
+ VALUES (?, ?, ?, ?)`,
247
+ [id, value.fieldAlias, sort, text],
248
+ )
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Approve or reject. Returns undefined when there is no such entry, so a
254
+ * caller can tell "gone" from "done".
255
+ */
256
+ async setState(id: string, next: FormEntryState): Promise<FormEntry | undefined> {
257
+ const existing = await this.byId(id)
258
+ if (!existing) return undefined
259
+ await this.#db.exec('UPDATE form_entry SET state = ?, update_date = ? WHERE id = ?', [
260
+ next,
261
+ DbDate.toDb(new Date()),
262
+ id,
263
+ ])
264
+ return this.byId(id)
265
+ }
266
+
267
+ /** Marks an entry as spam, or clears the mark after a person looked. */
268
+ async setSpam(id: string, spam: boolean): Promise<FormEntry | undefined> {
269
+ const existing = await this.byId(id)
270
+ if (!existing) return undefined
271
+ await this.#db.exec('UPDATE form_entry SET spam = ?, update_date = ? WHERE id = ?', [
272
+ this.#db.dialect.boolValue(spam),
273
+ DbDate.toDb(new Date()),
274
+ id,
275
+ ])
276
+ return this.byId(id)
277
+ }
278
+
279
+ /** Replaces an entry's values, for the backoffice's edit of one. */
280
+ async setValues(id: string, values: readonly FormEntryValue[]): Promise<FormEntry | undefined> {
281
+ const existing = await this.byId(id)
282
+ if (!existing) return undefined
283
+ await this.#db.exec('DELETE FROM form_entry_value WHERE entry_id = ?', [id])
284
+ await this.#writeValues(id, values)
285
+ await this.#db.exec('UPDATE form_entry SET update_date = ? WHERE id = ?', [
286
+ DbDate.toDb(new Date()),
287
+ id,
288
+ ])
289
+ return this.byId(id)
290
+ }
291
+
292
+ async remove(id: string): Promise<boolean> {
293
+ const existing = await this.byId(id)
294
+ if (!existing) return false
295
+ // Explicit rather than relying on the cascade: SQLite enforces foreign keys
296
+ // only when the pragma is on, and this must not depend on that.
297
+ await this.#db.exec('DELETE FROM form_entry_value WHERE entry_id = ?', [id])
298
+ await this.#db.exec('DELETE FROM form_entry WHERE id = ?', [id])
299
+ return true
300
+ }
301
+
302
+ /** Every form that has entries, with how many — including orphaned ones. */
303
+ async forms(): Promise<{ formKey: string; formAlias: string; total: number }[]> {
304
+ const rows = await this.#db.query<{ form_key: string; form_alias: string; total: number }>(
305
+ `SELECT form_key, MAX(form_alias) AS form_alias, COUNT(*) AS total
306
+ FROM form_entry GROUP BY form_key ORDER BY MAX(form_alias)`,
307
+ )
308
+ return rows.map((row) => ({
309
+ formKey: row.form_key,
310
+ formAlias: row.form_alias,
311
+ total: Number(row.total),
312
+ }))
313
+ }
314
+ }