@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +136 -0
- package/src/index.ts +63 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +547 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +223 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +208 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +77 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- 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
|
+
}
|