@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 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.8.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
  },
@@ -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
- !marker ||
159
- Number(marker.version) !== AUTH_SCHEMA_VERSION ||
160
- marker.checksum !== AUTH_SCHEMA_CHECKSUM
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
- 'PostgreSQL auth schema marker is incompatible; run the migration from this release',
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
@@ -19,6 +19,7 @@ export {
19
19
  MIGRATE_COMMAND,
20
20
  SCHEMA_CHECKSUM,
21
21
  SCHEMA_VERSION,
22
+ SNAPSHOT_SCHEMA_DDL,
22
23
  TICKET_SCHEMA_CHECKSUM,
23
24
  TICKET_SCHEMA_VERSION,
24
25
  assertSchema,
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
+ }