@defrex/autobuild-postgres-store 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/package.json +18 -1
- package/src/auth-schema.ts +21 -6
- package/src/index.ts +1 -0
- package/src/retry.ts +260 -0
- package/src/schema.ts +453 -31
- package/src/store.ts +618 -197
- package/src/tickets.ts +88 -59
- package/src/blobs.test.ts +0 -121
- package/src/config.test.ts +0 -174
- package/src/env.test.ts +0 -48
- package/src/event-wait-poll-ms.test.ts +0 -23
- package/src/migrate.test.ts +0 -640
- package/src/store.live.test.ts +0 -453
- package/src/testing/concurrent-worker.ts +0 -42
- package/src/tickets.live.test.ts +0 -49
package/src/store.live.test.ts
DELETED
|
@@ -1,453 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { MemoryBlobStore, sampleEventWrite } from '@defrex/autobuild/plugin-sdk'
|
|
2
|
-
import { openPostgresBuildStore } from '../store'
|
|
3
|
-
|
|
4
|
-
async function main(): Promise<void> {
|
|
5
|
-
const [url, mode, resource, countText] = process.argv.slice(2)
|
|
6
|
-
if (!url || !mode || !resource) throw new Error('missing worker arguments')
|
|
7
|
-
const count = Number(countText ?? '1')
|
|
8
|
-
const store = await openPostgresBuildStore(url, new MemoryBlobStore())
|
|
9
|
-
try {
|
|
10
|
-
if (mode === 'build') {
|
|
11
|
-
for (let index = 0; index < count; index++) {
|
|
12
|
-
await store.append(resource, sampleEventWrite(`${process.pid}-${index}`))
|
|
13
|
-
}
|
|
14
|
-
} else if (mode === 'repo') {
|
|
15
|
-
for (let index = 0; index < count; index++) {
|
|
16
|
-
await store.appendRepo(resource, {
|
|
17
|
-
actor: { kind: 'human', user: `worker-${process.pid}` },
|
|
18
|
-
type: 'dispatcher.pause-set',
|
|
19
|
-
payload: { enabled: index % 2 === 0 },
|
|
20
|
-
})
|
|
21
|
-
}
|
|
22
|
-
} else if (mode === 'conditional') {
|
|
23
|
-
const result = await store.appendIfCurrent(
|
|
24
|
-
resource,
|
|
25
|
-
count,
|
|
26
|
-
sampleEventWrite(String(process.pid)),
|
|
27
|
-
)
|
|
28
|
-
console.log(result ? 'winner' : 'stale')
|
|
29
|
-
} else {
|
|
30
|
-
throw new Error(`unknown worker mode ${mode}`)
|
|
31
|
-
}
|
|
32
|
-
} finally {
|
|
33
|
-
await store.close()
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (import.meta.main) {
|
|
38
|
-
main().catch((error: unknown) => {
|
|
39
|
-
console.error(error)
|
|
40
|
-
process.exitCode = 1
|
|
41
|
-
})
|
|
42
|
-
}
|
package/src/tickets.live.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, test } from 'bun:test'
|
|
2
|
-
import { SQL } from 'bun'
|
|
3
|
-
import { describeTicketSourceContract } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
-
import { migratePostgres } from './schema'
|
|
5
|
-
import { openPostgresTicketDatabase } from './tickets'
|
|
6
|
-
|
|
7
|
-
const testUrl = process.env.AB_POSTGRES_TEST_URL?.trim()
|
|
8
|
-
|
|
9
|
-
async function isolatedDatabase(): Promise<{ url: string; cleanup(): Promise<void> }> {
|
|
10
|
-
if (!testUrl) throw new Error('AB_POSTGRES_TEST_URL is required')
|
|
11
|
-
const schema = `ab_tickets_${crypto.randomUUID().replaceAll('-', '')}`
|
|
12
|
-
const admin = new SQL(testUrl)
|
|
13
|
-
await admin.unsafe(`CREATE SCHEMA ${schema}`)
|
|
14
|
-
await admin.close()
|
|
15
|
-
const scoped = new URL(testUrl)
|
|
16
|
-
scoped.searchParams.set('options', `-csearch_path=${schema}`)
|
|
17
|
-
await migratePostgres(scoped.toString())
|
|
18
|
-
return {
|
|
19
|
-
url: scoped.toString(),
|
|
20
|
-
cleanup: async () => {
|
|
21
|
-
const sql = new SQL(testUrl)
|
|
22
|
-
try {
|
|
23
|
-
await sql.unsafe(`DROP SCHEMA ${schema} CASCADE`)
|
|
24
|
-
} finally {
|
|
25
|
-
await sql.close()
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!testUrl) {
|
|
32
|
-
describe('PostgreSQL TicketSource live contract', () => {
|
|
33
|
-
test.skip('set AB_POSTGRES_TEST_URL to run TicketSource conformance', () => {})
|
|
34
|
-
})
|
|
35
|
-
} else {
|
|
36
|
-
describeTicketSourceContract('PostgresTicketSource', async () => {
|
|
37
|
-
const isolated = await isolatedDatabase()
|
|
38
|
-
const database = await openPostgresTicketDatabase(isolated.url)
|
|
39
|
-
return {
|
|
40
|
-
source: database.source({ teamKey: 'ENG', claimedState: 'Doing' }),
|
|
41
|
-
states: { ready: 'Ready', claimed: 'Doing', completed: 'Done' },
|
|
42
|
-
editableLabel: 'autobuild',
|
|
43
|
-
cleanup: async () => {
|
|
44
|
-
await database.close()
|
|
45
|
-
await isolated.cleanup()
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
})
|
|
49
|
-
}
|