@crombolo/data 0.11.0 → 0.12.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.
- package/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +46 -13
- package/src/index.ts +14 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/data-types.ts +38 -0
- package/src/repositories/delivery-member-tokens.ts +110 -0
- package/src/repositories/dictionary.ts +18 -0
- package/src/repositories/documents.ts +431 -86
- package/src/repositories/dynamic-roots.ts +151 -0
- package/src/repositories/form-entries.ts +32 -11
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +11 -0
- package/src/repositories/nodes.ts +11 -0
- package/src/repositories/public-access.ts +30 -0
- package/src/repositories/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +8 -0
- package/src/schema/seed.ts +13 -2
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- package/src/url-options.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.12.1"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/ambient.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The transaction the current async chain is inside, shared by the drivers and the lock managers. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
|
|
4
|
+
/** Cleared when its transaction ends, so work left running afterwards falls back to the pool. */
|
|
5
|
+
export interface AmbientTransaction<T> {
|
|
6
|
+
owner: object
|
|
7
|
+
tx: T | undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ambient = new AsyncLocalStorage<AmbientTransaction<unknown>>()
|
|
11
|
+
|
|
12
|
+
/** The open transaction this chain holds on `owner`'s connection, if any. */
|
|
13
|
+
export function ambientFor<T>(owner: object): T | undefined {
|
|
14
|
+
const store = ambient.getStore()
|
|
15
|
+
return store?.owner === owner ? (store.tx as T | undefined) : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function insideTransaction(): boolean {
|
|
19
|
+
return ambient.getStore()?.tx !== undefined
|
|
20
|
+
}
|
package/src/busy.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Lock contention: recognising it in driver errors, retrying it, and the error an editor sees. */
|
|
2
|
+
|
|
3
|
+
export type Contention = 'deadlock' | 'lockTimeout' | 'busy'
|
|
4
|
+
|
|
5
|
+
/** A write gave up waiting for another to finish. Answered as 503 with Retry-After. */
|
|
6
|
+
export class ContentBusyError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly contention: Contention,
|
|
9
|
+
readonly retryAfterSeconds = 2,
|
|
10
|
+
) {
|
|
11
|
+
super('Someone else is changing this content right now. Try again in a moment.')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const POSTGRES_CODES: Record<string, Contention> = {
|
|
16
|
+
'40P01': 'deadlock',
|
|
17
|
+
'40001': 'deadlock',
|
|
18
|
+
'55P03': 'lockTimeout',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Which kind of contention a driver error reports, if any. */
|
|
22
|
+
export function classifyContention(error: unknown): Contention | undefined {
|
|
23
|
+
if (error instanceof ContentBusyError) return error.contention
|
|
24
|
+
if (!error || typeof error !== 'object') return undefined
|
|
25
|
+
const { code, errno, message } = error as { code?: unknown; errno?: unknown; message?: unknown }
|
|
26
|
+
// Bun's Postgres driver reports the SQLSTATE as `errno`, other drivers as `code`.
|
|
27
|
+
for (const state of [errno, code])
|
|
28
|
+
if (typeof state === 'string' && POSTGRES_CODES[state]) return POSTGRES_CODES[state]
|
|
29
|
+
if (
|
|
30
|
+
typeof code === 'string' &&
|
|
31
|
+
(code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_LOCKED'))
|
|
32
|
+
)
|
|
33
|
+
return 'busy'
|
|
34
|
+
if (errno === 5 || errno === 6) return 'busy'
|
|
35
|
+
if (typeof message === 'string' && /database (table )?is locked/i.test(message)) return 'busy'
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RetryPolicy {
|
|
40
|
+
/** Attempts in all, the first included. */
|
|
41
|
+
attempts: number
|
|
42
|
+
/** Waits between attempts; injected so a test does not sleep. */
|
|
43
|
+
sleep: (ms: number) => Promise<void>
|
|
44
|
+
backoffMs: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_RETRY: RetryPolicy = {
|
|
48
|
+
attempts: 2,
|
|
49
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
50
|
+
backoffMs: 50,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runs `fn`, retrying a deadlock: the database rolled the transaction back, so
|
|
55
|
+
* running it again is safe. A lock timeout is not retried, since the wait has
|
|
56
|
+
* already been spent. Contention that outlasts the policy becomes ContentBusyError.
|
|
57
|
+
*/
|
|
58
|
+
export async function retryContention<T>(
|
|
59
|
+
fn: () => Promise<T>,
|
|
60
|
+
policy: RetryPolicy = DEFAULT_RETRY,
|
|
61
|
+
): Promise<T> {
|
|
62
|
+
for (let attempt = 1; ; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
return await fn()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const contention = classifyContention(error)
|
|
67
|
+
if (!contention) throw error
|
|
68
|
+
if (contention !== 'deadlock' || attempt >= policy.attempts) {
|
|
69
|
+
throw error instanceof ContentBusyError ? error : new ContentBusyError(contention)
|
|
70
|
+
}
|
|
71
|
+
await policy.sleep(policy.backoffMs * attempt)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/change-report.ts
CHANGED
package/src/cluster.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface CacheInstruction {
|
|
|
20
20
|
createdBy: string | null
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Where Postgres says an instruction was written, so nodes poll now rather than on their timer. */
|
|
24
|
+
export const CACHE_INSTRUCTION_CHANNEL = 'crombolo_cache_instruction'
|
|
25
|
+
|
|
23
26
|
export async function appendCacheInstruction(
|
|
24
27
|
db: Db,
|
|
25
28
|
instruction: { kind: CacheInstructionKind; payload?: Record<string, unknown>; by?: string },
|
|
@@ -33,6 +36,9 @@ export async function appendCacheInstruction(
|
|
|
33
36
|
instruction.by ?? null,
|
|
34
37
|
],
|
|
35
38
|
)
|
|
39
|
+
// Delivered at commit, so a write that rolls back wakes nobody.
|
|
40
|
+
if (db.dialect.name === 'postgres')
|
|
41
|
+
await db.exec('SELECT pg_notify(?, ?)', [CACHE_INSTRUCTION_CHANNEL, instruction.kind])
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** The highest instruction id, so a fresh node ignores history. */
|
|
@@ -60,25 +66,28 @@ export interface ServerIdentity {
|
|
|
60
66
|
revision: string
|
|
61
67
|
/** `all`, `api` or `web`: what this node serves, so the cluster can be read back by role. */
|
|
62
68
|
role?: string
|
|
69
|
+
/** The Crombolo release this node runs; `version` is the schema set's. */
|
|
70
|
+
cromboloVersion?: string
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/** Records this node in `server`; called at boot and on each poll. */
|
|
66
74
|
export async function touchServer(db: Db, identity: ServerIdentity): Promise<void> {
|
|
67
75
|
const now = DbDate.toDb(new Date())
|
|
68
76
|
const role = identity.role ?? null
|
|
77
|
+
const crombolo = identity.cromboloVersion ?? null
|
|
69
78
|
const existing = await db.query<{ node_id: string }>(
|
|
70
79
|
'SELECT node_id FROM server WHERE node_id = ?',
|
|
71
80
|
[identity.nodeId],
|
|
72
81
|
)
|
|
73
82
|
if (existing[0]) {
|
|
74
83
|
await db.exec(
|
|
75
|
-
'UPDATE server SET version = ?, revision = ?, role = ?, last_seen = ? WHERE node_id = ?',
|
|
76
|
-
[identity.version, identity.revision, role, now, identity.nodeId],
|
|
84
|
+
'UPDATE server SET version = ?, revision = ?, role = ?, crombolo_version = ?, last_seen = ? WHERE node_id = ?',
|
|
85
|
+
[identity.version, identity.revision, role, crombolo, now, identity.nodeId],
|
|
77
86
|
)
|
|
78
87
|
} else {
|
|
79
88
|
await db.exec(
|
|
80
|
-
'INSERT INTO server (node_id, version, revision, role, last_seen) VALUES (?, ?, ?, ?, ?)',
|
|
81
|
-
[identity.nodeId, identity.version, identity.revision, role, now],
|
|
89
|
+
'INSERT INTO server (node_id, version, revision, role, crombolo_version, last_seen) VALUES (?, ?, ?, ?, ?, ?)',
|
|
90
|
+
[identity.nodeId, identity.version, identity.revision, role, crombolo, now],
|
|
82
91
|
)
|
|
83
92
|
}
|
|
84
93
|
}
|
|
@@ -89,13 +98,15 @@ export interface LiveNode {
|
|
|
89
98
|
role: string | null
|
|
90
99
|
version: string | null
|
|
91
100
|
revision: string | null
|
|
101
|
+
/** Null for a row written before node releases were recorded. */
|
|
102
|
+
cromboloVersion: string | null
|
|
92
103
|
lastSeen: Date
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
/** The nodes that have polled within `seenWithinMs`: the cluster as it stands. */
|
|
96
107
|
export async function liveNodes(db: Db, options: { seenWithinMs: number }): Promise<LiveNode[]> {
|
|
97
108
|
const rows = await db.query(
|
|
98
|
-
'SELECT node_id, role, version, revision, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
|
|
109
|
+
'SELECT node_id, role, version, revision, crombolo_version, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
|
|
99
110
|
[DbDate.toDb(new Date(Date.now() - options.seenWithinMs))],
|
|
100
111
|
)
|
|
101
112
|
return rows.map((row) => ({
|
|
@@ -103,6 +114,7 @@ export async function liveNodes(db: Db, options: { seenWithinMs: number }): Prom
|
|
|
103
114
|
role: (row.role as string | null) ?? null,
|
|
104
115
|
version: (row.version as string | null) ?? null,
|
|
105
116
|
revision: (row.revision as string | null) ?? null,
|
|
117
|
+
cromboloVersion: (row.crombolo_version as string | null) ?? null,
|
|
106
118
|
lastSeen: DbDate.fromDb(row.last_seen) as Date,
|
|
107
119
|
}))
|
|
108
120
|
}
|
package/src/database.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Driver-agnostic database handle. */
|
|
2
2
|
|
|
3
3
|
import { Database as BunSqlite } from 'bun:sqlite'
|
|
4
|
-
import {
|
|
4
|
+
import { ambient, ambientFor } from './ambient.ts'
|
|
5
5
|
import { type Dialect, type DialectName, dialectFor } from './dialect.ts'
|
|
6
|
-
import { createLockManager, type LockManager } from './locks.ts'
|
|
6
|
+
import { createLockManager, DEFAULT_LOCK_TIMEOUT_MS, type LockManager } from './locks.ts'
|
|
7
7
|
|
|
8
8
|
export interface Db {
|
|
9
9
|
readonly dialect: Dialect
|
|
@@ -12,6 +12,8 @@ export interface Db {
|
|
|
12
12
|
exec(sql: string, params?: unknown[]): Promise<void>
|
|
13
13
|
transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T>
|
|
14
14
|
close(): Promise<void>
|
|
15
|
+
/** Postgres only: calls `onNotify` for each `NOTIFY` on `channel`; resolves to the way to stop. */
|
|
16
|
+
listen?(channel: string, onNotify: (payload: string) => void): Promise<() => Promise<void>>
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface ConnectOptions {
|
|
@@ -22,10 +24,15 @@ export interface ConnectOptions {
|
|
|
22
24
|
file?: string
|
|
23
25
|
/** Postgres connection string. Ignored for SQLite. */
|
|
24
26
|
url?: string
|
|
27
|
+
/** How long a write waits for a content lock; defaults to `CROMBOLO_LOCK_TIMEOUT_SECONDS`, else 15s. */
|
|
28
|
+
lockTimeoutMs?: number
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
export function resolveLockTimeoutMs(options: ConnectOptions = {}): number {
|
|
32
|
+
if (options.lockTimeoutMs !== undefined) return options.lockTimeoutMs
|
|
33
|
+
const seconds = Number(Bun.env.CROMBOLO_LOCK_TIMEOUT_SECONDS)
|
|
34
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_LOCK_TIMEOUT_MS
|
|
35
|
+
}
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* One connection has one transaction at a time, but async chains interleave:
|
|
@@ -45,9 +52,10 @@ class SqliteDb implements Db {
|
|
|
45
52
|
locks?: LockManager,
|
|
46
53
|
inTransaction = false,
|
|
47
54
|
gate: { tail: Promise<void> } = { tail: Promise.resolve() },
|
|
55
|
+
lockTimeoutMs?: number,
|
|
48
56
|
) {
|
|
49
57
|
this.#db = db
|
|
50
|
-
this.locks = locks ?? createLockManager(this)
|
|
58
|
+
this.locks = locks ?? createLockManager(this, { timeoutMs: lockTimeoutMs })
|
|
51
59
|
this.#inTransaction = inTransaction
|
|
52
60
|
this.#gate = gate
|
|
53
61
|
}
|
|
@@ -63,7 +71,7 @@ class SqliteDb implements Db {
|
|
|
63
71
|
async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
|
64
72
|
if (this.#inTransaction) return fn(this)
|
|
65
73
|
const tx = new SqliteDb(this.#db, this.locks, true, this.#gate)
|
|
66
|
-
if (
|
|
74
|
+
if (ambientFor(this.#db)) return fn(tx)
|
|
67
75
|
const previous = this.#gate.tail
|
|
68
76
|
let release!: () => void
|
|
69
77
|
this.#gate.tail = new Promise<void>((resolve) => {
|
|
@@ -72,13 +80,16 @@ class SqliteDb implements Db {
|
|
|
72
80
|
await previous
|
|
73
81
|
try {
|
|
74
82
|
this.#db.exec('BEGIN')
|
|
83
|
+
const store = { owner: this.#db, tx: tx as unknown }
|
|
75
84
|
try {
|
|
76
|
-
const result = await
|
|
85
|
+
const result = await ambient.run(store, () => fn(tx))
|
|
77
86
|
this.#db.exec('COMMIT')
|
|
78
87
|
return result
|
|
79
88
|
} catch (error) {
|
|
80
89
|
this.#db.exec('ROLLBACK')
|
|
81
90
|
throw error
|
|
91
|
+
} finally {
|
|
92
|
+
store.tx = undefined
|
|
82
93
|
}
|
|
83
94
|
} finally {
|
|
84
95
|
release()
|
|
@@ -94,6 +105,10 @@ interface SqlTag {
|
|
|
94
105
|
unsafe(sql: string, params?: unknown[]): Promise<unknown>
|
|
95
106
|
begin<T>(fn: (tx: SqlTag) => Promise<T>): Promise<T>
|
|
96
107
|
close(): Promise<void>
|
|
108
|
+
listen(
|
|
109
|
+
channel: string,
|
|
110
|
+
onNotify: (payload: string) => void,
|
|
111
|
+
): Promise<{ unlisten(): Promise<void> }>
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
class PostgresDb implements Db {
|
|
@@ -102,9 +117,9 @@ class PostgresDb implements Db {
|
|
|
102
117
|
#sql: SqlTag
|
|
103
118
|
#inTransaction: boolean
|
|
104
119
|
|
|
105
|
-
constructor(sql: SqlTag, locks?: LockManager, inTransaction = false) {
|
|
120
|
+
constructor(sql: SqlTag, locks?: LockManager, inTransaction = false, lockTimeoutMs?: number) {
|
|
106
121
|
this.#sql = sql
|
|
107
|
-
this.locks = locks ?? createLockManager(this)
|
|
122
|
+
this.locks = locks ?? createLockManager(this, { timeoutMs: lockTimeoutMs })
|
|
108
123
|
this.#inTransaction = inTransaction
|
|
109
124
|
}
|
|
110
125
|
|
|
@@ -116,15 +131,33 @@ class PostgresDb implements Db {
|
|
|
116
131
|
await this.#sql.unsafe(this.dialect.render(sql), params)
|
|
117
132
|
}
|
|
118
133
|
|
|
119
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Nested calls join the enclosing transaction, as they do on SQLite. The
|
|
136
|
+
* chain is marked as inside one, so a deadlock is not retried into a
|
|
137
|
+
* transaction Postgres has already aborted.
|
|
138
|
+
*/
|
|
120
139
|
async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
|
|
121
140
|
if (this.#inTransaction) return fn(this)
|
|
122
|
-
return this.#sql.begin(async (
|
|
141
|
+
return this.#sql.begin(async (sql) => {
|
|
142
|
+
const tx = new PostgresDb(sql, this.locks, true)
|
|
143
|
+
const store = { owner: this, tx: tx as unknown }
|
|
144
|
+
try {
|
|
145
|
+
return await ambient.run(store, () => fn(tx))
|
|
146
|
+
} finally {
|
|
147
|
+
store.tx = undefined
|
|
148
|
+
}
|
|
149
|
+
})
|
|
123
150
|
}
|
|
124
151
|
|
|
125
152
|
async close(): Promise<void> {
|
|
126
153
|
await this.#sql.close()
|
|
127
154
|
}
|
|
155
|
+
|
|
156
|
+
async listen(channel: string, onNotify: (payload: string) => void): Promise<() => Promise<void>> {
|
|
157
|
+
if (this.#inTransaction) throw new Error('A transaction cannot listen.')
|
|
158
|
+
const subscription = await this.#sql.listen(channel, onNotify)
|
|
159
|
+
return () => subscription.unlisten()
|
|
160
|
+
}
|
|
128
161
|
}
|
|
129
162
|
|
|
130
163
|
export function resolveDialectName(options: ConnectOptions = {}): DialectName {
|
|
@@ -139,7 +172,7 @@ export async function connect(options: ConnectOptions = {}): Promise<Db> {
|
|
|
139
172
|
// A modest pool: many short-lived connections exhaust Postgres' client limit,
|
|
140
173
|
// and a single process does not need more.
|
|
141
174
|
const sql = new SQL({ url, max: options.poolSize ?? 5 }) as unknown as SqlTag
|
|
142
|
-
const db = new PostgresDb(sql)
|
|
175
|
+
const db = new PostgresDb(sql, undefined, false, resolveLockTimeoutMs(options))
|
|
143
176
|
await db.exec('CREATE EXTENSION IF NOT EXISTS citext')
|
|
144
177
|
return db
|
|
145
178
|
}
|
|
@@ -147,5 +180,5 @@ export async function connect(options: ConnectOptions = {}): Promise<Db> {
|
|
|
147
180
|
sqlite.exec('PRAGMA journal_mode = WAL')
|
|
148
181
|
sqlite.exec('PRAGMA foreign_keys = ON')
|
|
149
182
|
sqlite.exec('PRAGMA busy_timeout = 5000')
|
|
150
|
-
return new SqliteDb(sqlite)
|
|
183
|
+
return new SqliteDb(sqlite, undefined, false, undefined, resolveLockTimeoutMs(options))
|
|
151
184
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export { insideTransaction } from './ambient.ts'
|
|
1
2
|
export * from './audit.ts'
|
|
2
3
|
export * from './bundle-tables/index.ts'
|
|
4
|
+
export * from './busy.ts'
|
|
3
5
|
export * from './change-report.ts'
|
|
4
6
|
export * from './cluster.ts'
|
|
5
7
|
export * from './database.ts'
|
|
@@ -9,12 +11,15 @@ export * from './maintenance.ts'
|
|
|
9
11
|
export * from './migrations.ts'
|
|
10
12
|
export * from './node-ref.ts'
|
|
11
13
|
export * from './pending.ts'
|
|
14
|
+
export * from './relations.ts'
|
|
12
15
|
export * from './repositories/bundles.ts'
|
|
13
16
|
export * from './repositories/content-types.ts'
|
|
14
17
|
export * from './repositories/data-types.ts'
|
|
18
|
+
export * from './repositories/delivery-member-tokens.ts'
|
|
15
19
|
export * from './repositories/dictionary.ts'
|
|
16
20
|
export * from './repositories/documents.ts'
|
|
17
21
|
export * from './repositories/domains.ts'
|
|
22
|
+
export * from './repositories/dynamic-roots.ts'
|
|
18
23
|
export * from './repositories/folders.ts'
|
|
19
24
|
export * from './repositories/form-entries.ts'
|
|
20
25
|
export * from './repositories/form-workflows.ts'
|
|
@@ -28,9 +33,11 @@ export * from './repositories/redirects.ts'
|
|
|
28
33
|
export * from './repositories/references.ts'
|
|
29
34
|
export * from './repositories/tags.ts'
|
|
30
35
|
export * from './repositories/templates.ts'
|
|
36
|
+
export * from './repositories/transfer-answers.ts'
|
|
31
37
|
export * from './repositories/transfer-runs.ts'
|
|
32
38
|
export * from './repositories/user-data.ts'
|
|
33
39
|
export * from './repositories/users.ts'
|
|
40
|
+
export * from './repositories/webhooks.ts'
|
|
34
41
|
export * from './schema/assistant.ts'
|
|
35
42
|
export * from './schema/audit-log.ts'
|
|
36
43
|
export * from './schema/built-in-content-types.ts'
|
|
@@ -43,6 +50,8 @@ export * from './schema/client-id-prefix.ts'
|
|
|
43
50
|
export * from './schema/content.ts'
|
|
44
51
|
export * from './schema/content-seed.ts'
|
|
45
52
|
export * from './schema/created-packages.ts'
|
|
53
|
+
export * from './schema/delivery-member-tokens.ts'
|
|
54
|
+
export * from './schema/diagnostics.ts'
|
|
46
55
|
export * from './schema/elements.ts'
|
|
47
56
|
export * from './schema/form-permission-prefix.ts'
|
|
48
57
|
export * from './schema/group-permissions.ts'
|
|
@@ -58,12 +67,17 @@ export * from './schema/rich-text-ui.ts'
|
|
|
58
67
|
export * from './schema/search-index.ts'
|
|
59
68
|
export * from './schema/seed.ts'
|
|
60
69
|
export * from './schema/since-version.ts'
|
|
70
|
+
export * from './schema/transfer-answers.ts'
|
|
61
71
|
export * from './schema/transfer-run-package.ts'
|
|
62
72
|
export * from './schema/transfer-runs.ts'
|
|
63
73
|
export * from './schema/upgrade-report.ts'
|
|
64
74
|
export * from './schema/users-localisation.ts'
|
|
65
75
|
export * from './schema/values.ts'
|
|
76
|
+
export * from './schema/webhooks.ts'
|
|
66
77
|
export * from './schema-ops.ts'
|
|
67
78
|
export * from './schema-state.ts'
|
|
68
79
|
export * from './search.ts'
|
|
69
80
|
export * from './search-text.ts'
|
|
81
|
+
export * from './setup.ts'
|
|
82
|
+
export * from './tree-invariants.ts'
|
|
83
|
+
export * from './url-options.ts'
|
package/src/locks.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* and necessary, while Postgres uses advisory locks.
|
|
5
5
|
*/
|
|
6
6
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
|
+
import { insideTransaction } from './ambient.ts'
|
|
8
|
+
import { ContentBusyError, DEFAULT_RETRY, type RetryPolicy, retryContention } from './busy.ts'
|
|
7
9
|
import type { Db } from './database.ts'
|
|
8
10
|
|
|
11
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 15_000
|
|
12
|
+
|
|
9
13
|
/** Mirrors Umbraco's Constants.Locks. */
|
|
10
14
|
export const Locks = {
|
|
11
15
|
Servers: -331,
|
|
@@ -55,49 +59,114 @@ async function reentrant<T>(
|
|
|
55
59
|
})
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
export interface LockOptions {
|
|
63
|
+
/** How long to wait for the lock before answering busy. */
|
|
64
|
+
timeoutMs?: number
|
|
65
|
+
retry?: RetryPolicy
|
|
66
|
+
}
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
/**
|
|
69
|
+
* An in-process FIFO per lock id with a bounded wait. Ownership passes straight
|
|
70
|
+
* to the next waiter on release, and a waiter that times out leaves the line.
|
|
71
|
+
*/
|
|
72
|
+
export class LockQueue {
|
|
73
|
+
#lines = new Map<number, Array<() => void>>()
|
|
64
74
|
|
|
65
|
-
async
|
|
66
|
-
|
|
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
|
|
75
|
+
async hold<T>(id: number, timeoutMs: number, fn: () => Promise<T>): Promise<T> {
|
|
76
|
+
await this.#acquire(id, timeoutMs)
|
|
76
77
|
try {
|
|
77
78
|
return await fn()
|
|
78
79
|
} finally {
|
|
79
|
-
release()
|
|
80
|
-
|
|
80
|
+
this.#release(id)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#acquire(id: number, timeoutMs: number): Promise<void> {
|
|
85
|
+
const line = this.#lines.get(id)
|
|
86
|
+
if (!line) {
|
|
87
|
+
this.#lines.set(id, [])
|
|
88
|
+
return Promise.resolve()
|
|
81
89
|
}
|
|
90
|
+
return new Promise<void>((resolve, reject) => {
|
|
91
|
+
const grant = () => {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
resolve()
|
|
94
|
+
}
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
const at = line.indexOf(grant)
|
|
97
|
+
if (at >= 0) line.splice(at, 1)
|
|
98
|
+
reject(new ContentBusyError('lockTimeout'))
|
|
99
|
+
}, timeoutMs)
|
|
100
|
+
line.push(grant)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#release(id: number): void {
|
|
105
|
+
const next = this.#lines.get(id)?.shift()
|
|
106
|
+
if (next) next()
|
|
107
|
+
else this.#lines.delete(id)
|
|
82
108
|
}
|
|
83
109
|
}
|
|
84
110
|
|
|
111
|
+
/** Retries only from outside any transaction: one the database aborted cannot be resumed. */
|
|
112
|
+
function guarded<T>(fn: () => Promise<T>, retry: RetryPolicy): Promise<T> {
|
|
113
|
+
return retryContention(fn, insideTransaction() ? { ...retry, attempts: 1 } : retry)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class SqliteLockManager implements LockManager {
|
|
117
|
+
#queue = new LockQueue()
|
|
118
|
+
#timeoutMs: number
|
|
119
|
+
#retry: RetryPolicy
|
|
120
|
+
|
|
121
|
+
constructor(options: LockOptions) {
|
|
122
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
|
|
123
|
+
this.#retry = options.retry ?? DEFAULT_RETRY
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
|
|
127
|
+
return reentrant(id, fn, (run) =>
|
|
128
|
+
guarded(() => this.#queue.hold(id, this.#timeoutMs, run), this.#retry),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The in-process queue first, then the advisory lock, which orders the nodes.
|
|
135
|
+
* Without the queue every waiting request held a pooled connection blocked on
|
|
136
|
+
* the advisory lock, and a pool full of waiters left the holder none to work on.
|
|
137
|
+
*/
|
|
85
138
|
class PostgresLockManager implements LockManager {
|
|
86
139
|
#db: Db
|
|
87
|
-
|
|
140
|
+
#queue = new LockQueue()
|
|
141
|
+
#timeoutMs: number
|
|
142
|
+
#retry: RetryPolicy
|
|
143
|
+
|
|
144
|
+
constructor(db: Db, options: LockOptions) {
|
|
88
145
|
this.#db = db
|
|
146
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
|
|
147
|
+
this.#retry = options.retry ?? DEFAULT_RETRY
|
|
89
148
|
}
|
|
90
149
|
|
|
91
150
|
withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
|
|
151
|
+
const timeout = Math.max(1, Math.round(this.#timeoutMs))
|
|
92
152
|
return reentrant(id, fn, (run) =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
153
|
+
guarded(
|
|
154
|
+
() =>
|
|
155
|
+
this.#queue.hold(id, this.#timeoutMs, () =>
|
|
156
|
+
this.#db.transaction(async (tx) => {
|
|
157
|
+
await tx.exec(`SET LOCAL lock_timeout = ${timeout}`)
|
|
158
|
+
await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
|
|
159
|
+
return run()
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
this.#retry,
|
|
163
|
+
),
|
|
97
164
|
)
|
|
98
165
|
}
|
|
99
166
|
}
|
|
100
167
|
|
|
101
|
-
export function createLockManager(db: Db): LockManager {
|
|
102
|
-
return db.dialect.name === 'postgres'
|
|
168
|
+
export function createLockManager(db: Db, options: LockOptions = {}): LockManager {
|
|
169
|
+
return db.dialect.name === 'postgres'
|
|
170
|
+
? new PostgresLockManager(db, options)
|
|
171
|
+
: new SqliteLockManager(options)
|
|
103
172
|
}
|
package/src/migrations.ts
CHANGED
|
@@ -349,6 +349,26 @@ export async function readLedger(db: Db): Promise<LedgerRow[]> {
|
|
|
349
349
|
}))
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
/**
|
|
353
|
+
* A step failed and was rolled back; every step before it is applied. Says which,
|
|
354
|
+
* and that running the upgrade again carries on from there.
|
|
355
|
+
*/
|
|
356
|
+
export class MigrationStepError extends Error {
|
|
357
|
+
constructor(
|
|
358
|
+
readonly step: string,
|
|
359
|
+
readonly applied: readonly string[],
|
|
360
|
+
readonly bundle: string | undefined,
|
|
361
|
+
override readonly cause: unknown,
|
|
362
|
+
) {
|
|
363
|
+
const reason = cause instanceof Error ? cause.message : String(cause)
|
|
364
|
+
super(
|
|
365
|
+
`The upgrade stopped at ${bundle ? `the bundle "${bundle}"'s step` : 'step'} ${step}: ${reason}. ` +
|
|
366
|
+
`Nothing from that step was kept${applied.length > 0 ? `; the ${applied.length} step(s) before it are applied` : ''}. ` +
|
|
367
|
+
'Fix the cause and run the upgrade again: it carries on from this step.',
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
352
372
|
/** The database is at a state this code's plan does not know: newer code wrote it. */
|
|
353
373
|
export class UpgradeStateError extends Error {
|
|
354
374
|
constructor(
|
|
@@ -440,23 +460,29 @@ export async function migrate(
|
|
|
440
460
|
await verifyApplied(db, plan, state)
|
|
441
461
|
for (const next of pending) {
|
|
442
462
|
const started = performance.now()
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
463
|
+
// The state and the ledger row commit with the step, so an interrupted
|
|
464
|
+
// run resumes at the first step that did not land and never repeats one.
|
|
465
|
+
await db
|
|
466
|
+
.transaction(async (tx) => {
|
|
467
|
+
await next.up(tx)
|
|
468
|
+
await next.backfill?.(tx)
|
|
469
|
+
await writeState(tx, next.to, plan.stateKey)
|
|
470
|
+
await recordInLedger(tx, {
|
|
471
|
+
name: next.name,
|
|
472
|
+
kind: next.kind ?? 'expand',
|
|
473
|
+
fromState: next.from,
|
|
474
|
+
toState: next.to,
|
|
475
|
+
durationMs: Math.round(performance.now() - started),
|
|
476
|
+
checksum: next.checksum ?? next.name,
|
|
477
|
+
appliedBy: options.by ?? null,
|
|
478
|
+
note: next.release ? `release ${next.release}` : null,
|
|
479
|
+
bundle: plan.bundle ?? null,
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
.catch((error: unknown) => {
|
|
483
|
+
throw new MigrationStepError(next.name, applied, plan.bundle, error)
|
|
484
|
+
})
|
|
447
485
|
state = next.to
|
|
448
|
-
await writeState(db, state, plan.stateKey)
|
|
449
|
-
await recordInLedger(db, {
|
|
450
|
-
name: next.name,
|
|
451
|
-
kind: next.kind ?? 'expand',
|
|
452
|
-
fromState: next.from,
|
|
453
|
-
toState: next.to,
|
|
454
|
-
durationMs: Math.round(performance.now() - started),
|
|
455
|
-
checksum: next.checksum ?? next.name,
|
|
456
|
-
appliedBy: options.by ?? null,
|
|
457
|
-
note: next.release ? `release ${next.release}` : null,
|
|
458
|
-
bundle: plan.bundle ?? null,
|
|
459
|
-
})
|
|
460
486
|
applied.push(next.name)
|
|
461
487
|
}
|
|
462
488
|
return { from, to: state, applied }
|