@defrex/autobuild-postgres-store 0.8.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 +63 -0
- package/package.json +37 -0
- package/src/auth-schema.ts +238 -0
- package/src/bin.ts +17 -0
- package/src/blobs.test.ts +121 -0
- package/src/config.test.ts +174 -0
- package/src/config.ts +152 -0
- package/src/env.test.ts +48 -0
- package/src/env.ts +31 -0
- package/src/event-wait-poll-ms.test.ts +23 -0
- package/src/index.ts +41 -0
- package/src/migrate.test.ts +640 -0
- package/src/s3.ts +45 -0
- package/src/schema.ts +1099 -0
- package/src/store.live.test.ts +453 -0
- package/src/store.ts +1176 -0
- package/src/testing/concurrent-worker.ts +42 -0
- package/src/tickets.live.test.ts +49 -0
- package/src/tickets.ts +276 -0
- package/src/vercel.ts +53 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import {
|
|
3
|
+
AUTH_SCHEMA_CHECKSUM,
|
|
4
|
+
AUTH_SCHEMA_DDL,
|
|
5
|
+
AUTH_SCHEMA_V1_CHECKSUM,
|
|
6
|
+
AUTH_SCHEMA_V2_CHECKSUM,
|
|
7
|
+
AUTH_SCHEMA_VERSION,
|
|
8
|
+
assertAuthSchema,
|
|
9
|
+
} from './auth-schema'
|
|
10
|
+
|
|
11
|
+
export const SCHEMA_VERSION = 6
|
|
12
|
+
export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
|
|
13
|
+
|
|
14
|
+
/** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
|
|
15
|
+
* recognized and upgraded in place (see migratePostgres). */
|
|
16
|
+
export const SCHEMA_V3_DDL = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
18
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
19
|
+
version integer NOT NULL,
|
|
20
|
+
checksum text NOT NULL,
|
|
21
|
+
applied_at timestamptz NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
24
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
25
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
26
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
27
|
+
heartbeat_at timestamptz
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
30
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
31
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
32
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
35
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
36
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
37
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
38
|
+
PRIMARY KEY (build, kind, revision)
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
41
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
42
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
43
|
+
heartbeat_at timestamptz
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
46
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
47
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
48
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
51
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
52
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
53
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
54
|
+
PRIMARY KEY (repo, kind, revision)
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
57
|
+
id text PRIMARY KEY,
|
|
58
|
+
repo text NOT NULL,
|
|
59
|
+
operator text NOT NULL,
|
|
60
|
+
title text,
|
|
61
|
+
created_at timestamptz NOT NULL,
|
|
62
|
+
updated_at timestamptz NOT NULL
|
|
63
|
+
);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
65
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
66
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
67
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
|
|
68
|
+
);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS session_artifacts (
|
|
70
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
71
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
72
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
73
|
+
PRIMARY KEY (session, kind, revision)
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
76
|
+
id text PRIMARY KEY, scope_kind text NOT NULL,
|
|
77
|
+
build text REFERENCES builds(slug) ON DELETE CASCADE,
|
|
78
|
+
repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
79
|
+
label text NOT NULL, format text NOT NULL, status text NOT NULL,
|
|
80
|
+
outcome text, artifact_kind text, artifact_revision bigint,
|
|
81
|
+
artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
|
|
82
|
+
session text REFERENCES sessions(id) ON DELETE CASCADE,
|
|
83
|
+
CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
|
|
84
|
+
CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
|
|
85
|
+
CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
86
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
87
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
88
|
+
OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
92
|
+
stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
93
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
|
|
94
|
+
PRIMARY KEY (stream, seq)
|
|
95
|
+
);`.trim()
|
|
96
|
+
export const SCHEMA_V3_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V3_DDL).digest('hex')
|
|
97
|
+
|
|
98
|
+
/** The frozen v2 DDL, kept verbatim so a deployed v2 marker's checksum can be
|
|
99
|
+
* recognized and upgraded in place (see migratePostgres). */
|
|
100
|
+
export const SCHEMA_V2_DDL = `
|
|
101
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
102
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
103
|
+
version integer NOT NULL,
|
|
104
|
+
checksum text NOT NULL,
|
|
105
|
+
applied_at timestamptz NOT NULL
|
|
106
|
+
);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
108
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
109
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
110
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
111
|
+
heartbeat_at timestamptz
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
114
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
115
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
116
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
117
|
+
);
|
|
118
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
119
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
120
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
121
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
122
|
+
PRIMARY KEY (build, kind, revision)
|
|
123
|
+
);
|
|
124
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
125
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
126
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
127
|
+
heartbeat_at timestamptz
|
|
128
|
+
);
|
|
129
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
130
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
131
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
132
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
133
|
+
);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
135
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
136
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
137
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
138
|
+
PRIMARY KEY (repo, kind, revision)
|
|
139
|
+
);
|
|
140
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
141
|
+
id text PRIMARY KEY, scope_kind text NOT NULL,
|
|
142
|
+
build text REFERENCES builds(slug) ON DELETE CASCADE,
|
|
143
|
+
repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
144
|
+
label text NOT NULL, format text NOT NULL, status text NOT NULL,
|
|
145
|
+
outcome text, artifact_kind text, artifact_revision bigint,
|
|
146
|
+
artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
|
|
147
|
+
CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo')),
|
|
148
|
+
CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
|
|
149
|
+
CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
150
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL)
|
|
151
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL)
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
155
|
+
stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
156
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
|
|
157
|
+
PRIMARY KEY (stream, seq)
|
|
158
|
+
);`.trim()
|
|
159
|
+
export const SCHEMA_V2_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V2_DDL).digest('hex')
|
|
160
|
+
|
|
161
|
+
/** The v1 DDL, kept verbatim so a deployed v1 marker's checksum can be
|
|
162
|
+
* recognized and upgraded in place (see migratePostgres). */
|
|
163
|
+
export const SCHEMA_V1_DDL = `
|
|
164
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
165
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
166
|
+
version integer NOT NULL,
|
|
167
|
+
checksum text NOT NULL,
|
|
168
|
+
applied_at timestamptz NOT NULL
|
|
169
|
+
);
|
|
170
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
171
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
172
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
173
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
174
|
+
heartbeat_at timestamptz
|
|
175
|
+
);
|
|
176
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
177
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
178
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
179
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
180
|
+
);
|
|
181
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
182
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
183
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
184
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
185
|
+
PRIMARY KEY (build, kind, revision)
|
|
186
|
+
);
|
|
187
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
188
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
189
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
190
|
+
heartbeat_at timestamptz
|
|
191
|
+
);
|
|
192
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
193
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
194
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
195
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
196
|
+
);
|
|
197
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
198
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
199
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
200
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
201
|
+
PRIMARY KEY (repo, kind, revision)
|
|
202
|
+
);`.trim()
|
|
203
|
+
export const SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V1_DDL).digest('hex')
|
|
204
|
+
|
|
205
|
+
/** The frozen v4 DDL, kept verbatim so a deployed v4 marker's checksum can be
|
|
206
|
+
* recognized and upgraded in place (see migratePostgres). */
|
|
207
|
+
export const SCHEMA_V4_DDL = `
|
|
208
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
209
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
210
|
+
version integer NOT NULL,
|
|
211
|
+
checksum text NOT NULL,
|
|
212
|
+
applied_at timestamptz NOT NULL
|
|
213
|
+
);
|
|
214
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
215
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
216
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
217
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
218
|
+
heartbeat_at timestamptz
|
|
219
|
+
);
|
|
220
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
221
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
222
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
223
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
224
|
+
);
|
|
225
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
226
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
227
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
228
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
229
|
+
PRIMARY KEY (build, kind, revision)
|
|
230
|
+
);
|
|
231
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
232
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
233
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
234
|
+
heartbeat_at timestamptz
|
|
235
|
+
);
|
|
236
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
237
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
238
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
239
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
240
|
+
);
|
|
241
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
242
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
243
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
244
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
245
|
+
PRIMARY KEY (repo, kind, revision)
|
|
246
|
+
);
|
|
247
|
+
CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
|
|
248
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
249
|
+
id text PRIMARY KEY,
|
|
250
|
+
repo text NOT NULL,
|
|
251
|
+
operator text NOT NULL,
|
|
252
|
+
title text,
|
|
253
|
+
created_at timestamptz NOT NULL,
|
|
254
|
+
updated_at timestamptz NOT NULL,
|
|
255
|
+
creation_seq bigint NOT NULL
|
|
256
|
+
);
|
|
257
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
258
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
259
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
260
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
|
|
261
|
+
);
|
|
262
|
+
CREATE TABLE IF NOT EXISTS session_artifacts (
|
|
263
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
264
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
265
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
266
|
+
PRIMARY KEY (session, kind, revision)
|
|
267
|
+
);
|
|
268
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
269
|
+
id text PRIMARY KEY, scope_kind text NOT NULL,
|
|
270
|
+
build text REFERENCES builds(slug) ON DELETE CASCADE,
|
|
271
|
+
repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
272
|
+
label text NOT NULL, format text NOT NULL, status text NOT NULL,
|
|
273
|
+
outcome text, artifact_kind text, artifact_revision bigint,
|
|
274
|
+
artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
|
|
275
|
+
session text REFERENCES sessions(id) ON DELETE CASCADE,
|
|
276
|
+
CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
|
|
277
|
+
CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
|
|
278
|
+
CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
279
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
280
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
281
|
+
OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
285
|
+
stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
286
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
|
|
287
|
+
PRIMARY KEY (stream, seq)
|
|
288
|
+
);`.trim()
|
|
289
|
+
export const SCHEMA_V4_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V4_DDL).digest('hex')
|
|
290
|
+
|
|
291
|
+
/** The frozen v5 DDL, kept verbatim so a deployed v5 marker's checksum can be
|
|
292
|
+
* recognized and upgraded in place (see migratePostgres). v5 is the shape the
|
|
293
|
+
* hosted service ran when the streams.creation_seq tiebreak landed without a
|
|
294
|
+
* version bump; every deploy after that failed the marker check until v6. */
|
|
295
|
+
export const SCHEMA_V5_DDL = `
|
|
296
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
297
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
298
|
+
version integer NOT NULL,
|
|
299
|
+
checksum text NOT NULL,
|
|
300
|
+
applied_at timestamptz NOT NULL
|
|
301
|
+
);
|
|
302
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
303
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
304
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
305
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
306
|
+
heartbeat_at timestamptz, repo_origin text
|
|
307
|
+
);
|
|
308
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
309
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
310
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
311
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
312
|
+
);
|
|
313
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
314
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
315
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
316
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
317
|
+
PRIMARY KEY (build, kind, revision)
|
|
318
|
+
);
|
|
319
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
320
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
321
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
322
|
+
heartbeat_at timestamptz
|
|
323
|
+
);
|
|
324
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
325
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
326
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
327
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
328
|
+
);
|
|
329
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
330
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
331
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
332
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
333
|
+
PRIMARY KEY (repo, kind, revision)
|
|
334
|
+
);
|
|
335
|
+
CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
|
|
336
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
337
|
+
id text PRIMARY KEY,
|
|
338
|
+
repo text NOT NULL,
|
|
339
|
+
operator text NOT NULL,
|
|
340
|
+
title text,
|
|
341
|
+
created_at timestamptz NOT NULL,
|
|
342
|
+
updated_at timestamptz NOT NULL,
|
|
343
|
+
creation_seq bigint NOT NULL
|
|
344
|
+
);
|
|
345
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
346
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
347
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
348
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
|
|
349
|
+
);
|
|
350
|
+
CREATE TABLE IF NOT EXISTS session_artifacts (
|
|
351
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
352
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
353
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
354
|
+
PRIMARY KEY (session, kind, revision)
|
|
355
|
+
);
|
|
356
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
357
|
+
id text PRIMARY KEY, scope_kind text NOT NULL,
|
|
358
|
+
build text REFERENCES builds(slug) ON DELETE CASCADE,
|
|
359
|
+
repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
360
|
+
label text NOT NULL, format text NOT NULL, status text NOT NULL,
|
|
361
|
+
outcome text, artifact_kind text, artifact_revision bigint,
|
|
362
|
+
artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
|
|
363
|
+
session text REFERENCES sessions(id) ON DELETE CASCADE,
|
|
364
|
+
CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
|
|
365
|
+
CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
|
|
366
|
+
CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
367
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
368
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
369
|
+
OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
373
|
+
stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
374
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
|
|
375
|
+
PRIMARY KEY (stream, seq)
|
|
376
|
+
);`.trim()
|
|
377
|
+
export const SCHEMA_V5_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V5_DDL).digest('hex')
|
|
378
|
+
|
|
379
|
+
export const SCHEMA_DDL = `
|
|
380
|
+
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
381
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
382
|
+
version integer NOT NULL,
|
|
383
|
+
checksum text NOT NULL,
|
|
384
|
+
applied_at timestamptz NOT NULL
|
|
385
|
+
);
|
|
386
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
387
|
+
slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
|
|
388
|
+
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
389
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
390
|
+
heartbeat_at timestamptz, repo_origin text
|
|
391
|
+
);
|
|
392
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
393
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
394
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
395
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
|
|
396
|
+
);
|
|
397
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
398
|
+
build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
|
|
399
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
400
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
401
|
+
PRIMARY KEY (build, kind, revision)
|
|
402
|
+
);
|
|
403
|
+
CREATE TABLE IF NOT EXISTS repo_streams (
|
|
404
|
+
repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
405
|
+
lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
|
|
406
|
+
heartbeat_at timestamptz
|
|
407
|
+
);
|
|
408
|
+
CREATE TABLE IF NOT EXISTS repo_events (
|
|
409
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
410
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
411
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
|
|
412
|
+
);
|
|
413
|
+
CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
414
|
+
repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
415
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
416
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
417
|
+
PRIMARY KEY (repo, kind, revision)
|
|
418
|
+
);
|
|
419
|
+
CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
|
|
420
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
421
|
+
id text PRIMARY KEY,
|
|
422
|
+
repo text NOT NULL,
|
|
423
|
+
operator text NOT NULL,
|
|
424
|
+
title text,
|
|
425
|
+
created_at timestamptz NOT NULL,
|
|
426
|
+
updated_at timestamptz NOT NULL,
|
|
427
|
+
creation_seq bigint NOT NULL
|
|
428
|
+
);
|
|
429
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
430
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
431
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
|
|
432
|
+
type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
|
|
433
|
+
);
|
|
434
|
+
CREATE TABLE IF NOT EXISTS session_artifacts (
|
|
435
|
+
session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
436
|
+
kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
|
|
437
|
+
metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
|
|
438
|
+
PRIMARY KEY (session, kind, revision)
|
|
439
|
+
);
|
|
440
|
+
CREATE SEQUENCE IF NOT EXISTS streams_creation_seq;
|
|
441
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
442
|
+
id text PRIMARY KEY, scope_kind text NOT NULL,
|
|
443
|
+
build text REFERENCES builds(slug) ON DELETE CASCADE,
|
|
444
|
+
repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
|
|
445
|
+
label text NOT NULL, format text NOT NULL, status text NOT NULL,
|
|
446
|
+
outcome text, artifact_kind text, artifact_revision bigint,
|
|
447
|
+
artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
|
|
448
|
+
session text REFERENCES sessions(id) ON DELETE CASCADE,
|
|
449
|
+
creation_seq bigint NOT NULL,
|
|
450
|
+
CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
|
|
451
|
+
CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
|
|
452
|
+
CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
453
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
454
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
455
|
+
OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
456
|
+
)
|
|
457
|
+
);
|
|
458
|
+
CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
459
|
+
stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
460
|
+
seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
|
|
461
|
+
PRIMARY KEY (stream, seq)
|
|
462
|
+
);`.trim()
|
|
463
|
+
|
|
464
|
+
export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
|
|
465
|
+
|
|
466
|
+
/** Ticket persistence evolves independently so deployed v1 BuildStore markers
|
|
467
|
+
* remain valid while the hosted ticket tables are added. */
|
|
468
|
+
export const TICKET_SCHEMA_VERSION = 1
|
|
469
|
+
export const TICKET_SCHEMA_DDL = `
|
|
470
|
+
CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
|
|
471
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
472
|
+
version integer NOT NULL, checksum text NOT NULL, applied_at timestamptz NOT NULL
|
|
473
|
+
);
|
|
474
|
+
CREATE TABLE IF NOT EXISTS ab_tickets (
|
|
475
|
+
team text NOT NULL, id text NOT NULL, creation_key text,
|
|
476
|
+
title text NOT NULL, body text NOT NULL, state text NOT NULL,
|
|
477
|
+
labels text[] NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL,
|
|
478
|
+
updated_at timestamptz NOT NULL,
|
|
479
|
+
PRIMARY KEY (team, id), UNIQUE (team, creation_key)
|
|
480
|
+
);
|
|
481
|
+
CREATE TABLE IF NOT EXISTS ab_ticket_comments (
|
|
482
|
+
team text NOT NULL, ticket_id text NOT NULL, seq bigint NOT NULL,
|
|
483
|
+
body text NOT NULL, created_at timestamptz NOT NULL,
|
|
484
|
+
PRIMARY KEY (team, ticket_id, seq),
|
|
485
|
+
FOREIGN KEY (team, ticket_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE
|
|
486
|
+
);
|
|
487
|
+
CREATE TABLE IF NOT EXISTS ab_ticket_blockers (
|
|
488
|
+
team text NOT NULL, ticket_id text NOT NULL, blocker_id text NOT NULL,
|
|
489
|
+
PRIMARY KEY (team, ticket_id, blocker_id),
|
|
490
|
+
FOREIGN KEY (team, ticket_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
|
|
491
|
+
FOREIGN KEY (team, blocker_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
|
|
492
|
+
CHECK (ticket_id <> blocker_id)
|
|
493
|
+
);`.trim()
|
|
494
|
+
export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
495
|
+
.update(TICKET_SCHEMA_DDL)
|
|
496
|
+
.digest('hex')
|
|
497
|
+
|
|
498
|
+
type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
|
|
499
|
+
|
|
500
|
+
const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
501
|
+
ab_schema_migrations: [
|
|
502
|
+
['singleton', 'boolean', true, 'true'],
|
|
503
|
+
['version', 'integer', true],
|
|
504
|
+
['checksum', 'text', true],
|
|
505
|
+
['applied_at', 'timestamp with time zone', true],
|
|
506
|
+
],
|
|
507
|
+
builds: [
|
|
508
|
+
['slug', 'text', true],
|
|
509
|
+
['repo', 'text', true],
|
|
510
|
+
['ticket', 'jsonb', false],
|
|
511
|
+
['branch', 'text', false],
|
|
512
|
+
['created_at', 'timestamp with time zone', true],
|
|
513
|
+
['updated_at', 'timestamp with time zone', true],
|
|
514
|
+
['lease_holder', 'text', false],
|
|
515
|
+
['lease_expires_at', 'timestamp with time zone', false],
|
|
516
|
+
['lease_ttl_ms', 'bigint', false],
|
|
517
|
+
['heartbeat_at', 'timestamp with time zone', false],
|
|
518
|
+
// The repo_origin column rides last: the guarded v4→v5 ALTER adds it at
|
|
519
|
+
// the end, so migrated and fresh databases assert identically (the
|
|
520
|
+
// sessions.creation_seq and streams.session comments above).
|
|
521
|
+
['repo_origin', 'text', false],
|
|
522
|
+
],
|
|
523
|
+
events: [
|
|
524
|
+
['build', 'text', true],
|
|
525
|
+
['seq', 'bigint', true],
|
|
526
|
+
['ts', 'timestamp with time zone', true],
|
|
527
|
+
['actor', 'jsonb', true],
|
|
528
|
+
['type', 'text', true],
|
|
529
|
+
['payload', 'jsonb', true],
|
|
530
|
+
],
|
|
531
|
+
artifacts: [
|
|
532
|
+
['build', 'text', true],
|
|
533
|
+
['kind', 'text', true],
|
|
534
|
+
['revision', 'bigint', true],
|
|
535
|
+
['blob_ref', 'text', true],
|
|
536
|
+
['metadata', 'jsonb', true],
|
|
537
|
+
['created_at', 'timestamp with time zone', true],
|
|
538
|
+
],
|
|
539
|
+
repo_streams: [
|
|
540
|
+
['repo', 'text', true],
|
|
541
|
+
['created_at', 'timestamp with time zone', true],
|
|
542
|
+
['updated_at', 'timestamp with time zone', true],
|
|
543
|
+
['lease_holder', 'text', false],
|
|
544
|
+
['lease_expires_at', 'timestamp with time zone', false],
|
|
545
|
+
['lease_ttl_ms', 'bigint', false],
|
|
546
|
+
['heartbeat_at', 'timestamp with time zone', false],
|
|
547
|
+
],
|
|
548
|
+
repo_events: [
|
|
549
|
+
['repo', 'text', true],
|
|
550
|
+
['seq', 'bigint', true],
|
|
551
|
+
['ts', 'timestamp with time zone', true],
|
|
552
|
+
['actor', 'jsonb', true],
|
|
553
|
+
['type', 'text', true],
|
|
554
|
+
['payload', 'jsonb', true],
|
|
555
|
+
],
|
|
556
|
+
repo_artifacts: [
|
|
557
|
+
['repo', 'text', true],
|
|
558
|
+
['kind', 'text', true],
|
|
559
|
+
['revision', 'bigint', true],
|
|
560
|
+
['blob_ref', 'text', true],
|
|
561
|
+
['metadata', 'jsonb', true],
|
|
562
|
+
['created_at', 'timestamp with time zone', true],
|
|
563
|
+
],
|
|
564
|
+
sessions: [
|
|
565
|
+
['id', 'text', true],
|
|
566
|
+
['repo', 'text', true],
|
|
567
|
+
['operator', 'text', true],
|
|
568
|
+
['title', 'text', false],
|
|
569
|
+
['created_at', 'timestamp with time zone', true],
|
|
570
|
+
['updated_at', 'timestamp with time zone', true],
|
|
571
|
+
// The creation-order tiebreak column rides last: the guarded v3→v4 ALTER
|
|
572
|
+
// adds it at the end, so migrated and fresh databases assert identically
|
|
573
|
+
// (the streams.session precedent).
|
|
574
|
+
['creation_seq', 'bigint', true],
|
|
575
|
+
],
|
|
576
|
+
session_events: [
|
|
577
|
+
['session', 'text', true],
|
|
578
|
+
['seq', 'bigint', true],
|
|
579
|
+
['ts', 'timestamp with time zone', true],
|
|
580
|
+
['actor', 'jsonb', true],
|
|
581
|
+
['type', 'text', true],
|
|
582
|
+
['payload', 'jsonb', true],
|
|
583
|
+
],
|
|
584
|
+
session_artifacts: [
|
|
585
|
+
['session', 'text', true],
|
|
586
|
+
['kind', 'text', true],
|
|
587
|
+
['revision', 'bigint', true],
|
|
588
|
+
['blob_ref', 'text', true],
|
|
589
|
+
['metadata', 'jsonb', true],
|
|
590
|
+
['created_at', 'timestamp with time zone', true],
|
|
591
|
+
],
|
|
592
|
+
streams: [
|
|
593
|
+
['id', 'text', true],
|
|
594
|
+
['scope_kind', 'text', true],
|
|
595
|
+
['build', 'text', false],
|
|
596
|
+
['repo', 'text', false],
|
|
597
|
+
['label', 'text', true],
|
|
598
|
+
['format', 'text', true],
|
|
599
|
+
['status', 'text', true],
|
|
600
|
+
['outcome', 'text', false],
|
|
601
|
+
['artifact_kind', 'text', false],
|
|
602
|
+
['artifact_revision', 'bigint', false],
|
|
603
|
+
['artifact_blob_ref', 'text', false],
|
|
604
|
+
['created_at', 'timestamp with time zone', true],
|
|
605
|
+
['closed_at', 'timestamp with time zone', false],
|
|
606
|
+
// The session column rides last-but-one and the creation-order tiebreak
|
|
607
|
+
// column rides last: the guarded v2→v3 ALTER adds session at the end and
|
|
608
|
+
// the guarded tiebreak ALTERs append creation_seq after it, so migrated
|
|
609
|
+
// and fresh databases assert identically.
|
|
610
|
+
['session', 'text', false],
|
|
611
|
+
['creation_seq', 'bigint', true],
|
|
612
|
+
],
|
|
613
|
+
stream_chunks: [
|
|
614
|
+
['stream', 'text', true],
|
|
615
|
+
['seq', 'bigint', true],
|
|
616
|
+
['ts', 'timestamp with time zone', true],
|
|
617
|
+
['parts', 'jsonb', true],
|
|
618
|
+
],
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const EXPECTED_CONSTRAINTS = [
|
|
622
|
+
// CHECK constraints are compared by presence only (their pg_get_expr text
|
|
623
|
+
// is version-sensitive); PK/FK rows compare fully.
|
|
624
|
+
'ab_schema_migrations|c|||||',
|
|
625
|
+
'ab_schema_migrations|p|singleton||||',
|
|
626
|
+
'artifacts|f|build|builds|slug|c|',
|
|
627
|
+
'artifacts|p|build,kind,revision||||',
|
|
628
|
+
'builds|p|slug||||',
|
|
629
|
+
'events|f|build|builds|slug|c|',
|
|
630
|
+
'events|p|build,seq||||',
|
|
631
|
+
'repo_artifacts|f|repo|repo_streams|repo|c|',
|
|
632
|
+
'repo_artifacts|p|repo,kind,revision||||',
|
|
633
|
+
'repo_events|f|repo|repo_streams|repo|c|',
|
|
634
|
+
'repo_events|p|repo,seq||||',
|
|
635
|
+
'repo_streams|p|repo||||',
|
|
636
|
+
'session_artifacts|f|session|sessions|id|c|',
|
|
637
|
+
'session_artifacts|p|session,kind,revision||||',
|
|
638
|
+
'session_events|f|session|sessions|id|c|',
|
|
639
|
+
'session_events|p|session,seq||||',
|
|
640
|
+
'sessions|p|id||||',
|
|
641
|
+
'stream_chunks|f|stream|streams|id|c|',
|
|
642
|
+
'stream_chunks|p|stream,seq||||',
|
|
643
|
+
'streams|c|||||',
|
|
644
|
+
'streams|c|||||',
|
|
645
|
+
'streams|c|||||',
|
|
646
|
+
'streams|f|build|builds|slug|c|',
|
|
647
|
+
'streams|f|repo|repo_streams|repo|c|',
|
|
648
|
+
'streams|f|session|sessions|id|c|',
|
|
649
|
+
'streams|p|id||||',
|
|
650
|
+
] as const
|
|
651
|
+
|
|
652
|
+
export function schemaError(detail: string): Error {
|
|
653
|
+
return new Error(`PostgreSQL BuildStore schema ${detail}; run: ${MIGRATE_COMMAND}`)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
interface CatalogColumn {
|
|
657
|
+
table_name: string
|
|
658
|
+
column_name: string
|
|
659
|
+
formatted_type: string
|
|
660
|
+
not_null: boolean
|
|
661
|
+
default_expression: string | null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
interface CatalogConstraint {
|
|
665
|
+
table_name: string
|
|
666
|
+
constraint_type: string
|
|
667
|
+
columns: unknown
|
|
668
|
+
referenced_table: string | null
|
|
669
|
+
referenced_columns: unknown
|
|
670
|
+
delete_action: string
|
|
671
|
+
check_expression: string | null
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function stringArray(value: unknown): string[] {
|
|
675
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) return value
|
|
676
|
+
throw schemaError('catalog shape is unreadable')
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function assertCatalogShape(sql: SQL): Promise<void> {
|
|
680
|
+
const columns: CatalogColumn[] = await sql`
|
|
681
|
+
SELECT c.relname AS table_name, a.attname AS column_name,
|
|
682
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS formatted_type,
|
|
683
|
+
a.attnotnull AS not_null,
|
|
684
|
+
CASE WHEN ad.oid IS NULL THEN NULL ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) END
|
|
685
|
+
AS default_expression
|
|
686
|
+
FROM pg_catalog.pg_class c
|
|
687
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
688
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
|
|
689
|
+
LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
|
|
690
|
+
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
|
|
691
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
692
|
+
ORDER BY c.relname, a.attnum`
|
|
693
|
+
|
|
694
|
+
for (const [table, expected] of Object.entries(EXPECTED_COLUMNS)) {
|
|
695
|
+
const actual = columns
|
|
696
|
+
.filter((column) => column.table_name === table)
|
|
697
|
+
.map((column) => [
|
|
698
|
+
column.column_name,
|
|
699
|
+
column.formatted_type,
|
|
700
|
+
column.not_null,
|
|
701
|
+
column.default_expression ?? undefined,
|
|
702
|
+
])
|
|
703
|
+
const normalizedExpected = expected.map(([name, type, notNull, defaultValue]) => [
|
|
704
|
+
name,
|
|
705
|
+
type,
|
|
706
|
+
notNull,
|
|
707
|
+
defaultValue,
|
|
708
|
+
])
|
|
709
|
+
if (JSON.stringify(actual) !== JSON.stringify(normalizedExpected)) {
|
|
710
|
+
throw schemaError(`table ${table} is missing or mismatched`)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const constraints: CatalogConstraint[] = await sql`
|
|
715
|
+
SELECT owner.relname AS table_name, con.contype AS constraint_type,
|
|
716
|
+
to_jsonb(ARRAY(
|
|
717
|
+
SELECT attribute.attname
|
|
718
|
+
FROM unnest(con.conkey) WITH ORDINALITY AS key(attnum, position)
|
|
719
|
+
JOIN pg_catalog.pg_attribute attribute
|
|
720
|
+
ON attribute.attrelid = con.conrelid AND attribute.attnum = key.attnum
|
|
721
|
+
ORDER BY key.position
|
|
722
|
+
)) AS columns,
|
|
723
|
+
referenced.relname AS referenced_table,
|
|
724
|
+
to_jsonb(ARRAY(
|
|
725
|
+
SELECT attribute.attname
|
|
726
|
+
FROM unnest(con.confkey) WITH ORDINALITY AS key(attnum, position)
|
|
727
|
+
JOIN pg_catalog.pg_attribute attribute
|
|
728
|
+
ON attribute.attrelid = con.confrelid AND attribute.attnum = key.attnum
|
|
729
|
+
ORDER BY key.position
|
|
730
|
+
)) AS referenced_columns,
|
|
731
|
+
con.confdeltype AS delete_action,
|
|
732
|
+
CASE WHEN con.contype = 'c' THEN pg_catalog.pg_get_expr(con.conbin, con.conrelid) ELSE NULL END
|
|
733
|
+
AS check_expression
|
|
734
|
+
FROM pg_catalog.pg_constraint con
|
|
735
|
+
JOIN pg_catalog.pg_class owner ON owner.oid = con.conrelid
|
|
736
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = owner.relnamespace
|
|
737
|
+
LEFT JOIN pg_catalog.pg_class referenced ON referenced.oid = con.confrelid
|
|
738
|
+
WHERE n.nspname = current_schema() AND con.contype IN ('p', 'f', 'c')`
|
|
739
|
+
const expectedTables = new Set(Object.keys(EXPECTED_COLUMNS))
|
|
740
|
+
const actualConstraints = constraints
|
|
741
|
+
.filter((constraint) => expectedTables.has(constraint.table_name))
|
|
742
|
+
.map((constraint) =>
|
|
743
|
+
// CHECK constraints are asserted by presence; their pg_get_expr text
|
|
744
|
+
// varies across PostgreSQL versions and is not design-critical.
|
|
745
|
+
constraint.constraint_type === 'c'
|
|
746
|
+
? [constraint.table_name, 'c', '', '', '', '', ''].join('|')
|
|
747
|
+
: [
|
|
748
|
+
constraint.table_name,
|
|
749
|
+
constraint.constraint_type,
|
|
750
|
+
stringArray(constraint.columns).join(','),
|
|
751
|
+
constraint.referenced_table ?? '',
|
|
752
|
+
stringArray(constraint.referenced_columns).join(','),
|
|
753
|
+
constraint.constraint_type === 'f' ? constraint.delete_action : '',
|
|
754
|
+
constraint.check_expression ?? '',
|
|
755
|
+
].join('|'),
|
|
756
|
+
)
|
|
757
|
+
.sort()
|
|
758
|
+
if (JSON.stringify(actualConstraints) !== JSON.stringify(EXPECTED_CONSTRAINTS)) {
|
|
759
|
+
throw schemaError('constraints are missing or mismatched')
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export async function assertSchema(sql: SQL): Promise<void> {
|
|
764
|
+
let rows: { version: number; checksum: string }[]
|
|
765
|
+
try {
|
|
766
|
+
rows = await sql`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true`
|
|
767
|
+
} catch (error) {
|
|
768
|
+
const code =
|
|
769
|
+
(error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
|
|
770
|
+
if (code === '42P01') throw schemaError('is missing')
|
|
771
|
+
throw error
|
|
772
|
+
}
|
|
773
|
+
const marker = rows[0]
|
|
774
|
+
if (!marker) throw schemaError('marker is missing')
|
|
775
|
+
if (Number(marker.version) !== SCHEMA_VERSION) {
|
|
776
|
+
throw schemaError(`version ${marker.version} does not match required version ${SCHEMA_VERSION}`)
|
|
777
|
+
}
|
|
778
|
+
if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('checksum is mismatched')
|
|
779
|
+
await assertCatalogShape(sql)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
783
|
+
let rows: { version: number; checksum: string }[]
|
|
784
|
+
try {
|
|
785
|
+
rows =
|
|
786
|
+
await sql`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true`
|
|
787
|
+
} catch (error) {
|
|
788
|
+
const code =
|
|
789
|
+
(error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
|
|
790
|
+
if (code === '42P01') throw schemaError('ticket tables are missing')
|
|
791
|
+
throw error
|
|
792
|
+
}
|
|
793
|
+
const marker = rows[0]
|
|
794
|
+
if (!marker) throw schemaError('ticket marker is missing')
|
|
795
|
+
if (
|
|
796
|
+
Number(marker.version) !== TICKET_SCHEMA_VERSION ||
|
|
797
|
+
marker.checksum !== TICKET_SCHEMA_CHECKSUM
|
|
798
|
+
) {
|
|
799
|
+
throw schemaError('ticket marker is incompatible')
|
|
800
|
+
}
|
|
801
|
+
const expected: Record<string, Array<[string, string, boolean]>> = {
|
|
802
|
+
ab_ticket_schema_migrations: [
|
|
803
|
+
['singleton', 'boolean', true],
|
|
804
|
+
['version', 'integer', true],
|
|
805
|
+
['checksum', 'text', true],
|
|
806
|
+
['applied_at', 'timestamp with time zone', true],
|
|
807
|
+
],
|
|
808
|
+
ab_tickets: [
|
|
809
|
+
['team', 'text', true],
|
|
810
|
+
['id', 'text', true],
|
|
811
|
+
['creation_key', 'text', false],
|
|
812
|
+
['title', 'text', true],
|
|
813
|
+
['body', 'text', true],
|
|
814
|
+
['state', 'text', true],
|
|
815
|
+
['labels', 'text[]', true],
|
|
816
|
+
['created_at', 'timestamp with time zone', true],
|
|
817
|
+
['updated_at', 'timestamp with time zone', true],
|
|
818
|
+
],
|
|
819
|
+
ab_ticket_comments: [
|
|
820
|
+
['team', 'text', true],
|
|
821
|
+
['ticket_id', 'text', true],
|
|
822
|
+
['seq', 'bigint', true],
|
|
823
|
+
['body', 'text', true],
|
|
824
|
+
['created_at', 'timestamp with time zone', true],
|
|
825
|
+
],
|
|
826
|
+
ab_ticket_blockers: [
|
|
827
|
+
['team', 'text', true],
|
|
828
|
+
['ticket_id', 'text', true],
|
|
829
|
+
['blocker_id', 'text', true],
|
|
830
|
+
],
|
|
831
|
+
}
|
|
832
|
+
const columns: CatalogColumn[] = await sql`
|
|
833
|
+
SELECT c.relname AS table_name, a.attname AS column_name,
|
|
834
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS formatted_type,
|
|
835
|
+
a.attnotnull AS not_null, NULL::text AS default_expression
|
|
836
|
+
FROM pg_catalog.pg_class c
|
|
837
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
838
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
|
|
839
|
+
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
|
|
840
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
841
|
+
AND c.relname = ANY(ARRAY['ab_ticket_schema_migrations', 'ab_tickets',
|
|
842
|
+
'ab_ticket_comments', 'ab_ticket_blockers'])
|
|
843
|
+
ORDER BY c.relname, a.attnum`
|
|
844
|
+
for (const [table, shape] of Object.entries(expected)) {
|
|
845
|
+
const actual = columns
|
|
846
|
+
.filter((column) => column.table_name === table)
|
|
847
|
+
.map((column) => [column.column_name, column.formatted_type, column.not_null])
|
|
848
|
+
if (JSON.stringify(actual) !== JSON.stringify(shape)) {
|
|
849
|
+
throw schemaError(`ticket table ${table} is missing or mismatched`)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** The guarded streams.creation_seq upgrade, shared by every pre-v6 marker
|
|
855
|
+
* branch whose database carries a pre-existing streams table (v2, v3, v4, v5).
|
|
856
|
+
* The idempotent full DDL above has already created the
|
|
857
|
+
* streams_creation_seq sequence; the column is added only when missing, with
|
|
858
|
+
* a (created_at, id)-ordered backfill — legacy same-millisecond ties are
|
|
859
|
+
* genuinely unorderable, so any total order consistent with createdAt is
|
|
860
|
+
* acceptable — SET NOT NULL, and the sequence positioned above the backfilled
|
|
861
|
+
* values so the next nextval continues the counter without collision. */
|
|
862
|
+
const STREAMS_CREATION_SEQ_MIGRATION = `
|
|
863
|
+
DO $$ BEGIN
|
|
864
|
+
IF NOT EXISTS (
|
|
865
|
+
SELECT 1 FROM information_schema.columns
|
|
866
|
+
WHERE table_schema = current_schema() AND table_name = 'streams'
|
|
867
|
+
AND column_name = 'creation_seq'
|
|
868
|
+
) THEN
|
|
869
|
+
ALTER TABLE streams ADD COLUMN creation_seq bigint;
|
|
870
|
+
WITH numbered AS (
|
|
871
|
+
SELECT id, row_number() OVER (ORDER BY created_at, id) AS rn FROM streams
|
|
872
|
+
)
|
|
873
|
+
UPDATE streams SET creation_seq = numbered.rn
|
|
874
|
+
FROM numbered WHERE streams.id = numbered.id;
|
|
875
|
+
ALTER TABLE streams ALTER COLUMN creation_seq SET NOT NULL;
|
|
876
|
+
PERFORM setval('streams_creation_seq',
|
|
877
|
+
(SELECT COALESCE(MAX(creation_seq), 0) FROM streams) + 1, false);
|
|
878
|
+
END IF;
|
|
879
|
+
END $$;
|
|
880
|
+
`
|
|
881
|
+
|
|
882
|
+
export async function migratePostgres(url: string): Promise<void> {
|
|
883
|
+
const sql = new SQL(url)
|
|
884
|
+
try {
|
|
885
|
+
await sql.begin(async (tx) => {
|
|
886
|
+
await tx`SELECT pg_advisory_xact_lock(470281941)`
|
|
887
|
+
await tx.unsafe(SCHEMA_DDL)
|
|
888
|
+
await tx.unsafe(TICKET_SCHEMA_DDL)
|
|
889
|
+
await tx.unsafe(AUTH_SCHEMA_DDL)
|
|
890
|
+
const rows: { version: number; checksum: string }[] =
|
|
891
|
+
await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
892
|
+
const marker = rows[0]
|
|
893
|
+
if (marker) {
|
|
894
|
+
// v1–v5 → v6: the guarded builds.repo_origin column runs for EVERY
|
|
895
|
+
// pre-v6 marker, before the version branches — not only in a v4
|
|
896
|
+
// branch. `CREATE TABLE IF NOT EXISTS builds` does not alter an
|
|
897
|
+
// existing builds table, so without this the internal assertSchema
|
|
898
|
+
// below would fail on every upgraded legacy database. The ALTER is
|
|
899
|
+
// guarded and idempotent, so re-running it on a v6 database (or a
|
|
900
|
+
// marker that turns out to be rejected below, which rolls this
|
|
901
|
+
// transaction back) is a no-op.
|
|
902
|
+
await tx.unsafe(`
|
|
903
|
+
DO $$ BEGIN
|
|
904
|
+
IF NOT EXISTS (
|
|
905
|
+
SELECT 1 FROM information_schema.columns
|
|
906
|
+
WHERE table_schema = current_schema() AND table_name = 'builds'
|
|
907
|
+
AND column_name = 'repo_origin'
|
|
908
|
+
) THEN
|
|
909
|
+
ALTER TABLE builds ADD COLUMN repo_origin text;
|
|
910
|
+
END IF;
|
|
911
|
+
END $$;
|
|
912
|
+
`)
|
|
913
|
+
const version = Number(marker.version)
|
|
914
|
+
if (version === SCHEMA_VERSION) {
|
|
915
|
+
if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('marker is incompatible')
|
|
916
|
+
} else if (version === 1 && marker.checksum === SCHEMA_V1_CHECKSUM) {
|
|
917
|
+
// v1 → v6: the idempotent full DDL above already applied the deltas
|
|
918
|
+
// (the stream tables and the session tables); v1 databases never had
|
|
919
|
+
// a streams or sessions table, so the full DDL created them with the
|
|
920
|
+
// session column, the widened CHECKs, and both creation_seq columns.
|
|
921
|
+
// The guarded repo_origin ALTER above covered builds. Promote the
|
|
922
|
+
// marker in this transaction.
|
|
923
|
+
await tx`UPDATE ab_schema_migrations
|
|
924
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
925
|
+
applied_at = ${new Date().toISOString()}
|
|
926
|
+
WHERE singleton = true`
|
|
927
|
+
} else if (version === 2 && marker.checksum === SCHEMA_V2_CHECKSUM) {
|
|
928
|
+
// v2 → v6: the idempotent full DDL above created the session tables
|
|
929
|
+
// (with the creation_seq column); a v2 database's streams table
|
|
930
|
+
// needs the guarded session column, the widened CHECK constraints,
|
|
931
|
+
// and the guarded creation_seq column (shared with v3 and v4).
|
|
932
|
+
await tx.unsafe(`
|
|
933
|
+
DO $$ BEGIN
|
|
934
|
+
IF NOT EXISTS (
|
|
935
|
+
SELECT 1 FROM information_schema.columns
|
|
936
|
+
WHERE table_schema = current_schema() AND table_name = 'streams'
|
|
937
|
+
AND column_name = 'session'
|
|
938
|
+
) THEN
|
|
939
|
+
ALTER TABLE streams
|
|
940
|
+
ADD COLUMN session text REFERENCES sessions(id) ON DELETE CASCADE;
|
|
941
|
+
END IF;
|
|
942
|
+
END $$;
|
|
943
|
+
ALTER TABLE streams DROP CONSTRAINT IF EXISTS streams_scope_kind_check;
|
|
944
|
+
ALTER TABLE streams
|
|
945
|
+
ADD CONSTRAINT streams_scope_kind_check
|
|
946
|
+
CHECK (scope_kind IN ('build','repo','session'));
|
|
947
|
+
ALTER TABLE streams DROP CONSTRAINT IF EXISTS streams_scope_exactly_one_check;
|
|
948
|
+
ALTER TABLE streams
|
|
949
|
+
ADD CONSTRAINT streams_scope_exactly_one_check CHECK (
|
|
950
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
951
|
+
OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
952
|
+
OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
953
|
+
);
|
|
954
|
+
`)
|
|
955
|
+
await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
|
|
956
|
+
await tx`UPDATE ab_schema_migrations
|
|
957
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
958
|
+
applied_at = ${new Date().toISOString()}
|
|
959
|
+
WHERE singleton = true`
|
|
960
|
+
} else if (version === 3 && marker.checksum === SCHEMA_V3_CHECKSUM) {
|
|
961
|
+
// v3 → v6: the listSessions and listStreams creation-order
|
|
962
|
+
// tiebreaks (store/types.ts). The idempotent full DDL above created
|
|
963
|
+
// the sessions_creation_seq and streams_creation_seq sequences; a
|
|
964
|
+
// v3 database's sessions and streams tables need the guarded
|
|
965
|
+
// creation_seq columns, each with a (created_at, id)-ordered
|
|
966
|
+
// backfill — legacy same-millisecond ties are genuinely
|
|
967
|
+
// unorderable, so any total order consistent with createdAt is
|
|
968
|
+
// acceptable — SET NOT NULL, and the sequence positioned above the
|
|
969
|
+
// backfilled values so the next nextval continues the counter
|
|
970
|
+
// without collision. The guarded repo_origin ALTER above covered
|
|
971
|
+
// builds.
|
|
972
|
+
await tx.unsafe(`
|
|
973
|
+
DO $$ BEGIN
|
|
974
|
+
IF NOT EXISTS (
|
|
975
|
+
SELECT 1 FROM information_schema.columns
|
|
976
|
+
WHERE table_schema = current_schema() AND table_name = 'sessions'
|
|
977
|
+
AND column_name = 'creation_seq'
|
|
978
|
+
) THEN
|
|
979
|
+
ALTER TABLE sessions ADD COLUMN creation_seq bigint;
|
|
980
|
+
WITH numbered AS (
|
|
981
|
+
SELECT id, row_number() OVER (ORDER BY created_at, id) AS rn FROM sessions
|
|
982
|
+
)
|
|
983
|
+
UPDATE sessions SET creation_seq = numbered.rn
|
|
984
|
+
FROM numbered WHERE sessions.id = numbered.id;
|
|
985
|
+
ALTER TABLE sessions ALTER COLUMN creation_seq SET NOT NULL;
|
|
986
|
+
PERFORM setval('sessions_creation_seq',
|
|
987
|
+
(SELECT COALESCE(MAX(creation_seq), 0) FROM sessions) + 1, false);
|
|
988
|
+
END IF;
|
|
989
|
+
END $$;
|
|
990
|
+
`)
|
|
991
|
+
await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
|
|
992
|
+
await tx`UPDATE ab_schema_migrations
|
|
993
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
994
|
+
applied_at = ${new Date().toISOString()}
|
|
995
|
+
WHERE singleton = true`
|
|
996
|
+
} else if (version === 4 && marker.checksum === SCHEMA_V4_CHECKSUM) {
|
|
997
|
+
// v4 → v6: the listStreams creation-order tiebreak
|
|
998
|
+
// (store/types.ts), mirroring the v3→v4 sessions treatment. The
|
|
999
|
+
// idempotent full DDL above created the streams_creation_seq
|
|
1000
|
+
// sequence; a v4 database's streams table needs the guarded
|
|
1001
|
+
// creation_seq column, backfill, NOT NULL, and sequence continuity
|
|
1002
|
+
// (the shared pre-v6 streams migration). The guarded repo_origin
|
|
1003
|
+
// ALTER above covered builds.
|
|
1004
|
+
await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
|
|
1005
|
+
await tx`UPDATE ab_schema_migrations
|
|
1006
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1007
|
+
applied_at = ${new Date().toISOString()}
|
|
1008
|
+
WHERE singleton = true`
|
|
1009
|
+
} else if (version === 5 && marker.checksum === SCHEMA_V5_CHECKSUM) {
|
|
1010
|
+
// v5 → v6: the listStreams creation-order tiebreak (store/types.ts)
|
|
1011
|
+
// landed on the v5 DDL without a version bump, so the hosted
|
|
1012
|
+
// database carries the v5 marker with the pre-tiebreak checksum. The
|
|
1013
|
+
// idempotent full DDL above created the streams_creation_seq
|
|
1014
|
+
// sequence; a v5 database's streams table needs the guarded
|
|
1015
|
+
// creation_seq column, backfill, NOT NULL, and sequence continuity
|
|
1016
|
+
// (the shared pre-v6 streams migration). Every other v6 table and
|
|
1017
|
+
// column already exists in a v5 database.
|
|
1018
|
+
await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
|
|
1019
|
+
await tx`UPDATE ab_schema_migrations
|
|
1020
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1021
|
+
applied_at = ${new Date().toISOString()}
|
|
1022
|
+
WHERE singleton = true`
|
|
1023
|
+
} else {
|
|
1024
|
+
throw schemaError('marker is incompatible')
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
|
|
1028
|
+
VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1029
|
+
}
|
|
1030
|
+
const ticketRows: { version: number; checksum: string }[] =
|
|
1031
|
+
await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1032
|
+
const ticketMarker = ticketRows[0]
|
|
1033
|
+
if (ticketMarker) {
|
|
1034
|
+
if (
|
|
1035
|
+
Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION ||
|
|
1036
|
+
ticketMarker.checksum !== TICKET_SCHEMA_CHECKSUM
|
|
1037
|
+
) {
|
|
1038
|
+
throw schemaError('ticket marker is incompatible')
|
|
1039
|
+
}
|
|
1040
|
+
} else {
|
|
1041
|
+
await tx`INSERT INTO ab_ticket_schema_migrations
|
|
1042
|
+
(singleton, version, checksum, applied_at)
|
|
1043
|
+
VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1044
|
+
}
|
|
1045
|
+
const authRows: { version: number; checksum: string }[] =
|
|
1046
|
+
await tx`SELECT version, checksum FROM ab_auth_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1047
|
+
const authMarker = authRows[0]
|
|
1048
|
+
// v2 → v3: the guarded oauthApplication.authenticationScheme column
|
|
1049
|
+
// runs for EVERY pre-v3 auth marker, before the version branches —
|
|
1050
|
+
// `CREATE TABLE IF NOT EXISTS "oauthApplication"` does not alter an
|
|
1051
|
+
// existing table, so without this the internal assertAuthSchema below
|
|
1052
|
+
// would fail on every upgraded v1/v2 database. Guarded and idempotent
|
|
1053
|
+
// (the builds.repo_origin precedent), so a no-op on fresh installs,
|
|
1054
|
+
// v1 upgrades (full DDL already created the column), and current-v3
|
|
1055
|
+
// reruns. The column rides last in the v3 DDL so fresh and migrated
|
|
1056
|
+
// databases assert identically.
|
|
1057
|
+
await tx.unsafe(`
|
|
1058
|
+
ALTER TABLE "oauthApplication"
|
|
1059
|
+
ADD COLUMN IF NOT EXISTS "authenticationScheme" text;
|
|
1060
|
+
`)
|
|
1061
|
+
if (authMarker) {
|
|
1062
|
+
const authVersion = Number(authMarker.version)
|
|
1063
|
+
if (authVersion === AUTH_SCHEMA_VERSION) {
|
|
1064
|
+
if (authMarker.checksum !== AUTH_SCHEMA_CHECKSUM) {
|
|
1065
|
+
throw schemaError('auth marker is incompatible')
|
|
1066
|
+
}
|
|
1067
|
+
} else if (authVersion === 1 && authMarker.checksum === AUTH_SCHEMA_V1_CHECKSUM) {
|
|
1068
|
+
// v1 → v3: the idempotent full DDL above already created the four
|
|
1069
|
+
// MCP-plugin tables with the column (CREATE TABLE IF NOT EXISTS is
|
|
1070
|
+
// a no-op on the existing core tables); promote the marker in this
|
|
1071
|
+
// transaction.
|
|
1072
|
+
await tx`UPDATE ab_auth_schema_migrations
|
|
1073
|
+
SET version = ${AUTH_SCHEMA_VERSION}, checksum = ${AUTH_SCHEMA_CHECKSUM},
|
|
1074
|
+
applied_at = ${new Date().toISOString()}
|
|
1075
|
+
WHERE singleton = true`
|
|
1076
|
+
} else if (authVersion === 2 && authMarker.checksum === AUTH_SCHEMA_V2_CHECKSUM) {
|
|
1077
|
+
// v2 → v3: the guarded ALTER above added the column to the
|
|
1078
|
+
// pre-existing oauthApplication table; promote the marker in this
|
|
1079
|
+
// transaction.
|
|
1080
|
+
await tx`UPDATE ab_auth_schema_migrations
|
|
1081
|
+
SET version = ${AUTH_SCHEMA_VERSION}, checksum = ${AUTH_SCHEMA_CHECKSUM},
|
|
1082
|
+
applied_at = ${new Date().toISOString()}
|
|
1083
|
+
WHERE singleton = true`
|
|
1084
|
+
} else {
|
|
1085
|
+
throw schemaError('auth marker is incompatible')
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
await tx`INSERT INTO ab_auth_schema_migrations
|
|
1089
|
+
(singleton, version, checksum, applied_at)
|
|
1090
|
+
VALUES (true, ${AUTH_SCHEMA_VERSION}, ${AUTH_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1091
|
+
}
|
|
1092
|
+
await assertSchema(tx)
|
|
1093
|
+
await assertTicketSchema(tx)
|
|
1094
|
+
await assertAuthSchema(tx)
|
|
1095
|
+
})
|
|
1096
|
+
} finally {
|
|
1097
|
+
await sql.close()
|
|
1098
|
+
}
|
|
1099
|
+
}
|