@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings, persisted. A run upserts by `(code, subject, property, culture)`
|
|
3
|
+
* within its own `(source, scope)` and resolves whatever it no longer reports,
|
|
4
|
+
* so the dashboard shows items disappearing as they are fixed.
|
|
5
|
+
*
|
|
6
|
+
* `source` is required rather than defaulted: the sweep resolves what a run
|
|
7
|
+
* omits, so a caller that forgot to say which findings it owns would silently
|
|
8
|
+
* resolve somebody else's. `scope` narrows it again — one bundle's findings do
|
|
9
|
+
* not resolve another's.
|
|
10
|
+
*/
|
|
11
|
+
import type { Db } from './database.ts'
|
|
12
|
+
import { DbDate } from './dialect.ts'
|
|
13
|
+
|
|
14
|
+
export type FindingKind = 'auto' | 'person' | 'blocking'
|
|
15
|
+
|
|
16
|
+
/** Who a run of findings belongs to. Scopes the resolve sweep. */
|
|
17
|
+
export type FindingSource = 'upgrade' | 'transfer'
|
|
18
|
+
|
|
19
|
+
export interface Finding {
|
|
20
|
+
kind: FindingKind
|
|
21
|
+
/** Stable machine name: 'mandatory-missing', 'value-conversion', … */
|
|
22
|
+
code: string
|
|
23
|
+
subjectType:
|
|
24
|
+
| 'document'
|
|
25
|
+
| 'document-type'
|
|
26
|
+
| 'property'
|
|
27
|
+
| 'migration'
|
|
28
|
+
| 'file'
|
|
29
|
+
| 'data-type'
|
|
30
|
+
| 'media'
|
|
31
|
+
| 'element'
|
|
32
|
+
| 'bundle'
|
|
33
|
+
| 'dictionary-item'
|
|
34
|
+
subjectKey: string
|
|
35
|
+
subjectName: string
|
|
36
|
+
propertyAlias: string | null
|
|
37
|
+
culture: string | null
|
|
38
|
+
message: string
|
|
39
|
+
/** A backoffice URL, when the subject has one. */
|
|
40
|
+
link: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReportedFinding extends Finding {
|
|
44
|
+
id: number
|
|
45
|
+
runId: string
|
|
46
|
+
source: FindingSource
|
|
47
|
+
/** What within the source these findings are about: a bundle id, or null for an upgrade. */
|
|
48
|
+
scope: string | null
|
|
49
|
+
status: 'open' | 'resolved'
|
|
50
|
+
firstSeen: Date
|
|
51
|
+
lastSeen: Date
|
|
52
|
+
resolvedAt: Date | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface WriteReportOptions {
|
|
56
|
+
source: FindingSource
|
|
57
|
+
scope?: string | null
|
|
58
|
+
runId?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ReadReportOptions {
|
|
62
|
+
includeResolved?: boolean
|
|
63
|
+
source?: FindingSource
|
|
64
|
+
scope?: string | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const identity = (f: Finding) =>
|
|
68
|
+
`${f.code}|${f.subjectKey}|${f.propertyAlias ?? ''}|${f.culture ?? ''}`
|
|
69
|
+
|
|
70
|
+
/** Records a run: new findings inserted, seen ones refreshed, absent ones resolved. Returns the run id. */
|
|
71
|
+
export async function writeReport(
|
|
72
|
+
db: Db,
|
|
73
|
+
findings: readonly Finding[],
|
|
74
|
+
options: WriteReportOptions,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const { source } = options
|
|
77
|
+
const scope = options.scope ?? null
|
|
78
|
+
const runId = options.runId ?? crypto.randomUUID()
|
|
79
|
+
const now = DbDate.toDb(new Date())
|
|
80
|
+
const open = await readReport(db, { source, scope })
|
|
81
|
+
const openByIdentity = new Map(open.map((f) => [identity(f), f]))
|
|
82
|
+
const seen = new Set<string>()
|
|
83
|
+
for (const finding of findings) {
|
|
84
|
+
const key = identity(finding)
|
|
85
|
+
seen.add(key)
|
|
86
|
+
const existing = openByIdentity.get(key)
|
|
87
|
+
if (existing) {
|
|
88
|
+
await db.exec(
|
|
89
|
+
'UPDATE change_report SET run_id = ?, kind = ?, subject_name = ?, message = ?, link = ?, last_seen = ? WHERE id = ?',
|
|
90
|
+
[runId, finding.kind, finding.subjectName, finding.message, finding.link, now, existing.id],
|
|
91
|
+
)
|
|
92
|
+
} else {
|
|
93
|
+
await db.exec(
|
|
94
|
+
`INSERT INTO change_report (run_id, source, scope, kind, code, subject_type, subject_key, subject_name, property_alias, culture, message, link, status, first_seen, last_seen)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
|
96
|
+
[
|
|
97
|
+
runId,
|
|
98
|
+
source,
|
|
99
|
+
scope,
|
|
100
|
+
finding.kind,
|
|
101
|
+
finding.code,
|
|
102
|
+
finding.subjectType,
|
|
103
|
+
finding.subjectKey,
|
|
104
|
+
finding.subjectName,
|
|
105
|
+
finding.propertyAlias,
|
|
106
|
+
finding.culture,
|
|
107
|
+
finding.message,
|
|
108
|
+
finding.link,
|
|
109
|
+
now,
|
|
110
|
+
now,
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const [key, existing] of openByIdentity) {
|
|
116
|
+
if (seen.has(key)) continue
|
|
117
|
+
await db.exec("UPDATE change_report SET status = 'resolved', resolved_at = ? WHERE id = ?", [
|
|
118
|
+
now,
|
|
119
|
+
existing.id,
|
|
120
|
+
])
|
|
121
|
+
}
|
|
122
|
+
return runId
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readReport(
|
|
126
|
+
db: Db,
|
|
127
|
+
options: ReadReportOptions = {},
|
|
128
|
+
): Promise<ReportedFinding[]> {
|
|
129
|
+
const where: string[] = []
|
|
130
|
+
const params: unknown[] = []
|
|
131
|
+
if (!options.includeResolved) where.push("status = 'open'")
|
|
132
|
+
if (options.source !== undefined) {
|
|
133
|
+
where.push('source = ?')
|
|
134
|
+
params.push(options.source)
|
|
135
|
+
}
|
|
136
|
+
// `scope` is meaningful when null, so only an explicit key filters.
|
|
137
|
+
if ('scope' in options) {
|
|
138
|
+
const scope = options.scope ?? null
|
|
139
|
+
where.push(scope === null ? 'scope IS NULL' : 'scope = ?')
|
|
140
|
+
if (scope !== null) params.push(scope)
|
|
141
|
+
}
|
|
142
|
+
const rows = await db.query(
|
|
143
|
+
`SELECT id, run_id, source, scope, kind, code, subject_type, subject_key, subject_name, property_alias, culture, message, link, status, first_seen, last_seen, resolved_at
|
|
144
|
+
FROM change_report ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`,
|
|
145
|
+
params,
|
|
146
|
+
)
|
|
147
|
+
return rows.map((row) => ({
|
|
148
|
+
id: Number(row.id),
|
|
149
|
+
runId: String(row.run_id),
|
|
150
|
+
source: String(row.source) as FindingSource,
|
|
151
|
+
scope: (row.scope as string | null) ?? null,
|
|
152
|
+
kind: String(row.kind) as FindingKind,
|
|
153
|
+
code: String(row.code),
|
|
154
|
+
subjectType: String(row.subject_type) as Finding['subjectType'],
|
|
155
|
+
subjectKey: String(row.subject_key),
|
|
156
|
+
subjectName: String(row.subject_name ?? ''),
|
|
157
|
+
propertyAlias: (row.property_alias as string | null) ?? null,
|
|
158
|
+
culture: (row.culture as string | null) ?? null,
|
|
159
|
+
message: String(row.message),
|
|
160
|
+
link: (row.link as string | null) ?? null,
|
|
161
|
+
status: String(row.status) as 'open' | 'resolved',
|
|
162
|
+
firstSeen: DbDate.fromDb(row.first_seen) ?? new Date(0),
|
|
163
|
+
lastSeen: DbDate.fromDb(row.last_seen) ?? new Date(0),
|
|
164
|
+
resolvedAt: DbDate.fromDb(row.resolved_at) ?? null,
|
|
165
|
+
}))
|
|
166
|
+
}
|
package/src/cluster.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-node plumbing: node identity in `server`, and the polled
|
|
3
|
+
* `cache_instruction` table that tells other nodes what changed.
|
|
4
|
+
* docs/schema-as-code.md, "Cache coherence".
|
|
5
|
+
*/
|
|
6
|
+
import type { Db } from './database.ts'
|
|
7
|
+
import { DbDate } from './dialect.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `views` is a code change, not a data one: a template was written, so every
|
|
11
|
+
* node has to look at its views tree again. The other two invalidate the
|
|
12
|
+
* published cache; this one makes a node take a new snapshot.
|
|
13
|
+
*/
|
|
14
|
+
export type CacheInstructionKind = 'schema' | 'content' | 'components'
|
|
15
|
+
|
|
16
|
+
export interface CacheInstruction {
|
|
17
|
+
id: number
|
|
18
|
+
kind: CacheInstructionKind
|
|
19
|
+
payload: Record<string, unknown>
|
|
20
|
+
createdBy: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function appendCacheInstruction(
|
|
24
|
+
db: Db,
|
|
25
|
+
instruction: { kind: CacheInstructionKind; payload?: Record<string, unknown>; by?: string },
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
await db.exec(
|
|
28
|
+
'INSERT INTO cache_instruction (kind, payload, created_at, created_by) VALUES (?, ?, ?, ?)',
|
|
29
|
+
[
|
|
30
|
+
instruction.kind,
|
|
31
|
+
JSON.stringify(instruction.payload ?? {}),
|
|
32
|
+
DbDate.toDb(new Date()),
|
|
33
|
+
instruction.by ?? null,
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The highest instruction id, so a fresh node ignores history. */
|
|
39
|
+
export async function latestCacheInstructionId(db: Db): Promise<number> {
|
|
40
|
+
const rows = await db.query<{ n: number | null }>('SELECT MAX(id) AS n FROM cache_instruction')
|
|
41
|
+
return Number(rows[0]?.n ?? 0)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function cacheInstructionsAfter(db: Db, afterId: number): Promise<CacheInstruction[]> {
|
|
45
|
+
const rows = await db.query(
|
|
46
|
+
'SELECT id, kind, payload, created_by FROM cache_instruction WHERE id > ? ORDER BY id',
|
|
47
|
+
[afterId],
|
|
48
|
+
)
|
|
49
|
+
return rows.map((row) => ({
|
|
50
|
+
id: Number(row.id),
|
|
51
|
+
kind: String(row.kind) as CacheInstructionKind,
|
|
52
|
+
payload: row.payload ? (JSON.parse(String(row.payload)) as Record<string, unknown>) : {},
|
|
53
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ServerIdentity {
|
|
58
|
+
nodeId: string
|
|
59
|
+
version: string
|
|
60
|
+
revision: string
|
|
61
|
+
/** `all`, `api` or `web`: what this node serves, so the cluster can be read back by role. */
|
|
62
|
+
role?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Records this node in `server`; called at boot and on each poll. */
|
|
66
|
+
export async function touchServer(db: Db, identity: ServerIdentity): Promise<void> {
|
|
67
|
+
const now = DbDate.toDb(new Date())
|
|
68
|
+
const role = identity.role ?? null
|
|
69
|
+
const existing = await db.query<{ node_id: string }>(
|
|
70
|
+
'SELECT node_id FROM server WHERE node_id = ?',
|
|
71
|
+
[identity.nodeId],
|
|
72
|
+
)
|
|
73
|
+
if (existing[0]) {
|
|
74
|
+
await db.exec(
|
|
75
|
+
'UPDATE server SET version = ?, revision = ?, role = ?, last_seen = ? WHERE node_id = ?',
|
|
76
|
+
[identity.version, identity.revision, role, now, identity.nodeId],
|
|
77
|
+
)
|
|
78
|
+
} else {
|
|
79
|
+
await db.exec(
|
|
80
|
+
'INSERT INTO server (node_id, version, revision, role, last_seen) VALUES (?, ?, ?, ?, ?)',
|
|
81
|
+
[identity.nodeId, identity.version, identity.revision, role, now],
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface LiveNode {
|
|
87
|
+
nodeId: string
|
|
88
|
+
/** Null for a row written before roles were recorded. */
|
|
89
|
+
role: string | null
|
|
90
|
+
version: string | null
|
|
91
|
+
revision: string | null
|
|
92
|
+
lastSeen: Date
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The nodes that have polled within `seenWithinMs`: the cluster as it stands. */
|
|
96
|
+
export async function liveNodes(db: Db, options: { seenWithinMs: number }): Promise<LiveNode[]> {
|
|
97
|
+
const rows = await db.query(
|
|
98
|
+
'SELECT node_id, role, version, revision, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
|
|
99
|
+
[DbDate.toDb(new Date(Date.now() - options.seenWithinMs))],
|
|
100
|
+
)
|
|
101
|
+
return rows.map((row) => ({
|
|
102
|
+
nodeId: String(row.node_id),
|
|
103
|
+
role: (row.role as string | null) ?? null,
|
|
104
|
+
version: (row.version as string | null) ?? null,
|
|
105
|
+
revision: (row.revision as string | null) ?? null,
|
|
106
|
+
lastSeen: DbDate.fromDb(row.last_seen) as Date,
|
|
107
|
+
}))
|
|
108
|
+
}
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Driver-agnostic database handle. */
|
|
2
|
+
import { Database as BunSqlite } from 'bun:sqlite'
|
|
3
|
+
import { type Dialect, type DialectName, dialectFor } from './dialect.ts'
|
|
4
|
+
import { createLockManager, type LockManager } from './locks.ts'
|
|
5
|
+
|
|
6
|
+
export interface Db {
|
|
7
|
+
readonly dialect: Dialect
|
|
8
|
+
readonly locks: LockManager
|
|
9
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
|
|
10
|
+
exec(sql: string, params?: unknown[]): Promise<void>
|
|
11
|
+
transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T>
|
|
12
|
+
close(): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConnectOptions {
|
|
16
|
+
dialect?: DialectName
|
|
17
|
+
/** Postgres connection pool size. */
|
|
18
|
+
poolSize?: number
|
|
19
|
+
/** SQLite file path, or ':memory:'. Ignored for Postgres. */
|
|
20
|
+
file?: string
|
|
21
|
+
/** Postgres connection string. Ignored for SQLite. */
|
|
22
|
+
url?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class SqliteDb implements Db {
|
|
26
|
+
readonly dialect = dialectFor('sqlite')
|
|
27
|
+
readonly locks: LockManager
|
|
28
|
+
#db: BunSqlite
|
|
29
|
+
#inTransaction: boolean
|
|
30
|
+
|
|
31
|
+
constructor(db: BunSqlite, locks?: LockManager, inTransaction = false) {
|
|
32
|
+
this.#db = db
|
|
33
|
+
this.locks = locks ?? createLockManager(this)
|
|
34
|
+
this.#inTransaction = inTransaction
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
38
|
+
return this.#db.query(this.dialect.render(sql)).all(...(params as never[])) as T[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async exec(sql: string, params: unknown[] = []): Promise<void> {
|
|
42
|
+
this.#db.query(this.dialect.render(sql)).run(...(params as never[]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
|
46
|
+
if (this.#inTransaction) return fn(this)
|
|
47
|
+
this.#db.exec('BEGIN')
|
|
48
|
+
try {
|
|
49
|
+
const result = await fn(new SqliteDb(this.#db, this.locks, true))
|
|
50
|
+
this.#db.exec('COMMIT')
|
|
51
|
+
return result
|
|
52
|
+
} catch (error) {
|
|
53
|
+
this.#db.exec('ROLLBACK')
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async close(): Promise<void> {
|
|
59
|
+
this.#db.close()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SqlTag {
|
|
64
|
+
unsafe(sql: string, params?: unknown[]): Promise<unknown>
|
|
65
|
+
begin<T>(fn: (tx: SqlTag) => Promise<T>): Promise<T>
|
|
66
|
+
close(): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class PostgresDb implements Db {
|
|
70
|
+
readonly dialect = dialectFor('postgres')
|
|
71
|
+
readonly locks: LockManager
|
|
72
|
+
#sql: SqlTag
|
|
73
|
+
#inTransaction: boolean
|
|
74
|
+
|
|
75
|
+
constructor(sql: SqlTag, locks?: LockManager, inTransaction = false) {
|
|
76
|
+
this.#sql = sql
|
|
77
|
+
this.locks = locks ?? createLockManager(this)
|
|
78
|
+
this.#inTransaction = inTransaction
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
82
|
+
return (await this.#sql.unsafe(this.dialect.render(sql), params)) as T[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async exec(sql: string, params: unknown[] = []): Promise<void> {
|
|
86
|
+
await this.#sql.unsafe(this.dialect.render(sql), params)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Nested calls join the enclosing transaction, as they do on SQLite. */
|
|
90
|
+
async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
|
91
|
+
if (this.#inTransaction) return fn(this)
|
|
92
|
+
return this.#sql.begin(async (tx) => fn(new PostgresDb(tx, this.locks, true)))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async close(): Promise<void> {
|
|
96
|
+
await this.#sql.close()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveDialectName(options: ConnectOptions = {}): DialectName {
|
|
101
|
+
return options.dialect ?? (Bun.env.CROMBOLO_DB as DialectName | undefined) ?? 'sqlite'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function connect(options: ConnectOptions = {}): Promise<Db> {
|
|
105
|
+
if (resolveDialectName(options) === 'postgres') {
|
|
106
|
+
const url = options.url ?? Bun.env.CROMBOLO_POSTGRES_URL ?? Bun.env.DATABASE_URL
|
|
107
|
+
if (!url) throw new Error('Postgres selected but no connection string was provided.')
|
|
108
|
+
const { SQL } = await import('bun')
|
|
109
|
+
// A modest pool: many short-lived connections exhaust Postgres' client limit,
|
|
110
|
+
// and a single process does not need more.
|
|
111
|
+
const sql = new SQL({ url, max: options.poolSize ?? 5 }) as unknown as SqlTag
|
|
112
|
+
const db = new PostgresDb(sql)
|
|
113
|
+
await db.exec('CREATE EXTENSION IF NOT EXISTS citext')
|
|
114
|
+
return db
|
|
115
|
+
}
|
|
116
|
+
const sqlite = new BunSqlite(options.file ?? ':memory:', { create: true })
|
|
117
|
+
sqlite.exec('PRAGMA journal_mode = WAL')
|
|
118
|
+
sqlite.exec('PRAGMA foreign_keys = ON')
|
|
119
|
+
sqlite.exec('PRAGMA busy_timeout = 5000')
|
|
120
|
+
return new SqliteDb(sqlite)
|
|
121
|
+
}
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single seam between the services and SQL. Queries are written once with
|
|
3
|
+
* `?` placeholders and rendered per dialect, so no service ever needs to know
|
|
4
|
+
* which database it is talking to.
|
|
5
|
+
*/
|
|
6
|
+
export type DialectName = 'sqlite' | 'postgres'
|
|
7
|
+
|
|
8
|
+
export interface ColumnTypes {
|
|
9
|
+
identity: string
|
|
10
|
+
integer: string
|
|
11
|
+
bigint: string
|
|
12
|
+
boolean: string
|
|
13
|
+
uuid: string
|
|
14
|
+
/** Case-insensitive text. Umbraco compares aliases and logins case-insensitively. */
|
|
15
|
+
textCi: string
|
|
16
|
+
text: string
|
|
17
|
+
varchar: (n: number) => string
|
|
18
|
+
/** A varchar that compares case-sensitively on both dialects, as a bundle's tables do. */
|
|
19
|
+
varcharExact: (n: number) => string
|
|
20
|
+
/** JSON, held as text and parsed by whoever reads it. */
|
|
21
|
+
json: string
|
|
22
|
+
timestamp: string
|
|
23
|
+
numeric: string
|
|
24
|
+
blob: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Dialect {
|
|
28
|
+
readonly name: DialectName
|
|
29
|
+
readonly types: ColumnTypes
|
|
30
|
+
placeholder(index: number): string
|
|
31
|
+
quote(identifier: string): string
|
|
32
|
+
/** Rewrites `?` placeholders into the dialect's own form. */
|
|
33
|
+
render(sql: string): string
|
|
34
|
+
now(): string
|
|
35
|
+
boolValue(value: boolean): unknown
|
|
36
|
+
/** Appended to a SELECT that must block a concurrent state change; empty where the engine is single-writer. */
|
|
37
|
+
readonly forShare: string
|
|
38
|
+
readonly forUpdate: string
|
|
39
|
+
/**
|
|
40
|
+
* SQL that moves a table's identity past rows inserted with explicit ids, or
|
|
41
|
+
* undefined where the engine does that itself.
|
|
42
|
+
*/
|
|
43
|
+
syncIdentity(table: string): string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const RESERVED_RENDER = /\?/g
|
|
47
|
+
|
|
48
|
+
export const sqliteDialect: Dialect = {
|
|
49
|
+
name: 'sqlite',
|
|
50
|
+
types: {
|
|
51
|
+
identity: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
52
|
+
integer: 'INTEGER',
|
|
53
|
+
bigint: 'INTEGER',
|
|
54
|
+
boolean: 'INTEGER',
|
|
55
|
+
uuid: 'TEXT',
|
|
56
|
+
// COLLATE NOCASE is how Umbraco gets case-insensitive aliases on SQLite.
|
|
57
|
+
textCi: 'TEXT COLLATE NOCASE',
|
|
58
|
+
text: 'TEXT',
|
|
59
|
+
varchar: (n) => `VARCHAR(${n}) COLLATE NOCASE`,
|
|
60
|
+
varcharExact: (n) => `VARCHAR(${n})`,
|
|
61
|
+
json: 'TEXT',
|
|
62
|
+
timestamp: 'TEXT',
|
|
63
|
+
// REAL would be lossy for decimal property values.
|
|
64
|
+
numeric: 'TEXT',
|
|
65
|
+
blob: 'BLOB',
|
|
66
|
+
},
|
|
67
|
+
placeholder: () => '?',
|
|
68
|
+
quote: (id) => `"${id.replaceAll('"', '""')}"`,
|
|
69
|
+
render: (sql) => sql,
|
|
70
|
+
now: () => "strftime('%Y-%m-%dT%H:%M:%fZ','now')",
|
|
71
|
+
boolValue: (value) => (value ? 1 : 0),
|
|
72
|
+
forShare: '',
|
|
73
|
+
forUpdate: '',
|
|
74
|
+
syncIdentity: () => undefined,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const postgresDialect: Dialect = {
|
|
78
|
+
name: 'postgres',
|
|
79
|
+
types: {
|
|
80
|
+
identity: 'INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY',
|
|
81
|
+
integer: 'INTEGER',
|
|
82
|
+
bigint: 'BIGINT',
|
|
83
|
+
boolean: 'BOOLEAN',
|
|
84
|
+
uuid: 'UUID',
|
|
85
|
+
// Postgres collates case-sensitively by default; citext restores Umbraco's semantics.
|
|
86
|
+
textCi: 'CITEXT',
|
|
87
|
+
text: 'TEXT',
|
|
88
|
+
varchar: (n) => `VARCHAR(${n})`,
|
|
89
|
+
varcharExact: (n) => `VARCHAR(${n})`,
|
|
90
|
+
json: 'TEXT',
|
|
91
|
+
timestamp: 'TIMESTAMPTZ',
|
|
92
|
+
numeric: 'NUMERIC',
|
|
93
|
+
blob: 'BYTEA',
|
|
94
|
+
},
|
|
95
|
+
placeholder: (index) => `$${index}`,
|
|
96
|
+
quote: (id) => `"${id.replaceAll('"', '""')}"`,
|
|
97
|
+
render(sql) {
|
|
98
|
+
let i = 0
|
|
99
|
+
return sql.replace(RESERVED_RENDER, () => `$${++i}`)
|
|
100
|
+
},
|
|
101
|
+
now: () => 'now()',
|
|
102
|
+
boolValue: (value) => value,
|
|
103
|
+
forShare: 'FOR SHARE',
|
|
104
|
+
forUpdate: 'FOR UPDATE',
|
|
105
|
+
syncIdentity: (table) =>
|
|
106
|
+
`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${table}))`,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function dialectFor(name: DialectName): Dialect {
|
|
110
|
+
return name === 'sqlite' ? sqliteDialect : postgresDialect
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Timestamps are written as ISO strings in both dialects — SQLite stores TEXT,
|
|
115
|
+
* and Postgres parses ISO into timestamptz — but they read back differently
|
|
116
|
+
* (string vs Date), so every read goes through `fromDb`.
|
|
117
|
+
*/
|
|
118
|
+
export const DbDate = {
|
|
119
|
+
toDb(value: Date): string {
|
|
120
|
+
return value.toISOString()
|
|
121
|
+
},
|
|
122
|
+
fromDb(value: unknown): Date | undefined {
|
|
123
|
+
if (value === null || value === undefined) return undefined
|
|
124
|
+
if (value instanceof Date) return value
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
const parsed = new Date(value)
|
|
127
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed
|
|
128
|
+
}
|
|
129
|
+
return undefined
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** SQLite has no boolean type, so a 0/1 integer comes back for one dialect only. */
|
|
134
|
+
export function fromDbBool(value: unknown): boolean {
|
|
135
|
+
return value === true || value === 1 || value === '1' || value === 't'
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export * from './bundle-tables/index.ts'
|
|
2
|
+
export * from './change-report.ts'
|
|
3
|
+
export * from './cluster.ts'
|
|
4
|
+
export * from './database.ts'
|
|
5
|
+
export * from './dialect.ts'
|
|
6
|
+
export * from './locks.ts'
|
|
7
|
+
export * from './maintenance.ts'
|
|
8
|
+
export * from './migrations.ts'
|
|
9
|
+
export * from './node-ref.ts'
|
|
10
|
+
export * from './pending.ts'
|
|
11
|
+
export * from './repositories/bundles.ts'
|
|
12
|
+
export * from './repositories/content-types.ts'
|
|
13
|
+
export * from './repositories/data-types.ts'
|
|
14
|
+
export * from './repositories/dictionary.ts'
|
|
15
|
+
export * from './repositories/documents.ts'
|
|
16
|
+
export * from './repositories/domains.ts'
|
|
17
|
+
export * from './repositories/folders.ts'
|
|
18
|
+
export * from './repositories/form-entries.ts'
|
|
19
|
+
export * from './repositories/form-workflows.ts'
|
|
20
|
+
export * from './repositories/languages.ts'
|
|
21
|
+
export * from './repositories/log-searches.ts'
|
|
22
|
+
export * from './repositories/members.ts'
|
|
23
|
+
export * from './repositories/nodes.ts'
|
|
24
|
+
export * from './repositories/notifications.ts'
|
|
25
|
+
export * from './repositories/public-access.ts'
|
|
26
|
+
export * from './repositories/redirects.ts'
|
|
27
|
+
export * from './repositories/references.ts'
|
|
28
|
+
export * from './repositories/tags.ts'
|
|
29
|
+
export * from './repositories/templates.ts'
|
|
30
|
+
export * from './repositories/transfer-runs.ts'
|
|
31
|
+
export * from './repositories/user-data.ts'
|
|
32
|
+
export * from './repositories/users.ts'
|
|
33
|
+
export * from './schema/assistant.ts'
|
|
34
|
+
export * from './schema/built-in-content-types.ts'
|
|
35
|
+
export * from './schema/built-in-media-types.ts'
|
|
36
|
+
export * from './schema/built-in-member-types.ts'
|
|
37
|
+
export * from './schema/bundle-package.ts'
|
|
38
|
+
export * from './schema/bundles-rename.ts'
|
|
39
|
+
export * from './schema/change-report.ts'
|
|
40
|
+
export * from './schema/client-id-prefix.ts'
|
|
41
|
+
export * from './schema/content.ts'
|
|
42
|
+
export * from './schema/content-seed.ts'
|
|
43
|
+
export * from './schema/created-packages.ts'
|
|
44
|
+
export * from './schema/elements.ts'
|
|
45
|
+
export * from './schema/form-permission-prefix.ts'
|
|
46
|
+
export * from './schema/group-permissions.ts'
|
|
47
|
+
export * from './schema/identity.ts'
|
|
48
|
+
export * from './schema/log-viewer.ts'
|
|
49
|
+
export * from './schema/member-property-type.ts'
|
|
50
|
+
export * from './schema/members.ts'
|
|
51
|
+
export * from './schema/plan.ts'
|
|
52
|
+
export * from './schema/public-access.ts'
|
|
53
|
+
export * from './schema/recycle-bin.ts'
|
|
54
|
+
export * from './schema/rich-text-ui.ts'
|
|
55
|
+
export * from './schema/seed.ts'
|
|
56
|
+
export * from './schema/since-version.ts'
|
|
57
|
+
export * from './schema/transfer-run-package.ts'
|
|
58
|
+
export * from './schema/transfer-runs.ts'
|
|
59
|
+
export * from './schema/upgrade-report.ts'
|
|
60
|
+
export * from './schema/users-localisation.ts'
|
|
61
|
+
export * from './schema/values.ts'
|
|
62
|
+
export * from './schema-ops.ts'
|
|
63
|
+
export * from './schema-state.ts'
|
package/src/locks.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named write locks around content-tree mutation. Umbraco takes a row lock per
|
|
3
|
+
* lock id; SQLite has a single writer, so an in-process queue is both sufficient
|
|
4
|
+
* and necessary, while Postgres uses advisory locks.
|
|
5
|
+
*/
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
|
+
import type { Db } from './database.ts'
|
|
8
|
+
|
|
9
|
+
/** Mirrors Umbraco's Constants.Locks. */
|
|
10
|
+
export const Locks = {
|
|
11
|
+
Servers: -331,
|
|
12
|
+
ContentTypes: -332,
|
|
13
|
+
ContentTree: -333,
|
|
14
|
+
MediaTree: -334,
|
|
15
|
+
MemberTree: -335,
|
|
16
|
+
MediaTypes: -336,
|
|
17
|
+
MemberTypes: -337,
|
|
18
|
+
Domains: -338,
|
|
19
|
+
KeyValues: -339,
|
|
20
|
+
Languages: -340,
|
|
21
|
+
ScheduledPublishing: -341,
|
|
22
|
+
/** Ours: schema sync and schema-state transitions. */
|
|
23
|
+
Schema: -342,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type LockId = (typeof Locks)[keyof typeof Locks]
|
|
27
|
+
|
|
28
|
+
export interface LockManager {
|
|
29
|
+
/** Runs `fn` while holding the named lock. Re-entrant within one chain. */
|
|
30
|
+
withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The locks the current async chain holds. A holder is emptied on release, so
|
|
35
|
+
* work the holder left running in the background does not inherit the lock.
|
|
36
|
+
*/
|
|
37
|
+
const held = new AsyncLocalStorage<Set<LockId>>()
|
|
38
|
+
|
|
39
|
+
/** Runs `fn` directly when this chain already holds `id`; otherwise takes it. */
|
|
40
|
+
async function reentrant<T>(
|
|
41
|
+
id: LockId,
|
|
42
|
+
fn: () => Promise<T>,
|
|
43
|
+
acquire: (run: () => Promise<T>) => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const current = held.getStore()
|
|
46
|
+
if (current?.has(id)) return fn()
|
|
47
|
+
return acquire(async () => {
|
|
48
|
+
const holding = new Set(current)
|
|
49
|
+
holding.add(id)
|
|
50
|
+
try {
|
|
51
|
+
return await held.run(holding, fn)
|
|
52
|
+
} finally {
|
|
53
|
+
holding.clear()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class SqliteLockManager implements LockManager {
|
|
59
|
+
#queues = new Map<number, Promise<unknown>>()
|
|
60
|
+
|
|
61
|
+
withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
|
|
62
|
+
return reentrant(id, fn, (run) => this.#acquire(id, run))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #acquire<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
|
|
66
|
+
const previous = this.#queues.get(id) ?? Promise.resolve()
|
|
67
|
+
let release!: () => void
|
|
68
|
+
const gate = new Promise<void>((resolve) => {
|
|
69
|
+
release = resolve
|
|
70
|
+
})
|
|
71
|
+
this.#queues.set(
|
|
72
|
+
id,
|
|
73
|
+
previous.then(() => gate),
|
|
74
|
+
)
|
|
75
|
+
await previous
|
|
76
|
+
try {
|
|
77
|
+
return await fn()
|
|
78
|
+
} finally {
|
|
79
|
+
release()
|
|
80
|
+
if (this.#queues.get(id) === gate) this.#queues.delete(id)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class PostgresLockManager implements LockManager {
|
|
86
|
+
#db: Db
|
|
87
|
+
constructor(db: Db) {
|
|
88
|
+
this.#db = db
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
|
|
92
|
+
return reentrant(id, fn, (run) =>
|
|
93
|
+
this.#db.transaction(async (tx) => {
|
|
94
|
+
await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
|
|
95
|
+
return run()
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createLockManager(db: Db): LockManager {
|
|
102
|
+
return db.dialect.name === 'postgres' ? new PostgresLockManager(db) : new SqliteLockManager()
|
|
103
|
+
}
|