@crombolo/data 0.0.0-stage → 0.10.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/bundle-tables/builder.ts +264 -0
  5. package/src/bundle-tables/capability.ts +377 -0
  6. package/src/bundle-tables/chain.ts +105 -0
  7. package/src/bundle-tables/ddl.ts +124 -0
  8. package/src/bundle-tables/guard.ts +116 -0
  9. package/src/bundle-tables/index.ts +7 -0
  10. package/src/bundle-tables/names.ts +58 -0
  11. package/src/bundle-tables/schema.ts +485 -0
  12. package/src/change-report.ts +166 -0
  13. package/src/cluster.ts +108 -0
  14. package/src/database.ts +121 -0
  15. package/src/dialect.ts +136 -0
  16. package/src/index.ts +63 -0
  17. package/src/locks.ts +103 -0
  18. package/src/maintenance.ts +51 -0
  19. package/src/migrations.ts +547 -0
  20. package/src/node-ref.ts +107 -0
  21. package/src/pending.ts +25 -0
  22. package/src/repositories/bundles.ts +223 -0
  23. package/src/repositories/content-types.ts +831 -0
  24. package/src/repositories/data-types.ts +222 -0
  25. package/src/repositories/dictionary.ts +259 -0
  26. package/src/repositories/documents.ts +2389 -0
  27. package/src/repositories/domains.ts +108 -0
  28. package/src/repositories/folders.ts +120 -0
  29. package/src/repositories/form-entries.ts +314 -0
  30. package/src/repositories/form-workflows.ts +213 -0
  31. package/src/repositories/languages.ts +108 -0
  32. package/src/repositories/log-searches.ts +33 -0
  33. package/src/repositories/members.ts +427 -0
  34. package/src/repositories/nodes.ts +361 -0
  35. package/src/repositories/notifications.ts +54 -0
  36. package/src/repositories/public-access.ts +187 -0
  37. package/src/repositories/redirects.ts +274 -0
  38. package/src/repositories/references.ts +162 -0
  39. package/src/repositories/tags.ts +93 -0
  40. package/src/repositories/templates.ts +114 -0
  41. package/src/repositories/transfer-runs.ts +208 -0
  42. package/src/repositories/user-data.ts +90 -0
  43. package/src/repositories/users.ts +898 -0
  44. package/src/schema/assistant.ts +74 -0
  45. package/src/schema/built-in-content-types.ts +106 -0
  46. package/src/schema/built-in-media-types.ts +335 -0
  47. package/src/schema/built-in-member-types.ts +61 -0
  48. package/src/schema/bundle-components.ts +49 -0
  49. package/src/schema/bundle-package.ts +27 -0
  50. package/src/schema/bundles-rename.ts +32 -0
  51. package/src/schema/change-report.ts +46 -0
  52. package/src/schema/client-id-prefix.ts +43 -0
  53. package/src/schema/content-editing.ts +50 -0
  54. package/src/schema/content-seed.ts +586 -0
  55. package/src/schema/content.ts +263 -0
  56. package/src/schema/created-packages.ts +56 -0
  57. package/src/schema/elements.ts +62 -0
  58. package/src/schema/form-permission-prefix.ts +50 -0
  59. package/src/schema/form-permissions.ts +26 -0
  60. package/src/schema/form-workflows.ts +54 -0
  61. package/src/schema/forms.ts +63 -0
  62. package/src/schema/group-permissions.ts +20 -0
  63. package/src/schema/identity.ts +176 -0
  64. package/src/schema/log-viewer.ts +68 -0
  65. package/src/schema/member-property-type.ts +30 -0
  66. package/src/schema/members.ts +97 -0
  67. package/src/schema/plan.ts +77 -0
  68. package/src/schema/public-access.ts +56 -0
  69. package/src/schema/recycle-bin.ts +21 -0
  70. package/src/schema/redirects.ts +57 -0
  71. package/src/schema/rich-text-ui.ts +34 -0
  72. package/src/schema/seed.ts +298 -0
  73. package/src/schema/server-role.ts +21 -0
  74. package/src/schema/since-version.ts +26 -0
  75. package/src/schema/transfer-run-package.ts +27 -0
  76. package/src/schema/transfer-runs.ts +71 -0
  77. package/src/schema/upgrade-report.ts +43 -0
  78. package/src/schema/users-localisation.ts +85 -0
  79. package/src/schema/values.ts +108 -0
  80. package/src/schema-ops.ts +35 -0
  81. package/src/schema-state.ts +196 -0
  82. package/README.md +0 -3
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The form workflow queue. `docs/forms.md`.
3
+ *
4
+ * Claimed with `UPDATE … WHERE state = 'pending' … RETURNING`, which is atomic
5
+ * on both dialects: on a multi-node Postgres site every node polls, and exactly
6
+ * one of them wins each row. Without that, two nodes would send the same email.
7
+ */
8
+ import { randomUUID } from 'node:crypto'
9
+ import type { Db } from '../database.ts'
10
+ import { DbDate } from '../dialect.ts'
11
+
12
+ export type FormWorkflowState = 'pending' | 'running' | 'done' | 'failed'
13
+
14
+ export interface FormWorkflowRun {
15
+ id: string
16
+ formKey: string
17
+ formAlias: string
18
+ workflowName: string
19
+ workflowType: string
20
+ runOn: string
21
+ /** The entry this came from; absent for a form that stores none. */
22
+ entryId: string | null
23
+ /** The submitted values, as the workflow needs them. */
24
+ payload: { fieldAlias: string; values: string[] }[]
25
+ state: FormWorkflowState
26
+ attempts: number
27
+ lastError: string | null
28
+ runAfter: Date
29
+ createDate: Date
30
+ updateDate: Date
31
+ }
32
+
33
+ export type FormWorkflowEnqueue = Pick<
34
+ FormWorkflowRun,
35
+ 'formKey' | 'formAlias' | 'workflowName' | 'workflowType' | 'runOn' | 'entryId' | 'payload'
36
+ >
37
+
38
+ interface Row {
39
+ id: string
40
+ form_key: string
41
+ form_alias: string
42
+ workflow_name: string
43
+ workflow_type: string
44
+ run_on: string
45
+ entry_id: string | null
46
+ payload: string
47
+ state: string
48
+ attempts: number
49
+ last_error: string | null
50
+ run_after: unknown
51
+ create_date: unknown
52
+ update_date: unknown
53
+ }
54
+
55
+ const SELECT = `SELECT id, form_key, form_alias, workflow_name, workflow_type, run_on,
56
+ entry_id, payload, state, attempts, last_error, run_after,
57
+ create_date, update_date
58
+ FROM form_workflow_run`
59
+
60
+ const STATES: readonly FormWorkflowState[] = ['pending', 'running', 'done', 'failed']
61
+
62
+ const payloadOf = (value: string): FormWorkflowRun['payload'] => {
63
+ try {
64
+ const parsed = JSON.parse(value) as unknown
65
+ return Array.isArray(parsed) ? (parsed as FormWorkflowRun['payload']) : []
66
+ } catch {
67
+ // A row edited by hand should still be visible and still be deletable.
68
+ return []
69
+ }
70
+ }
71
+
72
+ const hydrate = (row: Row): FormWorkflowRun => ({
73
+ id: row.id,
74
+ formKey: row.form_key,
75
+ formAlias: row.form_alias,
76
+ workflowName: row.workflow_name,
77
+ workflowType: row.workflow_type,
78
+ runOn: row.run_on,
79
+ entryId: row.entry_id,
80
+ payload: payloadOf(row.payload),
81
+ state: STATES.includes(row.state as FormWorkflowState)
82
+ ? (row.state as FormWorkflowState)
83
+ : 'pending',
84
+ attempts: Number(row.attempts),
85
+ lastError: row.last_error,
86
+ runAfter: DbDate.fromDb(row.run_after) ?? new Date(0),
87
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
88
+ updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
89
+ })
90
+
91
+ export class FormWorkflowRepository {
92
+ #db: Db
93
+ constructor(db: Db) {
94
+ this.#db = db
95
+ }
96
+
97
+ /** Queues the workflows a submission triggered, in the order they are declared. */
98
+ async enqueue(runs: readonly FormWorkflowEnqueue[], now = new Date()): Promise<string[]> {
99
+ const ids: string[] = []
100
+ for (const run of runs) {
101
+ const id = randomUUID()
102
+ await this.#db.exec(
103
+ `INSERT INTO form_workflow_run
104
+ (id, form_key, form_alias, workflow_name, workflow_type, run_on, entry_id,
105
+ payload, state, attempts, last_error, run_after, create_date, update_date)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
107
+ [
108
+ id,
109
+ run.formKey,
110
+ run.formAlias,
111
+ run.workflowName,
112
+ run.workflowType,
113
+ run.runOn,
114
+ run.entryId,
115
+ JSON.stringify(run.payload),
116
+ 'pending',
117
+ 0,
118
+ null,
119
+ DbDate.toDb(now),
120
+ DbDate.toDb(now),
121
+ DbDate.toDb(now),
122
+ ],
123
+ )
124
+ ids.push(id)
125
+ }
126
+ return ids
127
+ }
128
+
129
+ /** What is ready to run, oldest first so a backlog drains in order. */
130
+ async due(now = new Date(), limit = 25): Promise<FormWorkflowRun[]> {
131
+ const rows = await this.#db.query<Row>(
132
+ `${SELECT} WHERE state = ? AND run_after <= ? ORDER BY run_after, create_date LIMIT ?`,
133
+ ['pending', DbDate.toDb(now), limit],
134
+ )
135
+ return rows.map(hydrate)
136
+ }
137
+
138
+ /**
139
+ * Takes a row, or finds somebody else already has.
140
+ *
141
+ * The `state = 'pending'` in the WHERE is the whole mechanism: two nodes issue
142
+ * the same UPDATE, and only the first one changes a row.
143
+ */
144
+ async claim(id: string): Promise<FormWorkflowRun | undefined> {
145
+ const rows = await this.#db.query<Row>(
146
+ `UPDATE form_workflow_run
147
+ SET state = ?, attempts = attempts + 1, update_date = ?
148
+ WHERE id = ? AND state = ?
149
+ RETURNING id, form_key, form_alias, workflow_name, workflow_type, run_on,
150
+ entry_id, payload, state, attempts, last_error, run_after,
151
+ create_date, update_date`,
152
+ ['running', DbDate.toDb(new Date()), id, 'pending'],
153
+ )
154
+ const row = rows[0]
155
+ return row ? hydrate(row) : undefined
156
+ }
157
+
158
+ async succeed(id: string): Promise<void> {
159
+ await this.#db.exec(
160
+ 'UPDATE form_workflow_run SET state = ?, last_error = NULL, update_date = ? WHERE id = ?',
161
+ ['done', DbDate.toDb(new Date()), id],
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Records a failure. `retryAt` puts it back in the queue; without one it is
167
+ * finished and a person has to look.
168
+ */
169
+ async fail(id: string, error: string, retryAt?: Date): Promise<void> {
170
+ await this.#db.exec(
171
+ 'UPDATE form_workflow_run SET state = ?, last_error = ?, run_after = ?, update_date = ? WHERE id = ?',
172
+ [
173
+ retryAt ? 'pending' : 'failed',
174
+ error.slice(0, 2000),
175
+ DbDate.toDb(retryAt ?? new Date()),
176
+ DbDate.toDb(new Date()),
177
+ id,
178
+ ],
179
+ )
180
+ }
181
+
182
+ async byId(id: string): Promise<FormWorkflowRun | undefined> {
183
+ const rows = await this.#db.query<Row>(`${SELECT} WHERE id = ?`, [id])
184
+ const row = rows[0]
185
+ return row ? hydrate(row) : undefined
186
+ }
187
+
188
+ /** Every run for a form, newest first; what the backoffice would show. */
189
+ async forForm(formKey: string, limit = 100): Promise<FormWorkflowRun[]> {
190
+ const rows = await this.#db.query<Row>(
191
+ `${SELECT} WHERE form_key = ? ORDER BY create_date DESC LIMIT ?`,
192
+ [formKey, limit],
193
+ )
194
+ return rows.map(hydrate)
195
+ }
196
+
197
+ /** How many runs sit in each state, for a status line. */
198
+ async counts(): Promise<Record<FormWorkflowState, number>> {
199
+ const rows = await this.#db.query<{ state: string; n: number }>(
200
+ 'SELECT state, COUNT(*) AS n FROM form_workflow_run GROUP BY state',
201
+ )
202
+ const out: Record<FormWorkflowState, number> = {
203
+ pending: 0,
204
+ running: 0,
205
+ done: 0,
206
+ failed: 0,
207
+ }
208
+ for (const row of rows)
209
+ if (STATES.includes(row.state as FormWorkflowState))
210
+ out[row.state as FormWorkflowState] = Number(row.n)
211
+ return out
212
+ }
213
+ }
@@ -0,0 +1,108 @@
1
+ /** Languages, keyed by ISO code. Never deletes: content may reference a language. */
2
+ import type { Db } from '../database.ts'
3
+ import { fromDbBool } from '../dialect.ts'
4
+
5
+ export interface LanguageModel {
6
+ isoCode: string
7
+ cultureName: string
8
+ isDefault: boolean
9
+ isMandatory: boolean
10
+ fallbackIsoCode: string | null
11
+ }
12
+
13
+ export class LanguageRepository {
14
+ #db: Db
15
+ constructor(db: Db) {
16
+ this.#db = db
17
+ }
18
+
19
+ async all(): Promise<LanguageModel[]> {
20
+ const rows = await this.#db.query(
21
+ `SELECT l.iso_code, l.culture_name, l.is_default, l.is_mandatory, f.iso_code AS fallback
22
+ FROM language l LEFT JOIN language f ON f.id = l.fallback_language_id ORDER BY l.id`,
23
+ )
24
+ return rows.map((row) => ({
25
+ isoCode: String(row.iso_code),
26
+ cultureName: String(row.culture_name),
27
+ isDefault: fromDbBool(row.is_default),
28
+ isMandatory: fromDbBool(row.is_mandatory),
29
+ fallbackIsoCode: (row.fallback as string | null) ?? null,
30
+ }))
31
+ }
32
+
33
+ async byIso(isoCode: string): Promise<LanguageModel | undefined> {
34
+ return (await this.all()).find((l) => l.isoCode.toLowerCase() === isoCode.toLowerCase())
35
+ }
36
+
37
+ /** Saves one language; making it the default unmakes the previous one. */
38
+ async save(language: LanguageModel): Promise<LanguageModel> {
39
+ const bool = (v: boolean) => this.#db.dialect.boolValue(v)
40
+ if (language.isDefault)
41
+ await this.#db.exec('UPDATE language SET is_default = ? WHERE iso_code <> ?', [
42
+ bool(false),
43
+ language.isoCode,
44
+ ])
45
+ await this.upsertAll([language])
46
+ return (await this.byIso(language.isoCode)) as LanguageModel
47
+ }
48
+
49
+ /** Never the default, and never one another language falls back to. */
50
+ async delete(isoCode: string): Promise<'deleted' | 'default' | 'in-use' | 'not-found'> {
51
+ const language = await this.byIso(isoCode)
52
+ if (!language) return 'not-found'
53
+ if (language.isDefault) return 'default'
54
+ if ((await this.all()).some((l) => l.fallbackIsoCode === language.isoCode)) return 'in-use'
55
+ await this.#db.exec('DELETE FROM language WHERE iso_code = ?', [language.isoCode])
56
+ return 'deleted'
57
+ }
58
+
59
+ /** Inserts or updates by ISO code; fallbacks are resolved after every row exists. */
60
+ async upsertAll(
61
+ languages: readonly LanguageModel[],
62
+ ): Promise<{ created: number; updated: number }> {
63
+ let created = 0
64
+ let updated = 0
65
+ const bool = (v: boolean) => this.#db.dialect.boolValue(v)
66
+ for (const language of languages) {
67
+ const existing = await this.#db.query<{ id: number }>(
68
+ 'SELECT id FROM language WHERE iso_code = ?',
69
+ [language.isoCode],
70
+ )
71
+ if (existing[0]) {
72
+ await this.#db.exec(
73
+ 'UPDATE language SET culture_name = ?, is_default = ?, is_mandatory = ? WHERE id = ?',
74
+ [
75
+ language.cultureName,
76
+ bool(language.isDefault),
77
+ bool(language.isMandatory),
78
+ Number(existing[0].id),
79
+ ],
80
+ )
81
+ updated += 1
82
+ } else {
83
+ await this.#db.exec(
84
+ 'INSERT INTO language (iso_code, culture_name, is_default, is_mandatory) VALUES (?, ?, ?, ?)',
85
+ [
86
+ language.isoCode,
87
+ language.cultureName,
88
+ bool(language.isDefault),
89
+ bool(language.isMandatory),
90
+ ],
91
+ )
92
+ created += 1
93
+ }
94
+ }
95
+ for (const language of languages) {
96
+ const fallback = language.fallbackIsoCode
97
+ ? await this.#db.query<{ id: number }>('SELECT id FROM language WHERE iso_code = ?', [
98
+ language.fallbackIsoCode,
99
+ ])
100
+ : []
101
+ await this.#db.exec('UPDATE language SET fallback_language_id = ? WHERE iso_code = ?', [
102
+ fallback[0] ? Number(fallback[0].id) : null,
103
+ language.isoCode,
104
+ ])
105
+ }
106
+ return { created, updated }
107
+ }
108
+ }
@@ -0,0 +1,33 @@
1
+ /** The log viewer's saved searches, in the order they were saved. */
2
+ import type { Db } from '../database.ts'
3
+
4
+ export class LogSearchRepository {
5
+ #db: Db
6
+
7
+ constructor(db: Db) {
8
+ this.#db = db
9
+ }
10
+
11
+ async all(): Promise<Array<{ name: string; query: string }>> {
12
+ const rows = await this.#db.query<{ name: string; query: string }>(
13
+ 'SELECT name, query FROM log_viewer_query ORDER BY id',
14
+ )
15
+ return rows.map((r) => ({ name: String(r.name), query: String(r.query) }))
16
+ }
17
+
18
+ async byName(name: string): Promise<{ name: string; query: string } | undefined> {
19
+ return (await this.all()).find((s) => s.name === name)
20
+ }
21
+
22
+ async create(name: string, query: string): Promise<'created' | 'duplicate'> {
23
+ if (await this.byName(name)) return 'duplicate'
24
+ await this.#db.exec('INSERT INTO log_viewer_query (name, query) VALUES (?, ?)', [name, query])
25
+ return 'created'
26
+ }
27
+
28
+ async delete(name: string): Promise<boolean> {
29
+ if (!(await this.byName(name))) return false
30
+ await this.#db.exec('DELETE FROM log_viewer_query WHERE name = ?', [name])
31
+ return true
32
+ }
33
+ }