velocious 1.0.576 → 1.0.577

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.
Files changed (62) hide show
  1. package/README.md +46 -1
  2. package/build/background-jobs/main.js +13 -2
  3. package/build/background-jobs/scheduler.js +11 -7
  4. package/build/background-jobs/store.js +18 -13
  5. package/build/environment-handlers/node/cli/commands/test.js +2 -0
  6. package/build/environment-handlers/node/source-peer-package.js +172 -0
  7. package/build/routes/resolver.js +3 -50
  8. package/build/src/background-jobs/main.d.ts.map +1 -1
  9. package/build/src/background-jobs/main.js +13 -3
  10. package/build/src/background-jobs/scheduler.d.ts +2 -2
  11. package/build/src/background-jobs/scheduler.d.ts.map +1 -1
  12. package/build/src/background-jobs/scheduler.js +11 -8
  13. package/build/src/background-jobs/store.d.ts +5 -4
  14. package/build/src/background-jobs/store.d.ts.map +1 -1
  15. package/build/src/background-jobs/store.js +17 -12
  16. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  17. package/build/src/environment-handlers/node/cli/commands/test.js +3 -1
  18. package/build/src/environment-handlers/node/source-peer-package.d.ts +46 -0
  19. package/build/src/environment-handlers/node/source-peer-package.d.ts.map +1 -0
  20. package/build/src/environment-handlers/node/source-peer-package.js +148 -0
  21. package/build/src/routes/resolver.d.ts.map +1 -1
  22. package/build/src/routes/resolver.js +4 -45
  23. package/build/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -1
  25. package/scripts/run-tests.js +61 -20
  26. package/scripts/test-browser.js +6 -1
  27. package/src/background-jobs/main.js +13 -2
  28. package/src/background-jobs/scheduler.js +11 -7
  29. package/src/background-jobs/store.js +18 -13
  30. package/src/environment-handlers/node/cli/commands/test.js +2 -0
  31. package/src/environment-handlers/node/source-peer-package.js +172 -0
  32. package/src/routes/resolver.js +3 -50
  33. package/build/deployment-api/controller.js +0 -437
  34. package/build/deployment-api/index.js +0 -210
  35. package/build/deployment-api/path-matcher.js +0 -45
  36. package/build/deployment-api/registry.js +0 -84
  37. package/build/deployment-api/run-store.js +0 -798
  38. package/build/deployment-api/sanitize.js +0 -114
  39. package/build/src/deployment-api/controller.d.ts +0 -117
  40. package/build/src/deployment-api/controller.d.ts.map +0 -1
  41. package/build/src/deployment-api/controller.js +0 -384
  42. package/build/src/deployment-api/index.d.ts +0 -46
  43. package/build/src/deployment-api/index.d.ts.map +0 -1
  44. package/build/src/deployment-api/index.js +0 -178
  45. package/build/src/deployment-api/path-matcher.d.ts +0 -31
  46. package/build/src/deployment-api/path-matcher.d.ts.map +0 -1
  47. package/build/src/deployment-api/path-matcher.js +0 -39
  48. package/build/src/deployment-api/registry.d.ts +0 -106
  49. package/build/src/deployment-api/registry.d.ts.map +0 -1
  50. package/build/src/deployment-api/registry.js +0 -74
  51. package/build/src/deployment-api/run-store.d.ts +0 -402
  52. package/build/src/deployment-api/run-store.d.ts.map +0 -1
  53. package/build/src/deployment-api/run-store.js +0 -711
  54. package/build/src/deployment-api/sanitize.d.ts +0 -27
  55. package/build/src/deployment-api/sanitize.d.ts.map +0 -1
  56. package/build/src/deployment-api/sanitize.js +0 -100
  57. package/src/deployment-api/controller.js +0 -437
  58. package/src/deployment-api/index.js +0 -210
  59. package/src/deployment-api/path-matcher.js +0 -45
  60. package/src/deployment-api/registry.js +0 -84
  61. package/src/deployment-api/run-store.js +0 -798
  62. package/src/deployment-api/sanitize.js +0 -114
