@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.
@@ -0,0 +1,453 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { SQL } from 'bun'
3
+ import {
4
+ MemoryBlobStore,
5
+ StreamBatchTooLargeError,
6
+ describeBuildStoreContract,
7
+ harvestStartedWrite,
8
+ messagePostedWrite,
9
+ sampleBuildInput,
10
+ sampleEventWrite,
11
+ type BlobStore,
12
+ type StreamPart,
13
+ } from '@defrex/autobuild/plugin-sdk'
14
+ import { migratePostgres } from './schema'
15
+ import { EVENT_WAIT_POLL_MS, openPostgresBuildStore } from './store'
16
+
17
+ const testUrl = process.env.AB_POSTGRES_TEST_URL?.trim()
18
+
19
+ async function isolatedDatabase(): Promise<{ url: string; cleanup: () => Promise<void> }> {
20
+ if (!testUrl) throw new Error('AB_POSTGRES_TEST_URL is required')
21
+ const schema = `ab_test_${crypto.randomUUID().replaceAll('-', '')}`
22
+ const admin = new SQL(testUrl)
23
+ await admin.unsafe(`CREATE SCHEMA ${schema}`)
24
+ await admin.close()
25
+ const scoped = new URL(testUrl)
26
+ scoped.searchParams.set('options', `-csearch_path=${schema}`)
27
+ const url = scoped.toString()
28
+ await migratePostgres(url)
29
+ return {
30
+ url,
31
+ cleanup: async () => {
32
+ const cleanupSql = new SQL(testUrl)
33
+ try {
34
+ await cleanupSql.unsafe(`DROP SCHEMA ${schema} CASCADE`)
35
+ } finally {
36
+ await cleanupSql.close()
37
+ }
38
+ },
39
+ }
40
+ }
41
+
42
+ async function runWorker(
43
+ url: string,
44
+ mode: 'build' | 'repo' | 'conditional',
45
+ resource: string,
46
+ count: number,
47
+ ): Promise<string> {
48
+ const child = Bun.spawn(
49
+ [
50
+ process.execPath,
51
+ `${import.meta.dir}/testing/concurrent-worker.ts`,
52
+ url,
53
+ mode,
54
+ resource,
55
+ String(count),
56
+ ],
57
+ { stdout: 'pipe', stderr: 'pipe' },
58
+ )
59
+ const [exitCode, stdout, stderr] = await Promise.all([
60
+ child.exited,
61
+ new Response(child.stdout).text(),
62
+ new Response(child.stderr).text(),
63
+ ])
64
+ if (exitCode !== 0) throw new Error(`concurrency worker failed (${exitCode}): ${stderr}`)
65
+ return stdout.trim()
66
+ }
67
+
68
+ if (testUrl) {
69
+ describeBuildStoreContract('PostgreSQL', async (options) => {
70
+ const database = await isolatedDatabase()
71
+ const store = await openPostgresBuildStore(database.url, new MemoryBlobStore(), {
72
+ ...(options?.clock ? { clock: options.clock } : {}),
73
+ ...(options?.retention ? { retention: options.retention } : {}),
74
+ })
75
+ return { store, cleanup: database.cleanup }
76
+ })
77
+
78
+ describe('PostgreSQL durability and concurrency', () => {
79
+ test('concurrent repository ensures create one absent row and return the winning record', async () => {
80
+ const database = await isolatedDatabase()
81
+ const stores = await Promise.all(
82
+ Array.from({ length: 8 }, () =>
83
+ openPostgresBuildStore(database.url, new MemoryBlobStore()),
84
+ ),
85
+ )
86
+ const assertionSql = new SQL(database.url)
87
+ try {
88
+ const records = await Promise.all(stores.map((store) => store.ensureRepo('acme/new-repo')))
89
+ expect(records).toHaveLength(stores.length)
90
+ expect(
91
+ records.every((record) => JSON.stringify(record) === JSON.stringify(records[0])),
92
+ ).toBe(true)
93
+
94
+ const rows = await assertionSql`SELECT * FROM repo_streams WHERE repo = ${'acme/new-repo'}`
95
+ expect(rows).toHaveLength(1)
96
+ const createdAt = rows[0]?.created_at
97
+ const updatedAt = rows[0]?.updated_at
98
+ expect(records[0]).toEqual({
99
+ repo: 'acme/new-repo',
100
+ createdAt:
101
+ createdAt instanceof Date
102
+ ? createdAt.toISOString()
103
+ : new Date(String(createdAt)).toISOString(),
104
+ updatedAt:
105
+ updatedAt instanceof Date
106
+ ? updatedAt.toISOString()
107
+ : new Date(String(updatedAt)).toISOString(),
108
+ })
109
+ } finally {
110
+ await assertionSql.close()
111
+ await Promise.all(stores.map((store) => store.close()))
112
+ await database.cleanup()
113
+ }
114
+ })
115
+
116
+ test('concurrent creation of one absent build has one winner and normalized losers', async () => {
117
+ const database = await isolatedDatabase()
118
+ const stores = await Promise.all(
119
+ Array.from({ length: 8 }, () =>
120
+ openPostgresBuildStore(database.url, new MemoryBlobStore()),
121
+ ),
122
+ )
123
+ const assertionSql = new SQL(database.url)
124
+ try {
125
+ const results = await Promise.allSettled(
126
+ stores.map((store) => store.createBuild(sampleBuildInput('new-shared-build'))),
127
+ )
128
+ const fulfilled = results.filter((result) => result.status === 'fulfilled')
129
+ const rejected = results.filter((result) => result.status === 'rejected')
130
+ expect(fulfilled).toHaveLength(1)
131
+ expect(rejected).toHaveLength(stores.length - 1)
132
+ expect(rejected.map((result) => result.reason?.message)).toEqual(
133
+ Array.from(
134
+ { length: stores.length - 1 },
135
+ () => 'build "new-shared-build" already exists',
136
+ ),
137
+ )
138
+
139
+ const rows = await assertionSql`SELECT * FROM builds WHERE slug = ${'new-shared-build'}`
140
+ expect(rows).toHaveLength(1)
141
+ expect(rows[0]?.slug).toBe('new-shared-build')
142
+ expect(rows[0]?.repo).toBe(sampleBuildInput('new-shared-build').repo)
143
+ } finally {
144
+ await assertionSql.close()
145
+ await Promise.all(stores.map((store) => store.close()))
146
+ await database.cleanup()
147
+ }
148
+ })
149
+
150
+ test('fresh independent instances see prior writes and serialize streams', async () => {
151
+ const database = await isolatedDatabase()
152
+ const blobs = new MemoryBlobStore()
153
+ const first = await openPostgresBuildStore(database.url, blobs)
154
+ const second = await openPostgresBuildStore(database.url, blobs)
155
+ try {
156
+ await first.createBuild(sampleBuildInput('shared'))
157
+ await first.ensureRepo('acme/shared')
158
+ await Promise.all(
159
+ Array.from({ length: 20 }, (_, index) =>
160
+ (index % 2 ? first : second).append('shared', sampleEventWrite(String(index))),
161
+ ),
162
+ )
163
+ await Promise.all(
164
+ Array.from({ length: 20 }, (_, index) =>
165
+ (index % 2 ? first : second).appendRepo('acme/shared', {
166
+ actor: { kind: 'human', user: 'operator' },
167
+ type: 'dispatcher.pause-set',
168
+ payload: { enabled: index % 2 === 0 },
169
+ }),
170
+ ),
171
+ )
172
+ const contenders = await Promise.all([
173
+ first.appendIfCurrent('shared', 20, sampleEventWrite('winner-a')),
174
+ second.appendIfCurrent('shared', 20, sampleEventWrite('winner-b')),
175
+ ])
176
+ expect(contenders.filter(Boolean)).toHaveLength(1)
177
+ expect((await second.getEvents('shared')).map((event) => event.seq)).toEqual(
178
+ Array.from({ length: 21 }, (_, index) => index + 1),
179
+ )
180
+ expect((await first.getRepoEvents('acme/shared')).map((event) => event.seq)).toEqual(
181
+ Array.from({ length: 20 }, (_, index) => index + 1),
182
+ )
183
+ await first.close()
184
+ const fresh = await openPostgresBuildStore(database.url, blobs)
185
+ try {
186
+ expect(await fresh.getBuild('shared')).not.toBeNull()
187
+ } finally {
188
+ await fresh.close()
189
+ }
190
+ } finally {
191
+ await second.close()
192
+ await database.cleanup()
193
+ }
194
+ })
195
+
196
+ test('separate processes serialize build/repository appends and conditional races', async () => {
197
+ const database = await isolatedDatabase()
198
+ const store = await openPostgresBuildStore(database.url, new MemoryBlobStore())
199
+ try {
200
+ await store.createBuild(sampleBuildInput('process-shared'))
201
+ await store.ensureRepo('acme/process-shared')
202
+ await Promise.all([
203
+ runWorker(database.url, 'build', 'process-shared', 15),
204
+ runWorker(database.url, 'build', 'process-shared', 15),
205
+ runWorker(database.url, 'repo', 'acme/process-shared', 15),
206
+ runWorker(database.url, 'repo', 'acme/process-shared', 15),
207
+ ])
208
+ expect((await store.getEvents('process-shared')).map((event) => event.seq)).toEqual(
209
+ Array.from({ length: 30 }, (_, index) => index + 1),
210
+ )
211
+ expect(
212
+ (await store.getRepoEvents('acme/process-shared')).map((event) => event.seq),
213
+ ).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
214
+ const conditional = await Promise.all([
215
+ runWorker(database.url, 'conditional', 'process-shared', 30),
216
+ runWorker(database.url, 'conditional', 'process-shared', 30),
217
+ ])
218
+ expect(conditional.sort()).toEqual(['stale', 'winner'])
219
+ } finally {
220
+ await store.close()
221
+ await database.cleanup()
222
+ }
223
+ })
224
+
225
+ // Nominal bound (AUT-334): the 2 s assertion covers the poll-interval
226
+ // worst case — the 1 s EVENT_WAIT_POLL_MS poll plus up to ~1 s of query
227
+ // and CI scheduler slack — not a hard one-second delivery guarantee.
228
+ test('a held event read observes a cross-connection append within the one-poll worst-case budget (AUT-334)', async () => {
229
+ const database = await isolatedDatabase()
230
+ const blobs = new MemoryBlobStore()
231
+ const reader = await openPostgresBuildStore(database.url, blobs)
232
+ const writer = await openPostgresBuildStore(database.url, blobs)
233
+ try {
234
+ await reader.createBuild(sampleBuildInput('evt-wait-x'))
235
+ await reader.ensureRepo('acme/evt-wait-x')
236
+
237
+ // Build events: instance A holds, instance B appends; A resolves with
238
+ // the event well under the bound (the 1 s poll plus query slack).
239
+ const pendingBuild = reader.getEvents('evt-wait-x', 0, { waitSeconds: 5 })
240
+ await Bun.sleep(200)
241
+ const event = await writer.append('evt-wait-x', sampleEventWrite('cross'))
242
+ const started = Date.now()
243
+ expect(await pendingBuild).toEqual([event])
244
+ expect(Date.now() - started).toBeLessThan(2_000)
245
+
246
+ // Repository journal, symmetric.
247
+ const pendingRepo = reader.getRepoEvents('acme/evt-wait-x', 0, { waitSeconds: 5 })
248
+ await Bun.sleep(200)
249
+ const repoEvent = await writer.appendRepo('acme/evt-wait-x', harvestStartedWrite('h_x'))
250
+ const repoStarted = Date.now()
251
+ expect(await pendingRepo).toEqual([repoEvent])
252
+ expect(Date.now() - repoStarted).toBeLessThan(2_000)
253
+ } finally {
254
+ await reader.close()
255
+ await writer.close()
256
+ await database.cleanup()
257
+ }
258
+ })
259
+
260
+ test('EVENT_WAIT_POLL_MS pins the one-second hosted poll interval (AUT-334)', () => {
261
+ expect(EVENT_WAIT_POLL_MS).toBe(1000)
262
+ })
263
+
264
+ // Session events, same budget (AUT-381): the held read starts with an
265
+ // empty initial poll (sinceSeq past session.created), the writer appends
266
+ // at ~500 ms, and a 1 s poll first observes it at ~1000 ms. The ≥ 900 ms
267
+ // lower bound fails a regression to the 25 ms stream default (which
268
+ // resolves at ~525 ms); the < 2 s upper bound is the same one-poll
269
+ // worst-case convention as the AUT-334 test above (1 s poll plus query
270
+ // and CI scheduler slack), not a hard delivery guarantee.
271
+ test('a held session-event read observes a cross-connection append within the one-poll worst-case budget (AUT-381)', async () => {
272
+ const database = await isolatedDatabase()
273
+ const blobs = new MemoryBlobStore()
274
+ const reader = await openPostgresBuildStore(database.url, blobs)
275
+ const writer = await openPostgresBuildStore(database.url, blobs)
276
+ try {
277
+ const session = await reader.createSession({
278
+ repo: 'acme/evt-wait-x',
279
+ operator: 'operator',
280
+ title: 'sess-wait-x',
281
+ })
282
+ // session.created (seq 1) auto-appends; warm the path once so the
283
+ // held read's initial poll hits a pooled connection.
284
+ expect((await reader.getSessionEvents(session.id)).map((event) => event.seq)).toEqual([1])
285
+
286
+ const started = Date.now()
287
+ const pendingSession = reader.getSessionEvents(session.id, 1, { waitSeconds: 5 })
288
+ await Bun.sleep(500)
289
+ const event = await writer.appendSessionEvent(session.id, messagePostedWrite('cross'))
290
+ expect(await pendingSession).toEqual([event])
291
+ expect(Date.now() - started).toBeGreaterThanOrEqual(900)
292
+ expect(Date.now() - started).toBeLessThan(2_000)
293
+ } finally {
294
+ await reader.close()
295
+ await writer.close()
296
+ await database.cleanup()
297
+ }
298
+ })
299
+
300
+ test('a cross-connection append inside a close prepare window is included in the finalized artifact (AUT-348)', async () => {
301
+ const database = await isolatedDatabase()
302
+ // A shared gated blob store: instance A's close suspends inside its
303
+ // prepare-phase blobs.put until instance B's append has committed —
304
+ // forced ordering, no timing (AUT-348).
305
+ const backing = new MemoryBlobStore()
306
+ let signalEntered!: () => void
307
+ let release!: () => void
308
+ const entered = new Promise<void>((resolve) => {
309
+ signalEntered = resolve
310
+ })
311
+ const gateOpen = new Promise<void>((resolve) => {
312
+ release = resolve
313
+ })
314
+ let armed = true
315
+ const blobs: BlobStore = {
316
+ put: async (hash, bytes) => {
317
+ if (armed) {
318
+ armed = false
319
+ signalEntered()
320
+ await gateOpen
321
+ }
322
+ await backing.put(hash, bytes)
323
+ },
324
+ get: (hash) => backing.get(hash),
325
+ }
326
+ const a = await openPostgresBuildStore(database.url, blobs)
327
+ const b = await openPostgresBuildStore(database.url, blobs)
328
+ try {
329
+ await a.createBuild(sampleBuildInput('st-pg-close-race'))
330
+ const stream = await a.createStream({ kind: 'build', build: 'st-pg-close-race' }, 'turn')
331
+ await a.appendStreamParts(stream.id, [{ type: 'start', messageId: 'm' }])
332
+ await a.appendStreamParts(stream.id, [{ type: 'text-start', id: 't' }])
333
+
334
+ const closing = a.closeStream(stream.id, 'completed')
335
+ await entered
336
+ const chunk = await b.appendStreamParts(stream.id, [
337
+ { type: 'text-delta', id: 't', delta: 'late' },
338
+ ])
339
+ expect(chunk.seq).toBe(3) // B's append was acknowledged before the close commits
340
+ release()
341
+ const record = await closing
342
+ expect(record.status).toBe('closed')
343
+
344
+ // The commit's re-read-under-lock re-assembled from the newer chunk
345
+ // set: all three acknowledged appends are in chunkCount and the doc.
346
+ const artifact = await a.getArtifact('st-pg-close-race', `stream:${stream.id}`)
347
+ expect(artifact?.meta.metadata).toMatchObject({ chunkCount: 3 })
348
+ const document = JSON.parse(new TextDecoder().decode(artifact!.content)) as Array<{
349
+ parts: Array<{ type: string; text?: string; state?: string }>
350
+ }>
351
+ expect(document[0]?.parts).toContainEqual({
352
+ type: 'text',
353
+ text: 'late',
354
+ state: 'streaming',
355
+ })
356
+ } finally {
357
+ await a.close()
358
+ await b.close()
359
+ await database.cleanup()
360
+ }
361
+ })
362
+
363
+ test('a close held on stream A does not serialize an append on stream B (AUT-348)', async () => {
364
+ const database = await isolatedDatabase()
365
+ const backing = new MemoryBlobStore()
366
+ let signalEntered!: () => void
367
+ let release!: () => void
368
+ const entered = new Promise<void>((resolve) => {
369
+ signalEntered = resolve
370
+ })
371
+ const gateOpen = new Promise<void>((resolve) => {
372
+ release = resolve
373
+ })
374
+ let armed = true
375
+ const blobs: BlobStore = {
376
+ put: async (hash, bytes) => {
377
+ if (armed) {
378
+ armed = false
379
+ signalEntered()
380
+ await gateOpen
381
+ }
382
+ await backing.put(hash, bytes)
383
+ },
384
+ get: (hash) => backing.get(hash),
385
+ }
386
+ const store = await openPostgresBuildStore(database.url, blobs)
387
+ try {
388
+ await store.createBuild(sampleBuildInput('st-pg-two-streams'))
389
+ const a = await store.createStream({ kind: 'build', build: 'st-pg-two-streams' }, 'a')
390
+ const b = await store.createStream({ kind: 'build', build: 'st-pg-two-streams' }, 'b')
391
+ await store.appendStreamParts(a.id, [{ type: 'start', messageId: 'am' }])
392
+
393
+ const closing = store.closeStream(a.id, 'completed')
394
+ await entered // A's close is suspended inside its prepare-phase put
395
+ const chunk = await store.appendStreamParts(b.id, [{ type: 'start', messageId: 'bm' }])
396
+ expect(chunk.seq).toBe(1)
397
+ release()
398
+ const record = await closing
399
+ expect(record.status).toBe('closed')
400
+ expect((await store.readStream(b.id)).chunks).toHaveLength(1)
401
+ } finally {
402
+ await store.close()
403
+ await database.cleanup()
404
+ }
405
+ })
406
+
407
+ // Local-side adapters validate the batch and check the ceiling BEFORE
408
+ // resolving the stream, so an invalid batch on an unknown stream reports
409
+ // the validation or ceiling error — not unknown-stream. The remote server
410
+ // deliberately runs the opposite order (SPEC §7.6); that side is pinned
411
+ // in core's remote.test.ts. Both orders must write nothing. Runs only
412
+ // where AB_POSTGRES_TEST_URL is set — the unit verify step does not
413
+ // exercise it; the order here is otherwise fixed by the SPEC §7.6 text.
414
+ test('append rejection precedence: an invalid or oversized batch on an unknown stream rejects before any lookup and writes nothing', async () => {
415
+ const database = await isolatedDatabase()
416
+ const store = await openPostgresBuildStore(database.url, new MemoryBlobStore())
417
+ try {
418
+ await store.createBuild(sampleBuildInput('st-pg-ghost'))
419
+
420
+ const empty = await store.appendStreamParts('st_ghost', []).catch((e: unknown) => e)
421
+ expect(empty).toBeInstanceOf(Error)
422
+ expect((empty as Error).message).toContain('stream parts must be a nonempty array')
423
+
424
+ const noType = await store
425
+ .appendStreamParts('st_ghost', [{ delta: 'x' } as unknown as StreamPart])
426
+ .catch((e: unknown) => e)
427
+ expect(noType).toBeInstanceOf(Error)
428
+ expect((noType as Error).message).toContain('must carry a nonempty string "type"')
429
+ expect((noType as Error).message).not.toContain('unknown stream')
430
+
431
+ // Shape-valid (a text-delta with a long delta) so the ceiling — not
432
+ // part validation — is the rejection observed.
433
+ const oversized = await store
434
+ .appendStreamParts('st_ghost', [
435
+ { type: 'text-delta', id: 't', delta: 'x'.repeat(1_048_600) },
436
+ ])
437
+ .catch((e: unknown) => e)
438
+ expect(oversized).toBeInstanceOf(StreamBatchTooLargeError)
439
+ expect((oversized as Error).message).toContain('1048576')
440
+ expect((oversized as Error).message).not.toContain('unknown stream')
441
+
442
+ expect(await store.getStream('st_ghost')).toBeNull()
443
+ } finally {
444
+ await store.close()
445
+ await database.cleanup()
446
+ }
447
+ })
448
+ })
449
+ } else {
450
+ describe('PostgreSQL live contract', () => {
451
+ test.skip('set AB_POSTGRES_TEST_URL to run the PostgreSQL contract and concurrency suite', () => {})
452
+ })
453
+ }