@defrex/autobuild-postgres-store 0.8.0 → 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/README.md +48 -0
- package/package.json +18 -1
- package/src/auth-schema.ts +21 -6
- package/src/index.ts +1 -0
- package/src/retry.ts +260 -0
- package/src/schema.ts +453 -31
- package/src/store.ts +618 -197
- package/src/tickets.ts +88 -59
- package/src/blobs.test.ts +0 -121
- package/src/config.test.ts +0 -174
- package/src/env.test.ts +0 -48
- package/src/event-wait-poll-ms.test.ts +0 -23
- package/src/migrate.test.ts +0 -640
- package/src/store.live.test.ts +0 -453
- package/src/testing/concurrent-worker.ts +0 -42
- package/src/tickets.live.test.ts +0 -49
package/README.md
CHANGED
|
@@ -9,6 +9,19 @@ dependencies. A project that embeds the adapter adds it directly:
|
|
|
9
9
|
bun add @defrex/autobuild-postgres-store
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Only this package's `src/` tree and this README publish to npm: every
|
|
13
|
+
consumer of the published package imports through the manifest's `exports`
|
|
14
|
+
and `bin`, all of which live under `src/`. The manifest's `files` allowlist
|
|
15
|
+
pins the tarball to exactly that, and `bun tools/publish-contents-check.ts`
|
|
16
|
+
in the repository `check` gate fails when anything else would ship. The
|
|
17
|
+
deny-by-default allowlist is deliberate: a future top-level file in this
|
|
18
|
+
package (a scratch script, a live-test fixture, a dotenv file, an editor
|
|
19
|
+
artifact) stays out of the tarball unless the allowlist and the check are
|
|
20
|
+
updated together. The `src/testing/` helpers are test-only surface — their
|
|
21
|
+
sole consumer is the live test suite — and are excluded from the tarball by
|
|
22
|
+
the `!src/testing/**` negation (AUT-500), so a surface pass does not re-flag
|
|
23
|
+
them.
|
|
24
|
+
|
|
12
25
|
## Setup
|
|
13
26
|
|
|
14
27
|
To run the migration from the hosted deployment's own checkout, choose the
|
|
@@ -35,6 +48,41 @@ adds team-scoped tickets, comments, and blockers without replacing the
|
|
|
35
48
|
established BuildStore marker. Opening against a missing, older/newer, or
|
|
36
49
|
checksum-mismatched schema fails; schema creation is never implicit.
|
|
37
50
|
|
|
51
|
+
The current DDL of every marker — build store, ticket, and auth — is immutable
|
|
52
|
+
once a database has deployed it: deployed databases carry that exact DDL's
|
|
53
|
+
checksum, and editing the DDL in place leaves them all incompatible. Every
|
|
54
|
+
schema change is therefore a new version — the four-step rule: freeze the
|
|
55
|
+
previous DDL verbatim with its checksum, add an upgrade branch for it in
|
|
56
|
+
`migratePostgres`, bump the schema version, and update the committed pin in
|
|
57
|
+
`schema-guard.test.ts`. An edit to any current DDL or version without the
|
|
58
|
+
matching pin update fails the unit suite with a message that names those four
|
|
59
|
+
steps.
|
|
60
|
+
|
|
61
|
+
The pin is a tripwire, not an enforced coupling, and it cannot mechanically
|
|
62
|
+
force the version bump: the pin is the only committed record of the released
|
|
63
|
+
checksum, and an author who edits the current DDL and re-pins only the pin's
|
|
64
|
+
checksum — leaving the pinned version and the exported version constants
|
|
65
|
+
unchanged — passes the unit suite. Deployed databases then still carry the
|
|
66
|
+
pre-edit checksum of a version whose DDL has changed, and the next
|
|
67
|
+
`openPostgresBuildStore` or `migratePostgres` fails with a diagnostic naming
|
|
68
|
+
that cause. Follow all four steps on every schema change; the migration
|
|
69
|
+
diagnostic exists to catch the shortcut after the fact.
|
|
70
|
+
|
|
71
|
+
## Public entry points
|
|
72
|
+
|
|
73
|
+
The package exposes four subpaths, each named after the source module it
|
|
74
|
+
publishes:
|
|
75
|
+
|
|
76
|
+
- `@defrex/autobuild-postgres-store` — the full adapter surface: `openPostgresBuildStore`,
|
|
77
|
+
`openPostgresBuildStoreFromEnv`, `migratePostgres`, ticket and blob stores.
|
|
78
|
+
- `@defrex/autobuild-postgres-store/env` — URL resolution helpers
|
|
79
|
+
(`resolvePostgresUrl`, `describePostgresTarget`).
|
|
80
|
+
- `@defrex/autobuild-postgres-store/schema` — schema DDL, version and checksum
|
|
81
|
+
constants, schema assertions, and `migratePostgres`.
|
|
82
|
+
- `@defrex/autobuild-postgres-store/store` — `openPostgresBuildStore`, the
|
|
83
|
+
`PostgresBuildStore` class, `PostgresBuildStoreOptions`, and
|
|
84
|
+
`EVENT_WAIT_POLL_MS`.
|
|
85
|
+
|
|
38
86
|
## Concurrency
|
|
39
87
|
|
|
40
88
|
Identity rows are created conflict-safely before being locked for subsequent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild-postgres-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
"./env": {
|
|
15
15
|
"types": "./src/env.ts",
|
|
16
16
|
"import": "./src/env.ts"
|
|
17
|
+
},
|
|
18
|
+
"./schema": {
|
|
19
|
+
"types": "./src/schema.ts",
|
|
20
|
+
"import": "./src/schema.ts"
|
|
21
|
+
},
|
|
22
|
+
"./store": {
|
|
23
|
+
"types": "./src/store.ts",
|
|
24
|
+
"import": "./src/store.ts"
|
|
17
25
|
}
|
|
18
26
|
},
|
|
19
27
|
"bin": {
|
|
@@ -26,6 +34,15 @@
|
|
|
26
34
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
27
35
|
"@vercel/blob": "^2.0.0"
|
|
28
36
|
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"!src/**/*.test.ts",
|
|
41
|
+
"!src/**/*.test.tsx",
|
|
42
|
+
"!src/**/*.spec.ts",
|
|
43
|
+
"!src/**/*.spec.tsx",
|
|
44
|
+
"!src/testing/**"
|
|
45
|
+
],
|
|
29
46
|
"publishConfig": {
|
|
30
47
|
"access": "public"
|
|
31
48
|
},
|
package/src/auth-schema.ts
CHANGED
|
@@ -154,13 +154,28 @@ export async function assertAuthSchema(sql: SQL): Promise<void> {
|
|
|
154
154
|
throw error
|
|
155
155
|
}
|
|
156
156
|
const marker = rows[0]
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
if (!marker) {
|
|
158
|
+
throw new Error('PostgreSQL auth schema marker is missing; run the migration from this release')
|
|
159
|
+
}
|
|
160
|
+
if (Number(marker.version) !== AUTH_SCHEMA_VERSION) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`PostgreSQL auth schema marker is incompatible: version ${marker.version} does not match ` +
|
|
163
|
+
`required version ${AUTH_SCHEMA_VERSION}; run the migration from this release`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
if (marker.checksum !== AUTH_SCHEMA_CHECKSUM) {
|
|
167
|
+
// The targeted diagnostic for a version-matched, checksum-mismatched
|
|
168
|
+
// marker (AUT-548): the shape a checksum-only re-pin leaves deployed
|
|
169
|
+
// databases in, and also the shape of a database built by a different
|
|
170
|
+
// build of the same version, which a marker alone cannot distinguish.
|
|
162
171
|
throw new Error(
|
|
163
|
-
|
|
172
|
+
`PostgreSQL auth schema marker is incompatible: its version matches ` +
|
|
173
|
+
`${AUTH_SCHEMA_VERSION}, but its checksum does not match this build's DDL — the current ` +
|
|
174
|
+
`DDL was likely edited in place without bumping the version, or the database was built ` +
|
|
175
|
+
`by a different build of this same version. Never edit deployed DDL; follow the ` +
|
|
176
|
+
`four-step rule (freeze the previous DDL, add an upgrade branch in migratePostgres, ` +
|
|
177
|
+
`bump the version, re-pin in schema-guard.test.ts) and redeploy; run the migration ` +
|
|
178
|
+
`from this release`,
|
|
164
179
|
)
|
|
165
180
|
}
|
|
166
181
|
const columns: { table_name: string; column_name: string }[] = await sql`
|
package/src/index.ts
CHANGED
package/src/retry.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
|
|
3
|
+
export type Row = Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The executor handed to store operation bodies: a thin forwarder onto one
|
|
7
|
+
* pinned pooled connection. Callable as a tagged template (the normal
|
|
8
|
+
* parameterized path) plus `unsafe` for pre-built statement text.
|
|
9
|
+
*/
|
|
10
|
+
export type Exec = {
|
|
11
|
+
(strings: TemplateStringsArray, ...values: unknown[]): Promise<Row[]>
|
|
12
|
+
unsafe(text: string, params?: unknown[]): Promise<Row[]>
|
|
13
|
+
/** PostgreSQL array parameter builder (`SQL.array`), forwarded to the
|
|
14
|
+
* underlying target — a pure client-side parameter builder with no round
|
|
15
|
+
* trip, safe on reserved connections and transaction executors (both
|
|
16
|
+
* extend `SQL`). It builds a parameter, not a statement: it adds no
|
|
17
|
+
* marking of its own and does not touch failure attribution. */
|
|
18
|
+
array: (...args: Parameters<SQL['array']>) => ReturnType<SQL['array']>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* When a migration adds a column to a table, PostgreSQL backends holding a
|
|
23
|
+
* cached plan for a statement returning `*` from that table fail the next
|
|
24
|
+
* execution with SQLSTATE 0A000, "cached plan must not change result type".
|
|
25
|
+
* The server invalidates the plan, but Bun (1.4.x) names prepared statements
|
|
26
|
+
* deterministically from the query text and reuses them for the connection's
|
|
27
|
+
* lifetime — so every subsequent execution of the same text on that
|
|
28
|
+
* connection is revalidated and rejected again; the plan is never silently
|
|
29
|
+
* rebuilt. A bare same-connection retry therefore does not recover, and
|
|
30
|
+
* `DEALLOCATE ALL` is unusable too: it clears the server-side statements but
|
|
31
|
+
* Bun never evicts its client-side text→statement map, so the retried
|
|
32
|
+
* statement (and every other one) then fails with 26000 forever.
|
|
33
|
+
*
|
|
34
|
+
* What does recover, deterministically and on the same connection, is
|
|
35
|
+
* re-preparing the statement fresh: appending a marker comment to the query
|
|
36
|
+
* text gives it a new statement name, so Bun parses and prepares it again
|
|
37
|
+
* and the fresh plan sees the new schema.
|
|
38
|
+
*
|
|
39
|
+
* The marker must do more than make the one retry succeed, though. If every
|
|
40
|
+
* retry invented a fresh unique marker, the *original* text would stay
|
|
41
|
+
* poisoned forever: each later operation on the connection would first fail
|
|
42
|
+
* 0A000 and then prepare yet another variant — a permanent failed round trip
|
|
43
|
+
* per operation and an unbounded prepared-statement leak. So the marker is
|
|
44
|
+
* also **memoized**: the first plan-change failure for a statement text
|
|
45
|
+
* records a marked variant for that text, and every later execution — on any
|
|
46
|
+
* pooled connection — skips the poisoned plan and goes straight to the
|
|
47
|
+
* healed variant. The cost of a migration then stays what the incident
|
|
48
|
+
* model predicts (one failed execution per connection per statement) and the
|
|
49
|
+
* statement growth is bounded by the migration count, not request volume.
|
|
50
|
+
*
|
|
51
|
+
* The memo is per store instance and its markers are namespaced with a
|
|
52
|
+
* per-instance id, so two stores sharing one pool never reuse each other's
|
|
53
|
+
* prepared statement names (a fresh store's memo may lag; its own failure
|
|
54
|
+
* then heals under its own namespace instead of colliding with a stale
|
|
55
|
+
* variant another store prepared before a later migration).
|
|
56
|
+
*
|
|
57
|
+
* One memo entry is not enough to heal a retry, though. A migration can
|
|
58
|
+
* poison *any* `*`-returning plan a body touches, not only the statement
|
|
59
|
+
* that failed first — a body reading two migration-extended tables fails on
|
|
60
|
+
* the first, heals it, and would then abort on the second if the retry only
|
|
61
|
+
* re-prepared the memoized text. So a retry attempt re-prepares **every**
|
|
62
|
+
* statement the body executes: each text gets a freshly minted, memoized
|
|
63
|
+
* marker. Fresh minting (not marker reuse) is deliberate — a memoized
|
|
64
|
+
* marker prepared before the migration is itself stale, and reusing it
|
|
65
|
+
* would fail the retry with a second 0A000. The runner's catch also
|
|
66
|
+
* invalidates the failing statement's text before retrying, so the text is
|
|
67
|
+
* memoized even if the re-run body takes a branch that skips it.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** Distinguishes statement texts inside the memo. Raw parts joined by NUL —
|
|
71
|
+
* NUL cannot appear in a PostgreSQL statement, so the join is collision-free,
|
|
72
|
+
* and raw (not cooked) is what the server actually sees. */
|
|
73
|
+
export function statementKey(strings: TemplateStringsArray): string {
|
|
74
|
+
return (strings.raw ?? strings).join('\u0000')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The plan-invalidation failure class: SQLSTATE 0A000 naming the cached
|
|
78
|
+
* plan's result-type change. Anything else is not a plan change. */
|
|
79
|
+
export function isPlanChangeError(error: unknown): boolean {
|
|
80
|
+
return (
|
|
81
|
+
error instanceof SQL.PostgresError &&
|
|
82
|
+
error.errno === '0A000' &&
|
|
83
|
+
/cached plan must not change result type/.test(error.message)
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let memoSequence = 0
|
|
88
|
+
|
|
89
|
+
/** The per-store memo of poisoned statement texts (see the module comment). */
|
|
90
|
+
export class PlanInvalidations {
|
|
91
|
+
private readonly id: string
|
|
92
|
+
private counter = 0
|
|
93
|
+
private readonly markers = new Map<string, string>()
|
|
94
|
+
|
|
95
|
+
constructor() {
|
|
96
|
+
this.id = `s${++memoSequence}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The marker currently appended to `text`: `''` while the plain text is
|
|
100
|
+
* still the right thing to execute, or the marked suffix once the text's
|
|
101
|
+
* plan has been invalidated, so executions skip the poisoned plan. */
|
|
102
|
+
markerFor(text: string): string {
|
|
103
|
+
return this.markers.get(text) ?? ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Record a plan invalidation for `text` and return its fresh marker —
|
|
107
|
+
* unique per store instance and never prepared before, so the next
|
|
108
|
+
* execution of the text re-prepares it under a brand-new name. */
|
|
109
|
+
invalidate(text: string): string {
|
|
110
|
+
const marker = ` /*ab-plan-retry-${this.id}-${++this.counter}*/`
|
|
111
|
+
this.markers.set(text, marker)
|
|
112
|
+
return marker
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One execution attempt on a connection (or transaction executor): the
|
|
117
|
+
* executor store bodies run their statements through, plus attribution for
|
|
118
|
+
* which statement was in flight when the attempt failed. */
|
|
119
|
+
export interface Attempt {
|
|
120
|
+
exec: Exec
|
|
121
|
+
/** The statement text most recently dispatched and not resolved
|
|
122
|
+
* successfully — `null` after a clean statement, still set after a
|
|
123
|
+
* rejection. The runner's catch reads it to attribute a plan-change
|
|
124
|
+
* failure to the exact statement. Bodies execute statements sequentially,
|
|
125
|
+
* so this is the failing statement; concurrent misattribution would at
|
|
126
|
+
* worst over-mark a healthy text, which only costs one extra variant. */
|
|
127
|
+
inFlight: string | null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Build the per-attempt executor for one connection. Unpoisoned texts are
|
|
131
|
+
* forwarded verbatim, so normal-path prepared-statement behavior is
|
|
132
|
+
* unchanged; poisoned texts get their memoized marker appended (see the
|
|
133
|
+
* module comment), and `attempt.inFlight` tracks the statement text in
|
|
134
|
+
* flight for failure attribution. In retry mode (`retry`), every text is
|
|
135
|
+
* freshly minted and memoized instead, so the whole re-run body re-prepares
|
|
136
|
+
* against the post-migration schema. */
|
|
137
|
+
export function attemptExec(target: SQL, plans: PlanInvalidations, retry = false): Attempt {
|
|
138
|
+
const attempt: Attempt = { exec: undefined as unknown as Exec, inFlight: null }
|
|
139
|
+
/** Dispatch one call, keeping `inFlight` set while (and after) it fails,
|
|
140
|
+
* clearing it once it succeeds. */
|
|
141
|
+
const dispatch = (key: string, call: () => Promise<Row[]>): Promise<Row[]> => {
|
|
142
|
+
attempt.inFlight = key
|
|
143
|
+
return Promise.resolve(call()).then((rows) => {
|
|
144
|
+
attempt.inFlight = null
|
|
145
|
+
return rows
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const marker = (key: string): string => {
|
|
149
|
+
if (!retry) return plans.markerFor(key)
|
|
150
|
+
// Retry mode: a memoized marker may itself be stale (minted before the
|
|
151
|
+
// migration that triggered this retry), so mint fresh and memoize.
|
|
152
|
+
return plans.invalidate(key)
|
|
153
|
+
}
|
|
154
|
+
const exec = ((strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
155
|
+
const key = statementKey(strings)
|
|
156
|
+
const mark = marker(key)
|
|
157
|
+
return dispatch(key, () => {
|
|
158
|
+
if (mark === '') return target(strings, ...values)
|
|
159
|
+
// Copy the template array and mark the last raw part. A plain array
|
|
160
|
+
// copy drops the non-enumerable `raw` property — without it Bun treats
|
|
161
|
+
// the array as a query *value*, not a template — so re-attach it,
|
|
162
|
+
// pointing at the marked copy itself (comment-only changes make cooked
|
|
163
|
+
// and raw identical).
|
|
164
|
+
const parts: string[] = [...strings]
|
|
165
|
+
parts[parts.length - 1] += mark
|
|
166
|
+
Object.assign(parts, { raw: parts })
|
|
167
|
+
return target(parts as unknown as TemplateStringsArray, ...values)
|
|
168
|
+
})
|
|
169
|
+
}) as unknown as Exec
|
|
170
|
+
exec.unsafe = (text: string, params?: unknown[]) => {
|
|
171
|
+
const mark = marker(text)
|
|
172
|
+
return dispatch(text, () =>
|
|
173
|
+
mark === '' ? target.unsafe(text, params) : target.unsafe(`${text}${mark}`, params),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
// Forward the array builder verbatim: it produces a parameter value, not
|
|
177
|
+
// an execution, so it needs neither marking nor failure attribution.
|
|
178
|
+
// `Parameters<SQL['array']>` sidesteps Bun's namespace-internal
|
|
179
|
+
// `SQLArrayParameter`/`ArrayType` types, whose variance (`values: any[]`)
|
|
180
|
+
// resists a hand-written signature.
|
|
181
|
+
exec.array = ((...args: Parameters<SQL['array']>) =>
|
|
182
|
+
(target.array as (...forwarded: unknown[]) => ReturnType<SQL['array']>)(
|
|
183
|
+
...args,
|
|
184
|
+
)) as Exec['array']
|
|
185
|
+
attempt.exec = exec
|
|
186
|
+
return attempt
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The shared plan-retry mechanics every PostgreSQL-backed store routes its
|
|
190
|
+
* statements through: reserve one pooled connection per operation, and on a
|
|
191
|
+
* plan-change failure (AUT-396) retry the whole body exactly once on that
|
|
192
|
+
* same connection with every statement freshly prepared (see the module
|
|
193
|
+
* comment for why that is the only thing that recovers, and why the marked
|
|
194
|
+
* variants are memoized). One runner per pool owner: the memo's markers are
|
|
195
|
+
* namespaced per runner instance, so sources from different databases never
|
|
196
|
+
* collide on prepared-statement names, and healing done by one source
|
|
197
|
+
* benefits every other source on the same pool. */
|
|
198
|
+
export class PlanRetryRunner {
|
|
199
|
+
private readonly plans = new PlanInvalidations()
|
|
200
|
+
|
|
201
|
+
/** Run a non-transactional operation on one pinned pooled connection,
|
|
202
|
+
* retrying it exactly once — same connection, statements freshly prepared —
|
|
203
|
+
* when a cached plan fails to revalidate. A run body must not call a
|
|
204
|
+
* public store method (reserving inside a reservation yields a brand-new
|
|
205
|
+
* connection instead of the pinned one) and must execute its statements
|
|
206
|
+
* sequentially: failure attribution (`Attempt.inFlight`) reads the
|
|
207
|
+
* statement most recently dispatched. */
|
|
208
|
+
async run<T>(pool: SQL, body: (q: Exec) => Promise<T>): Promise<T> {
|
|
209
|
+
const conn = await pool.reserve()
|
|
210
|
+
try {
|
|
211
|
+
const attempt = attemptExec(conn, this.plans)
|
|
212
|
+
try {
|
|
213
|
+
return await body(attempt.exec)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!isPlanChangeError(error) || attempt.inFlight === null) throw error
|
|
216
|
+
// The failing statement's plan was invalidated once more: memoize a
|
|
217
|
+
// fresh marked variant for its text. The retry then re-prepares
|
|
218
|
+
// *every* statement the body executes — a migration can have
|
|
219
|
+
// poisoned any `*`-returning plan the body touches, not only the
|
|
220
|
+
// one that failed first, and a memoized marker minted before the
|
|
221
|
+
// migration is itself stale — so a body reading two
|
|
222
|
+
// migration-extended tables recovers in the single retry.
|
|
223
|
+
const failing = attempt.inFlight
|
|
224
|
+
this.plans.invalidate(failing)
|
|
225
|
+
return await body(attemptExec(conn, this.plans, true).exec)
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
conn.release()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Run a transactional operation on one pinned pooled connection, retrying
|
|
233
|
+
* the whole body exactly once on a plan-change error: the failed attempt
|
|
234
|
+
* rolls back, and the body re-runs in a fresh transaction on the same
|
|
235
|
+
* connection with freshly prepared statements. */
|
|
236
|
+
async tx<T>(pool: SQL, body: (q: Exec) => Promise<T>): Promise<T> {
|
|
237
|
+
const conn = await pool.reserve()
|
|
238
|
+
try {
|
|
239
|
+
// Attribution lives outside `begin`: the transaction executor only
|
|
240
|
+
// exists inside the callback, so each attempt builds its own exec and
|
|
241
|
+
// reports the failing statement's text back here on rejection.
|
|
242
|
+
let failing: string | null = null
|
|
243
|
+
try {
|
|
244
|
+
return await conn.begin((t) => {
|
|
245
|
+
const attempt = attemptExec(t, this.plans)
|
|
246
|
+
return body(attempt.exec).catch((error) => {
|
|
247
|
+
failing = attempt.inFlight
|
|
248
|
+
throw error
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (!isPlanChangeError(error) || failing === null) throw error
|
|
253
|
+
this.plans.invalidate(failing)
|
|
254
|
+
return await conn.begin((t) => body(attemptExec(t, this.plans, true).exec))
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
conn.release()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|