@@ -1,798 +0,0 @@
1
- // @ts-check
2
-
3
- import TableData from "../database/table-data/index.js"
4
- import TableIndex from "../database/table-data/table-index.js"
5
- import {createHash, randomUUID} from "node:crypto"
6
-
7
- /**
8
- * DeploymentRunRow type.
9
- * @typedef {object} DeploymentRunRow
10
- * @property {string} id - Run id (UUID).
11
- * @property {string} mountIdentifier - Stable identifier of the authenticated API mount that owns the run.
12
- * @property {string} project - Allowlisted project identifier.
13
- * @property {string} stage - Allowlisted stage identifier.
14
- * @property {string} revision - Full immutable requested Git revision.
15
- * @property {string} idempotencyKey - Caller-supplied idempotency key.
16
- * @property {string} status - Run status: pending, running, succeeded, failed, interrupted, or reconciliation_required.
17
- * @property {?} result - Sanitized adapter report, or null while unset.
18
- * @property {?} error - Sanitized failure payload, or null while unset.
19
- * @property {number} requestedAtMs - Creation time in ms epoch.
20
- * @property {number | null} startedAtMs - Execution start in ms epoch.
21
- * @property {number | null} finishedAtMs - Terminal time in ms epoch.
22
- * @property {string | null} ownerToken - Ownership token of the executing process.
23
- * @property {number | null} heartbeatAtMs - Last ownership lease heartbeat in ms epoch.
24
- */
25
- /**
26
- * DeploymentAuditEventRow type.
27
- * @typedef {object} DeploymentAuditEventRow
28
- * @property {string} id - Event id (UUID).
29
- * @property {string | null} runId - Owning run id.
30
- * @property {string} event - Event name (e.g. run_requested, run_started, run_succeeded, run_failed).
31
- * @property {?} payload - Sanitized JSON payload.
32
- * @property {number} createdAtMs - Creation time in ms epoch.
33
- */
34
- /**
35
- * CreateRunOutcome type.
36
- * @typedef {object} CreateRunOutcome
37
- * @property {"created" | "replay" | "conflict" | "in_progress" | "reconciliation_required"} outcome - What happened.
38
- * @property {DeploymentRunRow | null} run - The created, replayed, conflicting, or active run; null when the deployment lock was held mid-creation by another process.
39
- */
40
- const RUNS_TABLE = "velocious_deployment_runs"
41
- const AUDIT_TABLE = "velocious_deployment_api_audit_events"
42
- const BLOCKING_STATUSES = ["pending", "running", "reconciliation_required"]
43
- const DEFAULT_STALE_RUN_TIMEOUT_MS = 60000
44
- const IDEMPOTENCY_INDEX_NAME = "index_deployment_runs_on_mount_and_key"
45
- const LEGACY_UNSCOPED_MOUNT_IDENTIFIER = "legacy-unscoped"
46
- const SCHEMA_LOCK_NAME = "velocious-deployment-api:schema-v2"
47
-
48
- /**
49
- * Ownership token identifying runs executed by this process. Combined with the
50
- * heartbeat lease it lets reconciliation distinguish a genuinely active run
51
- * (fresh lease, or running in this process) from an interrupted one left
52
- * behind by a dead owner.
53
- */
54
- const PROCESS_OWNER_TOKEN = randomUUID()
55
-
56
- /**
57
- * Runs currently executing in this process. A genuinely active local run is
58
- * never reclaimed as stale, even if a blocked event loop delays its heartbeat.
59
- * @type {Set<string>}
60
- */
61
- const activeRunIdsInProcess = new Set()
62
-
63
- /**
64
- * Marks a run as executing in this process so lease reconciliation never
65
- * reclaims it.
66
- * @param {string} id - Run id.
67
- * @returns {void} - No return value.
68
- */
69
- export function registerActiveDeploymentRun(id) {
70
- activeRunIdsInProcess.add(id)
71
- }
72
-
73
- /**
74
- * Removes a run from the in-process execution registry.
75
- * @param {string} id - Run id.
76
- * @returns {void} - No return value.
77
- */
78
- export function unregisterActiveDeploymentRun(id) {
79
- activeRunIdsInProcess.delete(id)
80
- }
81
-
82
- /**
83
- * Parses a stored JSON text column.
84
- * @param {?} value - Raw column value.
85
- * @returns {?} - Parsed value or null.
86
- */
87
- function parseJsonColumn(value) {
88
- if (typeof value !== "string" || value.length === 0) return null
89
-
90
- return JSON.parse(value)
91
- }
92
-
93
- /**
94
- * Persistence for deployment API runs and audit events. Owns its schema
95
- * lazily (the record-attachments store precedent) so the tables exist in the
96
- * consuming app's database without app-side migrations.
97
- */
98
- export default class DeploymentRunStore {
99
- /**
100
- * Runs constructor.
101
- * @param {object} args - Options.
102
- * @param {import("../configuration.js").default} args.configuration - Configuration instance.
103
- * @param {string} [args.databaseIdentifier] - Database identifier; defaults to the primary database.
104
- * @param {string} args.mountIdentifier - Stable identifier of the authenticated API mount.
105
- * @param {number} [args.staleRunTimeoutMs] - Lease timeout after which an active run without a heartbeat counts as interrupted.
106
- */
107
- constructor({configuration, databaseIdentifier, mountIdentifier, staleRunTimeoutMs = DEFAULT_STALE_RUN_TIMEOUT_MS}) {
108
- if (typeof mountIdentifier !== "string" || mountIdentifier.length === 0) {
109
- throw new Error("DeploymentRunStore requires a mountIdentifier")
110
- }
111
-
112
- this.configuration = configuration
113
- this.databaseIdentifier = databaseIdentifier || "default"
114
- this.mountIdentifier = mountIdentifier
115
- this.staleRunTimeoutMs = staleRunTimeoutMs
116
- /** @type {Promise<void> | null} */
117
- this._readyPromise = null
118
- }
119
-
120
- /**
121
- * Ensures the run and audit tables exist.
122
- * @returns {Promise<void>} - Resolves when the schema is ready.
123
- */
124
- async ensureReady() {
125
- if (this._readyPromise) {
126
- await this._readyPromise
127
- return
128
- }
129
-
130
- this._readyPromise = this._withDb(async (db) => {
131
- const acquired = await db.acquireAdvisoryLock(SCHEMA_LOCK_NAME)
132
-
133
- if (!acquired) throw new Error("Failed to acquire deployment API schema lock")
134
-
135
- try {
136
- db.clearSchemaCache()
137
-
138
- if (!(await db.tableExists(RUNS_TABLE))) {
139
- const table = new TableData(RUNS_TABLE, {ifNotExists: true})
140
-
141
- table.string("id", {null: false, primaryKey: true})
142
- table.string("mount_identifier", {maxLength: 64, null: false})
143
- table.string("project", {null: false, index: true})
144
- table.string("stage", {null: false})
145
- table.string("revision", {null: false})
146
- table.string("idempotency_key", {null: false})
147
- table.string("status", {null: false, index: true})
148
- table.text("result_json", {null: true})
149
- table.text("error_json", {null: true})
150
- table.bigint("requested_at_ms", {null: false})
151
- table.bigint("started_at_ms", {null: true})
152
- table.bigint("finished_at_ms", {null: true})
153
- table.string("owner_token", {null: true})
154
- table.bigint("heartbeat_at_ms", {null: true})
155
- table.addIndex(new TableIndex(["mount_identifier", "idempotency_key"], {
156
- name: IDEMPOTENCY_INDEX_NAME,
157
- unique: true
158
- }))
159
-
160
- await db.createTable(table)
161
- }
162
-
163
- await this._ensureRunColumns(db)
164
- await this._ensureScopedIdempotencyIndex(db)
165
-
166
- if (!(await db.tableExists(AUDIT_TABLE))) {
167
- const table = new TableData(AUDIT_TABLE, {ifNotExists: true})
168
-
169
- table.string("id", {null: false, primaryKey: true})
170
- table.string("mount_identifier", {maxLength: 64, null: false})
171
- table.string("run_id", {null: true, index: true})
172
- table.string("event", {null: false, index: true})
173
- table.text("payload_json", {null: true})
174
- table.bigint("created_at_ms", {null: false})
175
-
176
- await db.createTable(table)
177
- }
178
-
179
- await this._ensureMountIdentifierColumn(db, AUDIT_TABLE)
180
- } finally {
181
- await db.releaseAdvisoryLock(SCHEMA_LOCK_NAME)
182
- }
183
- })
184
-
185
- try {
186
- await this._readyPromise
187
- } finally {
188
- this._readyPromise = null
189
- }
190
- }
191
-
192
- /**
193
- * Finds a run by id.
194
- * @param {string} id - Run id.
195
- * @returns {Promise<DeploymentRunRow | null>} - The run or null.
196
- */
197
- async findRunById(id) {
198
- await this.ensureReady()
199
-
200
- return await this._withDb(async (db) => await this._findRunById(db, id))
201
- }
202
-
203
- /**
204
- * Finds a run by idempotency key.
205
- * @param {string} idempotencyKey - Idempotency key.
206
- * @returns {Promise<DeploymentRunRow | null>} - The run or null.
207
- */
208
- async findRunByKey(idempotencyKey) {
209
- await this.ensureReady()
210
-
211
- return await this._withDb(async (db) => await this._findRunByKey(db, idempotencyKey))
212
- }
213
-
214
- /**
215
- * Creates a run fenced by two advisory locks taken in a consistent global
216
- * order — first the project/stage deployment lock, then the idempotency-key
217
- * lock — so concurrent requests get a deterministic outcome instead of
218
- * duplicate deployments or unique-constraint errors: an identical existing
219
- * key replays, a different payload for an existing key conflicts, and an
220
- * active run for the same project/stage blocks unless its ownership lease
221
- * expired, in which case it is terminally reconciled as interrupted first.
222
- * @param {object} args - Options.
223
- * @param {string} args.idempotencyKey - Idempotency key.
224
- * @param {string} args.project - Project identifier.
225
- * @param {string} args.revision - Requested revision.
226
- * @param {string} args.stage - Stage identifier.
227
- * @returns {Promise<CreateRunOutcome>} - The outcome and the relevant run.
228
- */
229
- async createRunIfPossible({idempotencyKey, project, revision, stage}) {
230
- await this.ensureReady()
231
-
232
- return await this._withDb(async (db) => {
233
- const lockName = this._deploymentLockName({project, stage})
234
- const acquired = await db.tryAcquireAdvisoryLock(lockName)
235
-
236
- if (!acquired) {
237
- // The lock being held proves another request is mid-creation for this
238
- // project/stage, so this is a bounded conflict even before the active
239
- // run row is visible.
240
- return {outcome: "in_progress", run: await this._findBlockingRun(db, {project, stage})}
241
- }
242
-
243
- try {
244
- // The key lock serializes this mount's idempotency key across different
245
- // project/stage pairs; always taken after the deployment lock so lock
246
- // ordering can never deadlock.
247
- const keyLockName = this._idempotencyLockName(idempotencyKey)
248
-
249
- await db.acquireAdvisoryLock(keyLockName)
250
-
251
- try {
252
- const existing = await this._findRunByKey(db, idempotencyKey)
253
-
254
- if (existing) {
255
- const samePayload = existing.project === project && existing.stage === stage && existing.revision === revision
256
-
257
- return {outcome: samePayload ? "replay" : "conflict", run: existing}
258
- }
259
-
260
- const activeRun = await this._findBlockingRun(db, {project, stage})
261
-
262
- if (activeRun) {
263
- if (activeRun.status === "reconciliation_required") {
264
- return {outcome: "reconciliation_required", run: activeRun}
265
- }
266
-
267
- if (!this._isRunInterrupted(activeRun)) return {outcome: "in_progress", run: activeRun}
268
-
269
- await this._reconcileExpiredRun(db, activeRun)
270
-
271
- if (activeRun.status === "running") {
272
- const reconciledRun = await this._findRunById(db, activeRun.id)
273
-
274
- if (!reconciledRun) throw new Error(`Reconciled deployment run ${activeRun.id} could not be found`)
275
-
276
- return {outcome: "reconciliation_required", run: reconciledRun}
277
- }
278
- }
279
-
280
- const id = randomUUID()
281
-
282
- await db.insert({
283
- tableName: RUNS_TABLE,
284
- data: {
285
- id,
286
- mount_identifier: this.mountIdentifier,
287
- project,
288
- stage,
289
- revision,
290
- idempotency_key: idempotencyKey,
291
- status: "pending",
292
- result_json: null,
293
- error_json: null,
294
- requested_at_ms: Date.now(),
295
- started_at_ms: null,
296
- finished_at_ms: null,
297
- owner_token: PROCESS_OWNER_TOKEN,
298
- heartbeat_at_ms: null
299
- }
300
- })
301
-
302
- const run = await this._findRunById(db, id)
303
-
304
- if (!run) throw new Error(`Deployment run ${id} was not found right after insertion`)
305
-
306
- return {outcome: "created", run}
307
- } finally {
308
- await db.releaseAdvisoryLock(keyLockName)
309
- }
310
- } finally {
311
- await db.releaseAdvisoryLock(lockName)
312
- }
313
- })
314
- }
315
-
316
- /**
317
- * Marks a run as running.
318
- * @param {object} args - Options.
319
- * @param {string} args.id - Run id.
320
- * @param {number} args.startedAtMs - Start time in ms epoch.
321
- * @returns {Promise<void>} - Resolves when updated.
322
- */
323
- async markRunning({id, startedAtMs}) {
324
- await this.ensureReady()
325
- await this._withDb(async (db) => {
326
- const affected = await db.affectedRows(db.updateSql({
327
- tableName: RUNS_TABLE,
328
- data: {heartbeat_at_ms: startedAtMs, started_at_ms: startedAtMs, status: "running"},
329
- conditions: {
330
- id,
331
- mount_identifier: this.mountIdentifier,
332
- owner_token: PROCESS_OWNER_TOKEN,
333
- status: "pending"
334
- }
335
- }))
336
-
337
- if (affected !== 1) {
338
- throw new Error(`Expected to mark exactly one pending deployment run with id ${id} as running for this process owner, but updated ${affected}`)
339
- }
340
- })
341
- }
342
-
343
- /**
344
- * Renews the ownership lease of a run executing in this process. A no-op
345
- * when the run already reached a terminal state.
346
- * @param {object} args - Options.
347
- * @param {string} args.id - Run id.
348
- * @param {number} args.heartbeatAtMs - Heartbeat time in ms epoch.
349
- * @returns {Promise<void>} - Resolves when updated.
350
- */
351
- async heartbeat({heartbeatAtMs, id}) {
352
- await this.ensureReady()
353
- await this._withDb(async (db) => {
354
- await db.affectedRows(db.updateSql({
355
- tableName: RUNS_TABLE,
356
- data: {heartbeat_at_ms: heartbeatAtMs},
357
- conditions: {id, mount_identifier: this.mountIdentifier, owner_token: PROCESS_OWNER_TOKEN, status: "running"}
358
- }))
359
- })
360
- }
361
-
362
- /**
363
- * Marks a run as succeeded with its sanitized result.
364
- * @param {object} args - Options.
365
- * @param {string} args.id - Run id.
366
- * @param {string} args.ownerToken - Expected execution owner token.
367
- * @param {number} args.finishedAtMs - Finish time in ms epoch.
368
- * @param {?} args.result - Sanitized adapter report.
369
- * @returns {Promise<void>} - Resolves when updated.
370
- */
371
- async markSucceeded({finishedAtMs, id, ownerToken, result}) {
372
- await this._transitionRunningRun({
373
- data: {finished_at_ms: finishedAtMs, result_json: JSON.stringify(result ?? null), status: "succeeded"},
374
- id,
375
- ownerToken
376
- })
377
- }
378
-
379
- /**
380
- * Marks a run as failed with its sanitized failure payload.
381
- * @param {object} args - Options.
382
- * @param {string} args.id - Run id.
383
- * @param {string} args.ownerToken - Expected execution owner token.
384
- * @param {number} args.finishedAtMs - Finish time in ms epoch.
385
- * @param {?} args.error - Sanitized failure payload.
386
- * @returns {Promise<void>} - Resolves when updated.
387
- */
388
- async markFailed({error, finishedAtMs, id, ownerToken}) {
389
- await this._transitionRunningRun({
390
- data: {error_json: JSON.stringify(error ?? null), finished_at_ms: finishedAtMs, status: "failed"},
391
- id,
392
- ownerToken
393
- })
394
- }
395
-
396
- /**
397
- * Fences a run after the adapter may have activated externally but its
398
- * successful result could not be durably recorded. This state blocks future
399
- * deployments until an operator reconciles the external outcome.
400
- * @param {object} args - Options.
401
- * @param {string} args.id - Run id.
402
- * @param {string} args.ownerToken - Expected execution owner token.
403
- * @param {number} args.finishedAtMs - Time reconciliation became necessary in ms epoch.
404
- * @param {?} args.error - Sanitized operator-facing explanation.
405
- * @returns {Promise<void>} - Resolves when updated.
406
- */
407
- async markReconciliationRequired({error, finishedAtMs, id, ownerToken}) {
408
- await this._transitionRunningRun({
409
- data: {error_json: JSON.stringify(error ?? null), finished_at_ms: finishedAtMs, status: "reconciliation_required"},
410
- id,
411
- ownerToken
412
- })
413
- }
414
-
415
- /**
416
- * Records a sanitized audit event.
417
- * @param {object} args - Options.
418
- * @param {string} args.event - Event name.
419
- * @param {?} args.payload - Sanitized payload.
420
- * @param {string | null} args.runId - Owning run id.
421
- * @returns {Promise<void>} - Resolves when recorded.
422
- */
423
- async addAuditEvent({event, payload, runId}) {
424
- await this.ensureReady()
425
- await this._withDb(async (db) => {
426
- await db.insert({
427
- tableName: AUDIT_TABLE,
428
- data: {
429
- id: randomUUID(),
430
- mount_identifier: this.mountIdentifier,
431
- run_id: runId,
432
- event,
433
- payload_json: payload === undefined ? null : JSON.stringify(payload ?? null),
434
- created_at_ms: Date.now()
435
- }
436
- })
437
- })
438
- }
439
-
440
- /**
441
- * Lists audit events for a run in creation order.
442
- * @param {object} args - Options.
443
- * @param {string} args.runId - Run id.
444
- * @returns {Promise<DeploymentAuditEventRow[]>} - Audit events.
445
- */
446
- async listAuditEvents({runId}) {
447
- await this.ensureReady()
448
-
449
- return await this._withDb(async (db) => {
450
- const rows = await db
451
- .newQuery()
452
- .from(AUDIT_TABLE)
453
- .where({mount_identifier: this.mountIdentifier, run_id: runId})
454
- .order("created_at_ms ASC")
455
- .results()
456
-
457
- return rows.map((row) => this._auditRowFromDb(row))
458
- })
459
- }
460
-
461
- /**
462
- * Builds the advisory-lock name for a project/stage pair. Hashed so the name
463
- * stays within driver limits (MySQL/MariaDB cap lock names at 64 chars)
464
- * regardless of identifier length.
465
- * @param {object} args - Options.
466
- * @param {string} args.project - Project identifier.
467
- * @param {string} args.stage - Stage identifier.
468
- * @returns {string} - Bounded advisory-lock name.
469
- */
470
- _deploymentLockName({project, stage}) {
471
- const hash = createHash("sha256").update(`${this.mountIdentifier}:${project}:${stage}`).digest("hex").slice(0, 32)
472
-
473
- return `velocious-deployment-api:${hash}`
474
- }
475
-
476
- /**
477
- * Builds the advisory-lock name serializing one idempotency key within this mount.
478
- * @param {string} idempotencyKey - Idempotency key.
479
- * @returns {string} - Bounded advisory-lock name.
480
- */
481
- _idempotencyLockName(idempotencyKey) {
482
- const hash = createHash("sha256").update(`${this.mountIdentifier}:${idempotencyKey}`).digest("hex").slice(0, 32)
483
-
484
- return `velocious-deployment-api:key:${hash}`
485
- }
486
-
487
- /**
488
- * Decides whether an active run counts as interrupted: its owner lease
489
- * (heartbeat, or the request time when it never started) is older than the
490
- * configured timeout and it is not executing in this process. A run with a
491
- * fresh lease owned by another worker is genuinely active and must never be
492
- * reclaimed here.
493
- * @param {DeploymentRunRow} run - Active run.
494
- * @returns {boolean} - Whether the run is interrupted.
495
- */
496
- _isRunInterrupted(run) {
497
- if (activeRunIdsInProcess.has(run.id)) return false
498
-
499
- const leaseReferenceMs = run.heartbeatAtMs ?? run.requestedAtMs
500
-
501
- return Date.now() - leaseReferenceMs > this.staleRunTimeoutMs
502
- }
503
-
504
- /**
505
- * Reconciles an expired ownership lease. Pending work is safely interrupted
506
- * because external activation never began. Running work requires operator
507
- * reconciliation because the external outcome may already have changed.
508
- * Runs inside the project/stage deployment lock, so exactly one reconciler
509
- * wins.
510
- * @param {import("../database/drivers/base.js").default} db - Database connection.
511
- * @param {DeploymentRunRow} run - Interrupted run.
512
- * @returns {Promise<void>} - Resolves when reconciled.
513
- */
514
- async _reconcileExpiredRun(db, run) {
515
- const finishedAtMs = Date.now()
516
- const reconciliationRequired = run.status === "running"
517
- const errorPayload = reconciliationRequired
518
- ? {message: "Deployment outcome requires operator reconciliation after the execution owner lease expired"}
519
- : {message: "Deployment run was interrupted before execution began (ownership lease expired)"}
520
- const status = reconciliationRequired ? "reconciliation_required" : "interrupted"
521
- const event = reconciliationRequired ? "run_reconciliation_required" : "run_interrupted"
522
- const data = {error_json: JSON.stringify(errorPayload), finished_at_ms: finishedAtMs, status}
523
- const affected = await db.affectedRows(db.updateSql({
524
- tableName: RUNS_TABLE,
525
- data,
526
- conditions: {id: run.id, mount_identifier: this.mountIdentifier, owner_token: run.ownerToken, status: run.status}
527
- }))
528
-
529
- if (affected !== 1) {
530
- throw new Error(`Expected to reconcile exactly one expired deployment run with id ${run.id}, but updated ${affected}`)
531
- }
532
-
533
- await db.insert({
534
- tableName: AUDIT_TABLE,
535
- data: {
536
- id: randomUUID(),
537
- mount_identifier: this.mountIdentifier,
538
- run_id: run.id,
539
- event,
540
- payload_json: JSON.stringify({project: run.project, revision: run.revision, stage: run.stage}),
541
- created_at_ms: Date.now()
542
- }
543
- })
544
- }
545
-
546
- /**
547
- * Adds the ownership lease columns to a runs table created before they
548
- * existed (the background-jobs store column-upgrade precedent).
549
- * @param {import("../database/drivers/base.js").default} db - Database connection.
550
- * @returns {Promise<void>} - Resolves when the columns exist.
551
- */
552
- async _ensureRunColumns(db) {
553
- const table = await db.getTableByNameOrFail(RUNS_TABLE)
554
- const missingMountIdentifier = !(await table.getColumnByName("mount_identifier"))
555
- const missingOwnerToken = !(await table.getColumnByName("owner_token"))
556
- const missingHeartbeat = !(await table.getColumnByName("heartbeat_at_ms"))
557
-
558
- if (missingMountIdentifier || missingOwnerToken || missingHeartbeat) {
559
- const tableData = new TableData(RUNS_TABLE)
560
-
561
- if (missingMountIdentifier) tableData.string("mount_identifier", {maxLength: 64, null: true})
562
- if (missingOwnerToken) tableData.string("owner_token", {null: true})
563
- if (missingHeartbeat) tableData.bigint("heartbeat_at_ms", {null: true})
564
-
565
- const sqls = await db.alterTableSQLs(tableData)
566
-
567
- for (const sql of sqls) {
568
- await db.query(sql)
569
- }
570
-
571
- db.clearSchemaCache()
572
- }
573
-
574
- await this._quarantineLegacyRows(db, RUNS_TABLE)
575
- }
576
-
577
- /**
578
- * Adds and safely backfills a mount identifier on an existing lazily-owned table.
579
- * Legacy rows were not attributable to an authenticated mount, so they are
580
- * quarantined in an unreachable scope instead of being exposed to whichever
581
- * mount happens to initialize first.
582
- * @param {import("../database/drivers/base.js").default} db - Database connection.
583
- * @param {string} tableName - Lazily-owned table name.
584
- * @returns {Promise<void>} - Resolves when the column exists.
585
- */
586
- async _ensureMountIdentifierColumn(db, tableName) {
587
- const table = await db.getTableByNameOrFail(tableName)
588
-
589
- if (!(await table.getColumnByName("mount_identifier"))) {
590
- const tableData = new TableData(tableName)
591
-
592
- tableData.string("mount_identifier", {maxLength: 64, null: true})
593
-
594
- for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
595
-
596
- db.clearSchemaCache()
597
- }
598
-
599
- await this._quarantineLegacyRows(db, tableName)
600
- }
601
-
602
- /**
603
- * Assigns pre-scope rows to a reserved identifier no real mount can use.
604
- * @param {import("../database/drivers/base.js").default} db - Database connection.
605
- * @param {string} tableName - Table containing legacy rows.
606
- * @returns {Promise<void>} - Resolves when legacy rows are quarantined.
607
- */
608
- async _quarantineLegacyRows(db, tableName) {
609
- await db.affectedRows(db.updateSql({
610
- tableName,
611
- data: {mount_identifier: LEGACY_UNSCOPED_MOUNT_IDENTIFIER},
612
- conditions: {mount_identifier: null}
613
- }))
614
- }
615
-
616
- /**
617
- * Replaces the legacy global idempotency-key uniqueness with mount-scoped
618
- * uniqueness. The scoped index is created before the old one is removed, so
619
- * an interrupted lazy upgrade never leaves idempotency keys unfenced.
620
- * @param {import("../database/drivers/base.js").default} db - Database connection.
621
- * @returns {Promise<void>} - Resolves when index ownership is scoped.
622
- */
623
- async _ensureScopedIdempotencyIndex(db) {
624
- db.clearSchemaCache()
625
-
626
- const table = await db.getTableByNameOrFail(RUNS_TABLE)
627
- const indexes = await table.getIndexes()
628
- const scopedIndex = indexes.find((index) => {
629
- return index.isUnique() && index.getColumnNames().join(",") === "mount_identifier,idempotency_key"
630
- })
631
-
632
- if (!scopedIndex) {
633
- const sqls = await db.createIndexSQLs({
634
- columns: ["mount_identifier", "idempotency_key"],
635
- name: IDEMPOTENCY_INDEX_NAME,
636
- tableName: RUNS_TABLE,
637
- unique: true
638
- })
639
-
640
- for (const sql of sqls) await db.query(sql)
641
-
642
- db.clearSchemaCache()
643
- }
644
-
645
- const refreshedTable = await db.getTableByNameOrFail(RUNS_TABLE)
646
- const legacyIndexes = (await refreshedTable.getIndexes()).filter((index) => {
647
- return !index.isPrimaryKey() && index.isUnique() && index.getColumnNames().join(",") === "idempotency_key"
648
- })
649
-
650
- for (const legacyIndex of legacyIndexes) {
651
- const sqls = await db.removeIndexSQLs({name: legacyIndex.getName(), tableName: RUNS_TABLE})
652
-
653
- for (const sql of sqls) await db.query(sql)
654
- }
655
-
656
- if (legacyIndexes.length > 0) db.clearSchemaCache()
657
- }
658
-
659
- /**
660
- * Applies a terminal transition only while the expected execution owner
661
- * still owns a running row. A stale worker therefore cannot overwrite a
662
- * state already written by lease reconciliation.
663
- * @param {object} args - Options.
664
- * @param {Record<string, ?>} args.data - Terminal column values.
665
- * @param {string} args.id - Run id.
666
- * @param {string} args.ownerToken - Expected execution owner token.
667
- * @returns {Promise<void>} - Resolves when updated.
668
- */
669
- async _transitionRunningRun({data, id, ownerToken}) {
670
- await this.ensureReady()
671
- await this._withDb(async (db) => {
672
- const affected = await db.affectedRows(db.updateSql({
673
- tableName: RUNS_TABLE,
674
- data,
675
- conditions: {id, mount_identifier: this.mountIdentifier, owner_token: ownerToken, status: "running"}
676
- }))
677
-
678
- if (affected !== 1) {
679
- throw new Error(`Expected to transition exactly one running deployment run with id ${id} for its execution owner, but updated ${affected}`)
680
- }
681
- })
682
- }
683
-
684
- /**
685
- * Finds a run by id on an open connection.
686
- * @param {import("../database/drivers/base.js").default} db - Database connection.
687
- * @param {string} id - Run id.
688
- * @returns {Promise<DeploymentRunRow | null>} - The run or null.
689
- */
690
- async _findRunById(db, id) {
691
- const rows = await db
692
- .newQuery()
693
- .from(RUNS_TABLE)
694
- .where({id, mount_identifier: this.mountIdentifier})
695
- .limit(1)
696
- .results()
697
-
698
- return rows[0] ? this._runRowFromDb(rows[0]) : null
699
- }
700
-
701
- /**
702
- * Finds a run by idempotency key on an open connection.
703
- * @param {import("../database/drivers/base.js").default} db - Database connection.
704
- * @param {string} idempotencyKey - Idempotency key.
705
- * @returns {Promise<DeploymentRunRow | null>} - The run or null.
706
- */
707
- async _findRunByKey(db, idempotencyKey) {
708
- const rows = await db
709
- .newQuery()
710
- .from(RUNS_TABLE)
711
- .where({idempotency_key: idempotencyKey, mount_identifier: this.mountIdentifier})
712
- .limit(1)
713
- .results()
714
-
715
- return rows[0] ? this._runRowFromDb(rows[0]) : null
716
- }
717
-
718
- /**
719
- * Finds the run blocking another deployment for a project/stage.
720
- * @param {import("../database/drivers/base.js").default} db - Database connection.
721
- * @param {object} args - Options.
722
- * @param {string} args.project - Project identifier.
723
- * @param {string} args.stage - Stage identifier.
724
- * @returns {Promise<DeploymentRunRow | null>} - The blocking run or null.
725
- */
726
- async _findBlockingRun(db, {project, stage}) {
727
- const rows = await db
728
- .newQuery()
729
- .from(RUNS_TABLE)
730
- .where({mount_identifier: this.mountIdentifier, project, stage, status: BLOCKING_STATUSES})
731
- .order("requested_at_ms ASC")
732
- .limit(1)
733
- .results()
734
-
735
- return rows[0] ? this._runRowFromDb(rows[0]) : null
736
- }
737
-
738
- /**
739
- * Normalizes a raw run row.
740
- * @param {?} row - Raw database row.
741
- * @returns {DeploymentRunRow} - Normalized run row.
742
- */
743
- _runRowFromDb(row) {
744
- const record = /** @type {Record<string, ?>} */ (row)
745
-
746
- return {
747
- id: /** @type {string} */ (record.id),
748
- mountIdentifier: /** @type {string} */ (record.mount_identifier),
749
- project: /** @type {string} */ (record.project),
750
- stage: /** @type {string} */ (record.stage),
751
- revision: /** @type {string} */ (record.revision),
752
- idempotencyKey: /** @type {string} */ (record.idempotency_key),
753
- status: /** @type {string} */ (record.status),
754
- result: parseJsonColumn(record.result_json),
755
- error: parseJsonColumn(record.error_json),
756
- requestedAtMs: Number(record.requested_at_ms),
757
- startedAtMs: record.started_at_ms === null || record.started_at_ms === undefined ? null : Number(record.started_at_ms),
758
- finishedAtMs: record.finished_at_ms === null || record.finished_at_ms === undefined ? null : Number(record.finished_at_ms),
759
- ownerToken: record.owner_token === null || record.owner_token === undefined ? null : /** @type {string} */ (record.owner_token),
760
- heartbeatAtMs: record.heartbeat_at_ms === null || record.heartbeat_at_ms === undefined ? null : Number(record.heartbeat_at_ms)
761
- }
762
- }
763
-
764
- /**
765
- * Normalizes a raw audit event row.
766
- * @param {?} row - Raw database row.
767
- * @returns {DeploymentAuditEventRow} - Normalized audit event row.
768
- */
769
- _auditRowFromDb(row) {
770
- const record = /** @type {Record<string, ?>} */ (row)
771
-
772
- return {
773
- id: /** @type {string} */ (record.id),
774
- runId: record.run_id === null || record.run_id === undefined ? null : /** @type {string} */ (record.run_id),
775
- event: /** @type {string} */ (record.event),
776
- payload: parseJsonColumn(record.payload_json),
777
- createdAtMs: Number(record.created_at_ms)
778
- }
779
- }
780
-
781
- /**
782
- * Runs a callback with a pooled connection.
783
- * @template T
784
- * @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Callback.
785
- * @returns {Promise<T>} - Callback result.
786
- */
787
- async _withDb(callback) {
788
- const pool = this.configuration.getDatabasePool(this.databaseIdentifier)
789
- /** @type {T | undefined} */
790
- let result
791
-
792
- await pool.withConnection({name: "Deployment run store"}, async (db) => {
793
- result = await callback(db)
794
- })
795
-
796
- return /** @type {T} */ (result)
797
- }
798
- }