@kici-dev/shared 0.0.0 → 0.1.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.
Files changed (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1 -6
  3. package/dist/chunk-gOLHoazu.js +4 -0
  4. package/dist/cold-store/bucket.d.ts +26 -0
  5. package/dist/cold-store/bucket.js +40 -0
  6. package/dist/cold-store/bucket.test.d.ts +2 -0
  7. package/dist/cold-store/chunk-encoder.d.ts +63 -0
  8. package/dist/cold-store/chunk-encoder.js +94 -0
  9. package/dist/cold-store/chunk-encoder.test.d.ts +2 -0
  10. package/dist/cold-store/chunk-id.d.ts +10 -0
  11. package/dist/cold-store/chunk-id.js +29 -0
  12. package/dist/cold-store/chunk-id.test.d.ts +2 -0
  13. package/dist/cold-store/cold-store.d.ts +345 -0
  14. package/dist/cold-store/cold-store.js +1078 -0
  15. package/dist/cold-store/cold-store.test.d.ts +2 -0
  16. package/dist/cold-store/config.d.ts +62 -0
  17. package/dist/cold-store/config.js +28 -0
  18. package/dist/cold-store/index.d.ts +20 -0
  19. package/dist/cold-store/index.js +11 -0
  20. package/dist/cold-store/key.d.ts +89 -0
  21. package/dist/cold-store/key.js +88 -0
  22. package/dist/cold-store/key.test.d.ts +2 -0
  23. package/dist/cold-store/lru.d.ts +33 -0
  24. package/dist/cold-store/lru.js +59 -0
  25. package/dist/cold-store/lru.test.d.ts +2 -0
  26. package/dist/cold-store/manifest.d.ts +11 -0
  27. package/dist/cold-store/manifest.js +57 -0
  28. package/dist/cold-store/manifest.test.d.ts +2 -0
  29. package/dist/cold-store/metrics.d.ts +103 -0
  30. package/dist/cold-store/metrics.js +179 -0
  31. package/dist/cold-store/table-adapter.d.ts +229 -0
  32. package/dist/cold-store/table-adapter.js +2 -0
  33. package/dist/cold-store/types.d.ts +111 -0
  34. package/dist/cold-store/types.js +2 -0
  35. package/dist/crypto.d.ts +33 -0
  36. package/dist/crypto.js +67 -0
  37. package/dist/db-admin.d.ts +1422 -0
  38. package/dist/db-admin.js +2525 -0
  39. package/dist/db-admin.test.d.ts +2 -0
  40. package/dist/db.d.ts +14 -0
  41. package/dist/db.js +23 -0
  42. package/dist/env/allowlist.d.ts +79 -0
  43. package/dist/env/allowlist.js +86 -0
  44. package/dist/env/define-env.d.ts +172 -0
  45. package/dist/env/define-env.js +295 -0
  46. package/dist/env/define-env.test.d.ts +2 -0
  47. package/dist/env/env-rule-allowlist.test.d.ts +2 -0
  48. package/dist/env/index.d.ts +11 -0
  49. package/dist/env/index.js +4 -0
  50. package/dist/env/logger-env.d.ts +30 -0
  51. package/dist/env/logger-env.js +95 -0
  52. package/dist/error.d.ts +16 -0
  53. package/dist/error.js +58 -0
  54. package/dist/error.test.d.ts +2 -0
  55. package/dist/format-bytes.d.ts +5 -0
  56. package/dist/format-bytes.js +15 -0
  57. package/dist/format-bytes.test.d.ts +2 -0
  58. package/dist/format-duration.d.ts +11 -0
  59. package/dist/format-duration.js +32 -0
  60. package/dist/format-duration.test.d.ts +2 -0
  61. package/dist/graceful-shutdown.d.ts +58 -0
  62. package/dist/graceful-shutdown.js +71 -0
  63. package/dist/graceful-shutdown.test.d.ts +2 -0
  64. package/dist/idempotency-files.d.ts +113 -0
  65. package/dist/idempotency-files.js +190 -0
  66. package/dist/idempotency-files.test.d.ts +2 -0
  67. package/dist/idempotency.d.ts +46 -0
  68. package/dist/idempotency.js +45 -0
  69. package/dist/idempotency.test.d.ts +2 -0
  70. package/dist/index.d.ts +19 -0
  71. package/dist/index.js +31 -0
  72. package/dist/logger.d.ts +57 -0
  73. package/dist/logger.js +175 -0
  74. package/dist/logger.test.d.ts +2 -0
  75. package/dist/reconnect-delay.d.ts +11 -0
  76. package/dist/reconnect-delay.js +22 -0
  77. package/dist/request-context.d.ts +42 -0
  78. package/dist/request-context.js +37 -0
  79. package/dist/ring-buffer.d.ts +35 -0
  80. package/dist/ring-buffer.js +62 -0
  81. package/dist/ring-buffer.test.d.ts +2 -0
  82. package/dist/routes/health.d.ts +26 -0
  83. package/dist/routes/health.js +45 -0
  84. package/dist/routes/health.test.d.ts +2 -0
  85. package/dist/routes/metrics.d.ts +20 -0
  86. package/dist/routes/metrics.js +38 -0
  87. package/dist/s3-client.d.ts +42 -0
  88. package/dist/s3-client.js +25 -0
  89. package/dist/telemetry/index.d.ts +3 -0
  90. package/dist/telemetry/index.js +4 -0
  91. package/dist/telemetry/init.d.ts +20 -0
  92. package/dist/telemetry/init.js +42 -0
  93. package/dist/telemetry/init.test.d.ts +2 -0
  94. package/dist/telemetry/metrics.d.ts +8 -0
  95. package/dist/telemetry/metrics.js +16 -0
  96. package/dist/tool-check.d.ts +33 -0
  97. package/dist/tool-check.js +60 -0
  98. package/dist/tool-check.test.d.ts +2 -0
  99. package/dist/ts-loader-hook.d.ts +26 -0
  100. package/dist/ts-loader-hook.js +48 -0
  101. package/dist/zx.d.ts +8 -0
  102. package/dist/zx.js +78 -0
  103. package/package.json +69 -5
  104. package/sbom.spdx.json +8775 -0
  105. package/index.js +0 -3
@@ -0,0 +1,2525 @@
1
+ import "./chunk-gOLHoazu.js";
2
+ import { createPool } from "./db.js";
3
+ import { createHash } from "node:crypto";
4
+ import pg from "pg";
5
+ //#region src/db-admin.ts
6
+ /**
7
+ * Admin/DB-operations helpers shared between `kici-admin` (orchestrator DB)
8
+ * and `kici-platform-admin` (Platform DB). Each CLI wraps these with its own
9
+ * bundled migration provider and audit-log pattern.
10
+ *
11
+ * All destructive ops require the caller to pass a fully-formed database URL
12
+ * — no parsing of environment variables here. The CLI layer handles that.
13
+ */
14
+ const MIGRATION_HASH_TABLE = "_migration_content_hash";
15
+ /**
16
+ * Parse a libpq-style URL into (adminUrl, dbName, dbOwner).
17
+ *
18
+ * `adminUrl` connects to the `postgres` maintenance DB so callers can run
19
+ * `CREATE DATABASE` / `DROP DATABASE` on `dbName`. `dbOwner` is the URL's
20
+ * username, which becomes the new DB owner when we create it.
21
+ */
22
+ function parseDatabaseUrl(databaseUrl) {
23
+ const url = new URL(databaseUrl);
24
+ const dbName = url.pathname.replace(/^\//, "");
25
+ if (!dbName) throw new Error(`Database URL missing /dbname path component: ${maskDatabaseUrl(databaseUrl)}`);
26
+ const dbOwner = decodeURIComponent(url.username);
27
+ if (!dbOwner) throw new Error(`Database URL missing username: ${maskDatabaseUrl(databaseUrl)}`);
28
+ url.pathname = "/postgres";
29
+ return {
30
+ adminUrl: url.toString(),
31
+ dbName,
32
+ dbOwner
33
+ };
34
+ }
35
+ /**
36
+ * Redact the password from a libpq URL for safe logging.
37
+ */
38
+ function maskDatabaseUrl(databaseUrl) {
39
+ try {
40
+ const url = new URL(databaseUrl);
41
+ if (url.password) url.password = "***";
42
+ return url.toString();
43
+ } catch {
44
+ return "<unparseable database-url>";
45
+ }
46
+ }
47
+ function escapeSqlLiteral(value) {
48
+ return value.replace(/'/g, "''");
49
+ }
50
+ function assertValidIdentifier(name, label) {
51
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`Invalid ${label} identifier: ${name}`);
52
+ }
53
+ async function withAdminPool(adminUrl, fn) {
54
+ const pool = new pg.Pool({
55
+ connectionString: adminUrl,
56
+ max: 1
57
+ });
58
+ try {
59
+ return await fn(pool);
60
+ } finally {
61
+ await pool.end();
62
+ }
63
+ }
64
+ /**
65
+ * DROP `dbName` (if it exists). Terminates existing backend connections
66
+ * so the DROP doesn't block. Idempotent — drops are IF EXISTS.
67
+ *
68
+ * Used by e2e cleanup after a full-lifecycle service-deploy test tears
69
+ * down its isolated database. Shares the same admin-URL + identifier-
70
+ * validation + backend-termination scaffolding as dropAndCreateDatabase
71
+ * so the two helpers cannot drift.
72
+ */
73
+ async function dropDatabaseDirect(databaseUrl) {
74
+ const { adminUrl, dbName } = parseDatabaseUrl(databaseUrl);
75
+ assertValidIdentifier(dbName, "database name");
76
+ await withAdminPool(adminUrl, async (pool) => {
77
+ await pool.query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", [dbName]);
78
+ await pool.query(`DROP DATABASE IF EXISTS "${dbName}"`);
79
+ });
80
+ }
81
+ /**
82
+ * Drop `dbName` (if it exists), then recreate it owned by `owner`. Terminates
83
+ * existing backend connections so the DROP doesn't block.
84
+ */
85
+ async function dropAndCreateDatabase(databaseUrl) {
86
+ const { adminUrl, dbName, dbOwner } = parseDatabaseUrl(databaseUrl);
87
+ assertValidIdentifier(dbName, "database name");
88
+ assertValidIdentifier(dbOwner, "database owner");
89
+ await withAdminPool(adminUrl, async (pool) => {
90
+ await pool.query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", [dbName]);
91
+ await pool.query(`DROP DATABASE IF EXISTS "${dbName}"`);
92
+ await pool.query(`CREATE DATABASE "${dbName}" OWNER "${dbOwner}"`);
93
+ });
94
+ }
95
+ /**
96
+ * CREATE DATABASE IF NOT EXISTS (idempotent). Uses the URL's username as owner.
97
+ */
98
+ async function ensureDatabase(databaseUrl) {
99
+ const { adminUrl, dbName, dbOwner } = parseDatabaseUrl(databaseUrl);
100
+ assertValidIdentifier(dbName, "database name");
101
+ assertValidIdentifier(dbOwner, "database owner");
102
+ return withAdminPool(adminUrl, async (pool) => {
103
+ if ((await pool.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName])).rows.length > 0) return "exists";
104
+ await pool.query(`CREATE DATABASE "${dbName}" OWNER "${dbOwner}"`);
105
+ return "created";
106
+ });
107
+ }
108
+ /**
109
+ * CREATE ROLE ... LOGIN [CREATEDB] (idempotent — updates password if role
110
+ * already exists).
111
+ */
112
+ async function createDbRole(adminUrl, opts) {
113
+ assertValidIdentifier(opts.username, "role name");
114
+ return withAdminPool(adminUrl, async (pool) => {
115
+ const exists = await pool.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [opts.username]);
116
+ const pw = escapeSqlLiteral(opts.password);
117
+ if (exists.rows.length === 0) {
118
+ const createDbClause = opts.createDb ? " CREATEDB" : "";
119
+ await pool.query(`CREATE ROLE "${opts.username}" LOGIN${createDbClause} PASSWORD '${pw}'`);
120
+ return "created";
121
+ }
122
+ await pool.query(`ALTER ROLE "${opts.username}" PASSWORD '${pw}'`);
123
+ return "updated";
124
+ });
125
+ }
126
+ /**
127
+ * Create a read-only role and grant SELECT on all tables in the public schema
128
+ * (plus default privileges for tables created later).
129
+ *
130
+ * `databaseUrl` must connect as the DB owner (or superuser) since we need to
131
+ * ALTER DEFAULT PRIVILEGES.
132
+ */
133
+ async function createReadOnlyDbUser(databaseUrl, opts) {
134
+ assertValidIdentifier(opts.username, "role name");
135
+ const { dbName } = parseDatabaseUrl(databaseUrl);
136
+ assertValidIdentifier(dbName, "database name");
137
+ const pool = createPool(databaseUrl);
138
+ try {
139
+ const exists = await pool.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [opts.username]);
140
+ const pw = escapeSqlLiteral(opts.password);
141
+ let outcome;
142
+ if (exists.rows.length === 0) {
143
+ await pool.query(`CREATE ROLE "${opts.username}" LOGIN PASSWORD '${pw}'`);
144
+ outcome = "created";
145
+ } else {
146
+ await pool.query(`ALTER ROLE "${opts.username}" PASSWORD '${pw}'`);
147
+ outcome = "updated";
148
+ }
149
+ await pool.query(`GRANT CONNECT ON DATABASE "${dbName}" TO "${opts.username}"`);
150
+ await pool.query(`GRANT USAGE ON SCHEMA public TO "${opts.username}"`);
151
+ await pool.query(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO "${opts.username}"`);
152
+ await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO "${opts.username}"`);
153
+ return outcome;
154
+ } finally {
155
+ await pool.end();
156
+ }
157
+ }
158
+ /**
159
+ * Compute a stable content hash over all migrations the provider exposes.
160
+ * Uses migration name + the string representation of `up`/`down` (via
161
+ * `Function.toString()`), so brute-force rewrites of a migration body invalidate
162
+ * the hash even when the filename stays the same.
163
+ *
164
+ * Works with any `MigrationProvider` — file-based or the orchestrator /
165
+ * Platform bundled ones.
166
+ */
167
+ async function computeMigrationsHash(provider) {
168
+ const migrations = await provider.getMigrations();
169
+ const names = Object.keys(migrations).sort();
170
+ const hash = createHash("sha256");
171
+ for (const name of names) {
172
+ const migration = migrations[name];
173
+ hash.update(name);
174
+ hash.update("\0");
175
+ hash.update(migration.up.toString());
176
+ hash.update("\0");
177
+ if (migration.down) hash.update(migration.down.toString());
178
+ hash.update("\0");
179
+ }
180
+ return hash.digest("hex");
181
+ }
182
+ /**
183
+ * Marker row key used by the bundled-provider hash (`computeMigrationsHash`).
184
+ *
185
+ * Kept distinct from the legacy e2e file-based hash (which still writes to
186
+ * the row keyed `'kysely_migration'`) so the two algorithms can coexist in
187
+ * the same `_migration_content_hash` table without clobbering each other.
188
+ * `kici-admin db check-schema` / `kici-platform-admin db check-schema` read
189
+ * this row; the e2e `isSchemaCurrent` helper reads the other.
190
+ */
191
+ const PROVIDER_HASH_KEY = "kysely_migration_provider";
192
+ /**
193
+ * Ensure the content-hash marker table exists, then upsert `hash` keyed by
194
+ * `PROVIDER_HASH_KEY`. Paired with `readStoredMigrationContentHash` +
195
+ * `isSchemaCurrent`.
196
+ */
197
+ async function storeMigrationContentHash(pool, hash) {
198
+ await pool.query(`
199
+ CREATE TABLE IF NOT EXISTS ${MIGRATION_HASH_TABLE} (
200
+ table_name text PRIMARY KEY,
201
+ hash text NOT NULL
202
+ )
203
+ `);
204
+ await pool.query(`INSERT INTO ${MIGRATION_HASH_TABLE} (table_name, hash) VALUES ($1, $2) ON CONFLICT (table_name) DO UPDATE SET hash = EXCLUDED.hash`, [PROVIDER_HASH_KEY, hash]);
205
+ }
206
+ /**
207
+ * Read the provider-based content hash, or `null` if the marker table / row
208
+ * doesn't exist.
209
+ */
210
+ async function readStoredMigrationContentHash(pool) {
211
+ try {
212
+ return (await pool.query(`SELECT hash FROM ${MIGRATION_HASH_TABLE} WHERE table_name = $1`, [PROVIDER_HASH_KEY])).rows[0]?.hash ?? null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+ /**
218
+ * TRUNCATE dispatch_queue on the orchestrator DB (direct SQL). Used by the
219
+ * `kici-admin queue clear --database-url ...` direct-DB fallback when the
220
+ * orchestrator isn't reachable over HTTP (e.g. warm-start cleanup before the
221
+ * service restarts).
222
+ */
223
+ async function clearDispatchQueueDirect(databaseUrl) {
224
+ const pool = createPool(databaseUrl);
225
+ try {
226
+ await pool.query("TRUNCATE dispatch_queue");
227
+ } finally {
228
+ await pool.end();
229
+ }
230
+ }
231
+ /**
232
+ * DELETE orphan execution_runs + execution_jobs for routing keys other than
233
+ * `routingKey` (and rows with NULL routing_key). Returns row counts.
234
+ */
235
+ async function purgeStaleExecutionDirect(databaseUrl, routingKey) {
236
+ const pool = createPool(databaseUrl);
237
+ try {
238
+ const concurrencyGroupsResult = await pool.query(`DELETE FROM concurrency_groups
239
+ WHERE routing_key != $1
240
+ OR routing_key IS NULL
241
+ OR run_id NOT IN (
242
+ SELECT run_id FROM execution_runs
243
+ WHERE status IN ('pending', 'running', 'cancelling')
244
+ )`, [routingKey]);
245
+ const jobs = await pool.query(`DELETE FROM execution_jobs
246
+ WHERE run_id IN (
247
+ SELECT run_id FROM execution_runs
248
+ WHERE routing_key != $1 OR routing_key IS NULL
249
+ )`, [routingKey]);
250
+ const runs = await pool.query(`DELETE FROM execution_runs WHERE routing_key != $1 OR routing_key IS NULL`, [routingKey]);
251
+ return {
252
+ jobsDeleted: jobs.rowCount ?? 0,
253
+ runsDeleted: runs.rowCount ?? 0,
254
+ concurrencyGroupsDeleted: concurrencyGroupsResult.rowCount ?? 0
255
+ };
256
+ } finally {
257
+ await pool.end();
258
+ }
259
+ }
260
+ /**
261
+ * DELETE orphan sources + their `__system__`-scoped webhook/private-key
262
+ * secrets, all `generic_webhook_sources` (the table is single-tenant per
263
+ * deployment), and any `workflow_registrations` rows whose routing_key no
264
+ * longer points at an existing source. When `dryRun` is true, only count
265
+ * the rows that would be deleted.
266
+ *
267
+ * Orphan registration cleanup is critical: generic_webhook_sources is wiped
268
+ * wholesale, but workflow_registrations rows previously persisted under those
269
+ * routing keys would otherwise survive and pollute the cross-source dispatch
270
+ * fan-out on the next test run (causing clone attempts against long-dead
271
+ * repo identifiers from earlier tests).
272
+ */
273
+ async function purgeStaleSourcesDirect(databaseUrl, routingKey, dryRun) {
274
+ const pool = createPool(databaseUrl);
275
+ try {
276
+ if (dryRun) {
277
+ const staleSources = await pool.query(`SELECT COUNT(*)::int AS count FROM sources WHERE routing_key != $1`, [routingKey]);
278
+ const staleSecrets = await pool.query(`SELECT COUNT(*)::int AS count FROM scoped_secrets
279
+ WHERE org_id = '__system__'
280
+ AND scope LIKE '__source__/%'
281
+ AND scope NOT IN (
282
+ SELECT '__source__/' || id::text FROM sources WHERE routing_key = $1
283
+ )`, [routingKey]);
284
+ const genericSources = await pool.query(`SELECT COUNT(*)::int AS count FROM generic_webhook_sources`);
285
+ const orphanRegistrations = await pool.query(`SELECT COUNT(*)::int AS count FROM workflow_registrations
286
+ WHERE routing_key != $1
287
+ AND routing_key NOT IN (SELECT routing_key FROM generic_webhook_sources)`, [routingKey]);
288
+ return {
289
+ dryRun: true,
290
+ staleSecrets: staleSecrets.rows[0]?.count ?? 0,
291
+ staleSources: staleSources.rows[0]?.count ?? 0,
292
+ genericSources: genericSources.rows[0]?.count ?? 0,
293
+ orphanRegistrations: orphanRegistrations.rows[0]?.count ?? 0
294
+ };
295
+ }
296
+ const secrets = await pool.query(`DELETE FROM scoped_secrets
297
+ WHERE org_id = '__system__'
298
+ AND scope LIKE '__source__/%'
299
+ AND scope NOT IN (
300
+ SELECT '__source__/' || id::text FROM sources WHERE routing_key = $1
301
+ )`, [routingKey]);
302
+ const sources = await pool.query(`DELETE FROM sources WHERE routing_key != $1`, [routingKey]);
303
+ const generic = await pool.query(`DELETE FROM generic_webhook_sources`);
304
+ const registrations = await pool.query(`DELETE FROM workflow_registrations
305
+ WHERE routing_key != $1
306
+ AND routing_key NOT IN (SELECT routing_key FROM sources)`, [routingKey]);
307
+ return {
308
+ dryRun: false,
309
+ secretsDeleted: secrets.rowCount ?? 0,
310
+ sourcesDeleted: sources.rowCount ?? 0,
311
+ genericDeleted: generic.rowCount ?? 0,
312
+ registrationsDeleted: registrations.rowCount ?? 0
313
+ };
314
+ } finally {
315
+ await pool.end();
316
+ }
317
+ }
318
+ /**
319
+ * Bulk-delete scoped_secrets. Scoped to one org when `orgId` is provided,
320
+ * else all orgs. Returns the number of rows deleted.
321
+ */
322
+ async function purgeScopedSecretsDirect(databaseUrl, orgId) {
323
+ const pool = createPool(databaseUrl);
324
+ try {
325
+ return { deleted: (orgId ? await pool.query(`DELETE FROM scoped_secrets WHERE org_id = $1`, [orgId]) : await pool.query(`DELETE FROM scoped_secrets`)).rowCount ?? 0 };
326
+ } finally {
327
+ await pool.end();
328
+ }
329
+ }
330
+ /**
331
+ * Allowed policy field names for `setEnvironmentPolicyDirect`. Kept as an
332
+ * explicit allowlist so the column-name interpolation in the UPDATE string
333
+ * can never be driven by unsanitised caller input.
334
+ */
335
+ const ENV_POLICY_COLUMNS = new Set([
336
+ "branch_restrictions",
337
+ "required_reviewers",
338
+ "wait_timer_seconds",
339
+ "hold_expiry_seconds",
340
+ "minimum_trust",
341
+ "enabled"
342
+ ]);
343
+ /**
344
+ * Upsert an environment row keyed by (org_id, name). Returns the env id and
345
+ * whether the row was newly inserted. `branchRestrictions` / `requiredReviewers`
346
+ * are JSON-serialised server-side; pass them as plain arrays or objects.
347
+ */
348
+ async function seedEnvironmentDirect(databaseUrl, opts) {
349
+ if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`environment: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
350
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`environment: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
351
+ const pool = new pg.Pool({
352
+ connectionString: databaseUrl,
353
+ max: 1
354
+ });
355
+ try {
356
+ const branchJson = JSON.stringify(opts.branchRestrictions ?? []);
357
+ const reviewersJson = opts.requiredReviewers === void 0 ? null : JSON.stringify(opts.requiredReviewers);
358
+ const row = (await pool.query(`INSERT INTO environments
359
+ (org_id, name, type, enabled, branch_restrictions, required_reviewers,
360
+ wait_timer_seconds, hold_expiry_seconds, minimum_trust)
361
+ VALUES ($1, $2, COALESCE($3, 'fixed'), COALESCE($4, true), $5::jsonb, $6::jsonb,
362
+ $7, COALESCE($8, 86400), $9)
363
+ ON CONFLICT (org_id, name) DO UPDATE SET
364
+ type = COALESCE(EXCLUDED.type, environments.type),
365
+ enabled = EXCLUDED.enabled,
366
+ branch_restrictions = EXCLUDED.branch_restrictions,
367
+ required_reviewers = EXCLUDED.required_reviewers,
368
+ wait_timer_seconds = EXCLUDED.wait_timer_seconds,
369
+ hold_expiry_seconds = EXCLUDED.hold_expiry_seconds,
370
+ minimum_trust = EXCLUDED.minimum_trust,
371
+ updated_at = now()
372
+ RETURNING id, (xmax = 0) AS inserted`, [
373
+ opts.orgId,
374
+ opts.name,
375
+ opts.type ?? null,
376
+ opts.enabled ?? null,
377
+ branchJson,
378
+ reviewersJson,
379
+ opts.waitTimerSeconds ?? null,
380
+ opts.holdExpirySeconds ?? null,
381
+ opts.minimumTrust ?? null
382
+ ])).rows[0];
383
+ if (!row) throw new Error(`environment: upsert returned no row for ${opts.name}`);
384
+ return {
385
+ envId: row.id,
386
+ created: row.inserted
387
+ };
388
+ } finally {
389
+ await pool.end();
390
+ }
391
+ }
392
+ /**
393
+ * Upsert an `environment_bindings` row connecting `envName` to `scopePattern`.
394
+ * Throws if the environment does not exist.
395
+ */
396
+ async function seedEnvironmentBindingDirect(databaseUrl, opts) {
397
+ const pool = new pg.Pool({
398
+ connectionString: databaseUrl,
399
+ max: 1
400
+ });
401
+ try {
402
+ const envRow = await pool.query(`SELECT id FROM environments WHERE org_id = $1 AND name = $2`, [opts.orgId, opts.envName]);
403
+ if (envRow.rows.length === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.envName})`);
404
+ const envId = envRow.rows[0].id;
405
+ return { created: (await pool.query(`INSERT INTO environment_bindings (org_id, environment_id, scope_pattern)
406
+ VALUES ($1, $2, $3)
407
+ ON CONFLICT DO NOTHING
408
+ RETURNING (xmax = 0) AS inserted`, [
409
+ opts.orgId,
410
+ envId,
411
+ opts.scopePattern
412
+ ])).rows[0]?.inserted ?? false };
413
+ } finally {
414
+ await pool.end();
415
+ }
416
+ }
417
+ /**
418
+ * UPDATE only the policy fields that were explicitly provided. Columns that
419
+ * were NOT in `opts` are left untouched. Throws if the environment is missing.
420
+ */
421
+ async function setEnvironmentPolicyDirect(databaseUrl, opts) {
422
+ if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`environment: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
423
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`environment: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
424
+ const setClauses = [];
425
+ const params = [];
426
+ let idx = 1;
427
+ const addSet = (column, value, cast) => {
428
+ if (!ENV_POLICY_COLUMNS.has(column)) throw new Error(`environment: unknown policy column ${column}`);
429
+ setClauses.push(`${column} = $${idx}${cast ? `::${cast}` : ""}`);
430
+ params.push(value);
431
+ idx += 1;
432
+ };
433
+ if (opts.branchRestrictions !== void 0) addSet("branch_restrictions", JSON.stringify(opts.branchRestrictions), "jsonb");
434
+ if (opts.requiredReviewers !== void 0) addSet("required_reviewers", opts.requiredReviewers === null ? null : JSON.stringify(opts.requiredReviewers), "jsonb");
435
+ if (opts.waitTimerSeconds !== void 0) addSet("wait_timer_seconds", opts.waitTimerSeconds);
436
+ if (opts.holdExpirySeconds !== void 0) addSet("hold_expiry_seconds", opts.holdExpirySeconds);
437
+ if (opts.minimumTrust !== void 0) addSet("minimum_trust", opts.minimumTrust);
438
+ if (opts.enabled !== void 0) addSet("enabled", opts.enabled);
439
+ if (setClauses.length === 0) throw new Error("environment: setEnvironmentPolicy requires at least one policy field");
440
+ const pool = new pg.Pool({
441
+ connectionString: databaseUrl,
442
+ max: 1
443
+ });
444
+ try {
445
+ params.push(opts.orgId, opts.envName);
446
+ const orgParam = `$${idx}`;
447
+ const nameParam = `$${idx + 1}`;
448
+ const sql = `UPDATE environments
449
+ SET ${setClauses.join(", ")}, updated_at = now()
450
+ WHERE org_id = ${orgParam} AND name = ${nameParam}`;
451
+ if (((await pool.query(sql, params)).rowCount ?? 0) === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.envName})`);
452
+ } finally {
453
+ await pool.end();
454
+ }
455
+ }
456
+ /**
457
+ * SELECT * FROM environments WHERE org_id = $1, ordered by name.
458
+ */
459
+ async function listEnvironmentsDirect(databaseUrl, opts) {
460
+ const pool = new pg.Pool({
461
+ connectionString: databaseUrl,
462
+ max: 1
463
+ });
464
+ try {
465
+ return { environments: (await pool.query(`SELECT id, org_id, name, type, enabled, branch_restrictions, required_reviewers,
466
+ wait_timer_seconds, hold_expiry_seconds, minimum_trust,
467
+ created_at, updated_at
468
+ FROM environments
469
+ WHERE org_id = $1
470
+ ORDER BY name`, [opts.orgId])).rows };
471
+ } finally {
472
+ await pool.end();
473
+ }
474
+ }
475
+ /**
476
+ * Fetch a single environment row joined with its variables and bindings.
477
+ * Throws if the environment does not exist.
478
+ */
479
+ async function showEnvironmentDirect(databaseUrl, opts) {
480
+ const pool = new pg.Pool({
481
+ connectionString: databaseUrl,
482
+ max: 1
483
+ });
484
+ try {
485
+ const envResult = await pool.query(`SELECT id, org_id, name, type, enabled, branch_restrictions, required_reviewers,
486
+ wait_timer_seconds, hold_expiry_seconds, minimum_trust,
487
+ created_at, updated_at
488
+ FROM environments
489
+ WHERE org_id = $1 AND name = $2`, [opts.orgId, opts.name]);
490
+ if (envResult.rows.length === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.name})`);
491
+ const env = envResult.rows[0];
492
+ const variables = await pool.query(`SELECT key, value, locked, updated_at
493
+ FROM environment_variables
494
+ WHERE environment_id = $1
495
+ ORDER BY key`, [env.id]);
496
+ const bindings = await pool.query(`SELECT scope_pattern, created_at
497
+ FROM environment_bindings
498
+ WHERE environment_id = $1
499
+ ORDER BY scope_pattern`, [env.id]);
500
+ return {
501
+ environment: env,
502
+ variables: variables.rows,
503
+ bindings: bindings.rows
504
+ };
505
+ } finally {
506
+ await pool.end();
507
+ }
508
+ }
509
+ /**
510
+ * Create (or update) an environment template + its seed variables in one
511
+ * transaction. Templates are represented as environments with `type='template'`
512
+ * by convention. Returns `{ envId, variablesSet }`.
513
+ */
514
+ async function createEnvironmentTemplateDirect(databaseUrl, opts) {
515
+ const pool = new pg.Pool({
516
+ connectionString: databaseUrl,
517
+ max: 1
518
+ });
519
+ const client = await pool.connect();
520
+ try {
521
+ await client.query("BEGIN");
522
+ const row = (await client.query(`INSERT INTO environments
523
+ (org_id, name, type, enabled, branch_restrictions, required_reviewers,
524
+ wait_timer_seconds, hold_expiry_seconds, minimum_trust)
525
+ VALUES ($1, $2, COALESCE($3, 'template'), true, $4::jsonb, $5::jsonb, $6, COALESCE($7, 86400), $8)
526
+ ON CONFLICT (org_id, name) DO UPDATE SET
527
+ type = COALESCE(EXCLUDED.type, environments.type),
528
+ branch_restrictions = EXCLUDED.branch_restrictions,
529
+ required_reviewers = EXCLUDED.required_reviewers,
530
+ wait_timer_seconds = EXCLUDED.wait_timer_seconds,
531
+ hold_expiry_seconds = EXCLUDED.hold_expiry_seconds,
532
+ minimum_trust = EXCLUDED.minimum_trust,
533
+ updated_at = now()
534
+ RETURNING id, (xmax = 0) AS inserted`, [
535
+ opts.orgId,
536
+ opts.templateName,
537
+ opts.type ?? null,
538
+ JSON.stringify(opts.branchRestrictions ?? []),
539
+ opts.requiredReviewers === void 0 ? null : JSON.stringify(opts.requiredReviewers),
540
+ opts.waitTimerSeconds ?? null,
541
+ opts.holdExpirySeconds ?? null,
542
+ opts.minimumTrust ?? null
543
+ ])).rows[0];
544
+ if (!row) throw new Error(`environment: template upsert returned no row`);
545
+ let variablesSet = 0;
546
+ if (opts.variables) for (const [key, value] of Object.entries(opts.variables)) {
547
+ await client.query(`INSERT INTO environment_variables (org_id, environment_id, key, value, locked)
548
+ VALUES ($1, $2, $3, $4, false)
549
+ ON CONFLICT (org_id, environment_id, key) DO UPDATE SET
550
+ value = EXCLUDED.value,
551
+ updated_at = now()`, [
552
+ opts.orgId,
553
+ row.id,
554
+ key,
555
+ value
556
+ ]);
557
+ variablesSet += 1;
558
+ }
559
+ await client.query("COMMIT");
560
+ return {
561
+ envId: row.id,
562
+ created: row.inserted,
563
+ variablesSet
564
+ };
565
+ } catch (err) {
566
+ await client.query("ROLLBACK").catch(() => void 0);
567
+ throw err;
568
+ } finally {
569
+ client.release();
570
+ await pool.end();
571
+ }
572
+ }
573
+ /**
574
+ * UPSERT a scoped_secrets row keyed by (org_id, scope=environment, key).
575
+ * Writes the value verbatim — the caller is responsible for encryption
576
+ * (matches the stage-4 deferral noted in the plan).
577
+ */
578
+ async function setEnvironmentSecretDirect(databaseUrl, opts) {
579
+ if (!opts.orgId) throw new Error("environment: orgId required");
580
+ if (!opts.environment) throw new Error("environment: environment name required");
581
+ if (!opts.key) throw new Error("environment: key required");
582
+ const pool = new pg.Pool({
583
+ connectionString: databaseUrl,
584
+ max: 1
585
+ });
586
+ try {
587
+ return { inserted: (await pool.query(`INSERT INTO scoped_secrets (org_id, scope, key, encrypted_value)
588
+ VALUES ($1, $2, $3, $4)
589
+ ON CONFLICT (org_id, scope, key) DO UPDATE SET
590
+ encrypted_value = EXCLUDED.encrypted_value,
591
+ updated_at = now()
592
+ RETURNING (xmax = 0) AS inserted`, [
593
+ opts.orgId,
594
+ opts.environment,
595
+ opts.key,
596
+ opts.encryptedValue
597
+ ])).rows[0]?.inserted ?? false };
598
+ } finally {
599
+ await pool.end();
600
+ }
601
+ }
602
+ /**
603
+ * READ-ONLY: SELECT from `dispatch_queue` with optional status + job-name
604
+ * filters and a bounded limit (defaults to 100). Includes source_tar_url,
605
+ * deps_url, and job_config so E2E tests can assert on cache metadata
606
+ * without a second round-trip.
607
+ */
608
+ async function listQueueDirect(databaseUrl, opts = {}) {
609
+ const pool = createPool(databaseUrl);
610
+ try {
611
+ const clauses = [];
612
+ const params = [];
613
+ let idx = 1;
614
+ if (opts.status !== void 0) {
615
+ clauses.push(`status = $${idx}`);
616
+ params.push(opts.status);
617
+ idx += 1;
618
+ }
619
+ if (opts.statusNotIn !== void 0 && opts.statusNotIn.length > 0) {
620
+ const placeholders = opts.statusNotIn.map(() => {
621
+ const p = `$${idx}`;
622
+ idx += 1;
623
+ return p;
624
+ });
625
+ clauses.push(`status NOT IN (${placeholders.join(",")})`);
626
+ params.push(...opts.statusNotIn);
627
+ }
628
+ if (opts.jobNamePrefix !== void 0) {
629
+ clauses.push(`job_name LIKE $${idx}`);
630
+ params.push(`${opts.jobNamePrefix}%`);
631
+ idx += 1;
632
+ }
633
+ if (opts.jobName !== void 0) {
634
+ clauses.push(`job_name = $${idx}`);
635
+ params.push(opts.jobName);
636
+ idx += 1;
637
+ }
638
+ if (opts.jobNameNotLike !== void 0) {
639
+ clauses.push(`job_name NOT LIKE $${idx}`);
640
+ params.push(opts.jobNameNotLike);
641
+ idx += 1;
642
+ }
643
+ if (opts.workflowName !== void 0) {
644
+ clauses.push(`workflow_name = $${idx}`);
645
+ params.push(opts.workflowName);
646
+ idx += 1;
647
+ }
648
+ if (opts.createdAfter !== void 0) {
649
+ clauses.push(`created_at > $${idx}`);
650
+ params.push(opts.createdAfter instanceof Date ? opts.createdAfter : new Date(opts.createdAfter));
651
+ idx += 1;
652
+ }
653
+ const limit = Math.max(1, Math.min(1e3, opts.limit ?? 100));
654
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
655
+ return { entries: (await pool.query(`SELECT id, run_id, workflow_name, job_name, status, routing_key,
656
+ provider, created_at, expires_at, delivery_id,
657
+ source_tar_url, deps_url, job_config
658
+ FROM dispatch_queue
659
+ ${where}
660
+ ORDER BY created_at DESC
661
+ LIMIT ${limit}`, params)).rows };
662
+ } finally {
663
+ await pool.end();
664
+ }
665
+ }
666
+ /**
667
+ * READ-ONLY: SELECT a single dispatch_queue row by id. Throws with a
668
+ * clear message when no row matches.
669
+ */
670
+ async function showQueueEntryDirect(databaseUrl, opts) {
671
+ const pool = createPool(databaseUrl);
672
+ try {
673
+ const result = await pool.query(`SELECT id, run_id, workflow_name, job_name, status, routing_key,
674
+ provider, created_at, expires_at, delivery_id,
675
+ source_tar_url, deps_url, job_config
676
+ FROM dispatch_queue
677
+ WHERE id = $1`, [opts.id]);
678
+ if (result.rows.length === 0) throw new Error(`queue: entry not found (id=${opts.id})`);
679
+ return result.rows[0];
680
+ } finally {
681
+ await pool.end();
682
+ }
683
+ }
684
+ /**
685
+ * READ-ONLY: SELECT execution_runs with optional filters. Ordered by
686
+ * created_at DESC, capped at a sensible limit.
687
+ */
688
+ async function listExecutionRunsDirect(databaseUrl, opts = {}) {
689
+ const pool = createPool(databaseUrl);
690
+ try {
691
+ const clauses = [];
692
+ const params = [];
693
+ let idx = 1;
694
+ if (opts.routingKey !== void 0) {
695
+ clauses.push(`routing_key = $${idx}`);
696
+ params.push(opts.routingKey);
697
+ idx += 1;
698
+ }
699
+ if (opts.status !== void 0) {
700
+ clauses.push(`status = $${idx}`);
701
+ params.push(opts.status);
702
+ idx += 1;
703
+ }
704
+ if (opts.workflowName !== void 0) {
705
+ clauses.push(`workflow_name = $${idx}`);
706
+ params.push(opts.workflowName);
707
+ idx += 1;
708
+ }
709
+ const limit = Math.max(1, Math.min(1e3, opts.limit ?? 100));
710
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
711
+ return { runs: (await pool.query(`SELECT id, run_id, workflow_name, status, provider, repo_identifier,
712
+ ref, sha, routing_key, environment, trust_tier, created_at,
713
+ started_at, completed_at, duration_ms
714
+ FROM execution_runs
715
+ ${where}
716
+ ORDER BY created_at DESC
717
+ LIMIT ${limit}`, params)).rows };
718
+ } finally {
719
+ await pool.end();
720
+ }
721
+ }
722
+ /**
723
+ * READ-ONLY: fetch a single run by run_id AND its jobs. Throws if no run
724
+ * matches the run_id. Jobs list may be empty for pending runs.
725
+ */
726
+ async function showExecutionRunDirect(databaseUrl, opts) {
727
+ const pool = createPool(databaseUrl);
728
+ try {
729
+ const runResult = await pool.query(`SELECT id, run_id, workflow_name, status, provider, repo_identifier,
730
+ ref, sha, routing_key, environment, trust_tier, created_at,
731
+ started_at, completed_at, duration_ms
732
+ FROM execution_runs
733
+ WHERE run_id = $1`, [opts.runId]);
734
+ if (runResult.rows.length === 0) throw new Error(`execution: run not found (run_id=${opts.runId})`);
735
+ const run = runResult.rows[0];
736
+ return {
737
+ run,
738
+ jobs: (await pool.query(`SELECT id, run_id, job_id, job_name, status, agent_id,
739
+ started_at, completed_at, duration_ms, created_at, error_message
740
+ FROM execution_jobs
741
+ WHERE run_id = $1
742
+ ORDER BY created_at ASC`, [run.run_id])).rows
743
+ };
744
+ } finally {
745
+ await pool.end();
746
+ }
747
+ }
748
+ /**
749
+ * READ-ONLY: list execution_jobs for a given execution_runs.id. Ordered by
750
+ * created_at ASC so downstream diffs show timeline order.
751
+ */
752
+ async function listExecutionJobsDirect(databaseUrl, opts) {
753
+ const pool = createPool(databaseUrl);
754
+ try {
755
+ return { jobs: (await pool.query(`SELECT j.id, j.run_id, j.job_id, j.job_name, j.status, j.agent_id,
756
+ j.started_at, j.completed_at, j.duration_ms, j.created_at, j.error_message
757
+ FROM execution_jobs j
758
+ INNER JOIN execution_runs r ON r.run_id = j.run_id
759
+ WHERE r.run_id::text = $1 OR r.id::text = $1
760
+ ORDER BY j.created_at ASC`, [opts.runId])).rows };
761
+ } finally {
762
+ await pool.end();
763
+ }
764
+ }
765
+ /**
766
+ * READ-ONLY: list workflow_registrations with optional filters. Also returns
767
+ * the latest registry_versions.version so callers can assert registry bumps
768
+ * without a second round trip.
769
+ */
770
+ async function listRegistrationsDirect(databaseUrl, opts = {}) {
771
+ const pool = createPool(databaseUrl);
772
+ try {
773
+ const clauses = [];
774
+ const params = [];
775
+ let idx = 1;
776
+ if (opts.customerId !== void 0) {
777
+ clauses.push(`customer_id = $${idx}`);
778
+ params.push(opts.customerId);
779
+ idx += 1;
780
+ }
781
+ if (opts.routingKey !== void 0) {
782
+ clauses.push(`routing_key = $${idx}`);
783
+ params.push(opts.routingKey);
784
+ idx += 1;
785
+ }
786
+ if (opts.repoIdentifier !== void 0) {
787
+ clauses.push(`repo_identifier = $${idx}`);
788
+ params.push(opts.repoIdentifier);
789
+ idx += 1;
790
+ }
791
+ if (opts.triggerType !== void 0) {
792
+ clauses.push(`$${idx} = ANY(trigger_types)`);
793
+ params.push(opts.triggerType);
794
+ idx += 1;
795
+ }
796
+ const limit = Math.max(1, Math.min(1e3, opts.limit ?? 100));
797
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
798
+ const result = await pool.query(`SELECT id, repo_identifier, workflow_name, routing_key, customer_id,
799
+ trigger_types, disabled, is_global, commit_sha, source_file,
800
+ created_at, updated_at
801
+ FROM workflow_registrations
802
+ ${where}
803
+ ORDER BY updated_at DESC
804
+ LIMIT ${limit}`, params);
805
+ const versionResult = await pool.query(`SELECT version FROM registry_versions ORDER BY version DESC LIMIT 1`);
806
+ return {
807
+ registrations: result.rows,
808
+ registryVersion: versionResult.rows[0]?.version ?? null
809
+ };
810
+ } finally {
811
+ await pool.end();
812
+ }
813
+ }
814
+ /**
815
+ * READ-ONLY: show one workflow_registrations row by id, plus the latest
816
+ * registry_versions row (the monotonic version bumped on every registration
817
+ * insert/delete). Throws if the registration id is unknown.
818
+ */
819
+ async function showRegistrationDirect(databaseUrl, opts) {
820
+ const pool = createPool(databaseUrl);
821
+ try {
822
+ const result = await pool.query(`SELECT id, repo_identifier, workflow_name, routing_key, customer_id,
823
+ trigger_types, disabled, is_global, commit_sha, source_file,
824
+ created_at, updated_at, lock_entry, provider_context
825
+ FROM workflow_registrations
826
+ WHERE id = $1`, [opts.id]);
827
+ if (result.rows.length === 0) throw new Error(`registration: not found (id=${opts.id})`);
828
+ const registryVersion = (await pool.query(`SELECT version FROM registry_versions ORDER BY version DESC LIMIT 1`)).rows[0]?.version ?? null;
829
+ return {
830
+ registration: result.rows[0],
831
+ registryVersion
832
+ };
833
+ } finally {
834
+ await pool.end();
835
+ }
836
+ }
837
+ /**
838
+ * Registerable trigger types. Kept in sync with
839
+ * packages/orchestrator/src/registration/extractor.ts — any trigger whose type
840
+ * is in this set (or which pins `repos: [...]` patterns) produces a
841
+ * workflow_registrations row on default-branch push extraction.
842
+ *
843
+ * Duplicated here intentionally so the helper has no runtime dependency on
844
+ * the engine package.
845
+ */
846
+ const REGISTERABLE_TRIGGER_TYPES = new Set([
847
+ "push",
848
+ "pr",
849
+ "pull_request",
850
+ "tag",
851
+ "release",
852
+ "schedule",
853
+ "kici_event",
854
+ "webhook",
855
+ "repository_dispatch",
856
+ "issue_comment",
857
+ "pull_request_review"
858
+ ]);
859
+ /**
860
+ * Transactionally upsert `workflow_registrations` rows from a lock file and
861
+ * bump `registry_versions.version`. Mirrors the orchestrator's
862
+ * RegistrationStore.replaceAll() path but runs offline — the E2E test helpers
863
+ * `seedWorkflowRegistrationsFromLockFile` called this pattern via raw pg.Pool
864
+ * before this helper existed.
865
+ *
866
+ * Writes one row per registerable workflow (UPSERT by
867
+ * (routing_key, repo_identifier, workflow_name)), then bumps
868
+ * `registry_versions` in the same transaction so orchestrator processes
869
+ * watching that row refresh their in-memory index.
870
+ */
871
+ async function registerWorkflowManualDirect(databaseUrl, opts) {
872
+ let lockFile;
873
+ if (opts.lockFile !== void 0) lockFile = opts.lockFile;
874
+ else if (opts.lockFileContents !== void 0) try {
875
+ lockFile = JSON.parse(opts.lockFileContents);
876
+ } catch (err) {
877
+ throw new Error(`registration: lockFileContents is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
878
+ }
879
+ else throw new Error("registration: either lockFileContents or lockFile must be provided");
880
+ if (!Array.isArray(lockFile.workflows)) throw new Error("registration: lock file missing workflows[] array");
881
+ const hasRepoPatterns = (t) => Array.isArray(t.repos) && t.repos.length > 0;
882
+ const registerable = lockFile.workflows.filter((w) => w.triggers.some((t) => REGISTERABLE_TRIGGER_TYPES.has(t._type) || hasRepoPatterns(t)));
883
+ const pool = createPool(databaseUrl);
884
+ const client = await pool.connect();
885
+ try {
886
+ await client.query("BEGIN");
887
+ for (const workflow of registerable) {
888
+ const triggerTypes = [...new Set(workflow.triggers.map((t) => t._type))];
889
+ await client.query(`INSERT INTO workflow_registrations (
890
+ repo_identifier, workflow_name, lock_entry, trigger_types,
891
+ routing_key, provider_context, customer_id, commit_sha, source_file
892
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
893
+ ON CONFLICT (routing_key, repo_identifier, workflow_name) DO UPDATE SET
894
+ lock_entry = EXCLUDED.lock_entry,
895
+ trigger_types = EXCLUDED.trigger_types,
896
+ provider_context = EXCLUDED.provider_context,
897
+ customer_id = EXCLUDED.customer_id,
898
+ commit_sha = EXCLUDED.commit_sha,
899
+ source_file = EXCLUDED.source_file,
900
+ updated_at = NOW()`, [
901
+ opts.repoIdentifier,
902
+ workflow.name,
903
+ JSON.stringify(workflow),
904
+ triggerTypes,
905
+ opts.routingKey,
906
+ JSON.stringify(opts.providerContext),
907
+ opts.customerId,
908
+ opts.commitSha ?? null,
909
+ `.kici/workflows/${workflow.name}.ts`
910
+ ]);
911
+ }
912
+ const registryVersion = (await client.query(`INSERT INTO registry_versions (id, version) VALUES ('default', 1)
913
+ ON CONFLICT (id) DO UPDATE SET version = registry_versions.version + 1, updated_at = NOW()
914
+ RETURNING version`)).rows[0].version;
915
+ await client.query("COMMIT");
916
+ return {
917
+ workflowCount: registerable.length,
918
+ registryVersion
919
+ };
920
+ } catch (err) {
921
+ await client.query("ROLLBACK").catch(() => {});
922
+ throw err;
923
+ } finally {
924
+ client.release();
925
+ await pool.end();
926
+ }
927
+ }
928
+ /**
929
+ * DELETE all rows from `raft_state` so a freshly-started orchestrator
930
+ * self-elects with a clean term. Used after swapping the running
931
+ * orchestrator process (warm-mode deploys) — without this, the new
932
+ * process loads a high term and takes 60+ seconds cycling through
933
+ * failed election rounds. Returns the number of rows deleted.
934
+ *
935
+ * Safe for repeated calls — if the table is empty, returns 0.
936
+ */
937
+ async function resetRaftStateDirect(databaseUrl) {
938
+ const pool = createPool(databaseUrl);
939
+ try {
940
+ return { rowsDeleted: (await pool.query("DELETE FROM raft_state")).rowCount ?? 0 };
941
+ } finally {
942
+ await pool.end();
943
+ }
944
+ }
945
+ /**
946
+ * INSERT a row into `kici_events` and fire `pg_notify('kici_event_channel', <id>)`
947
+ * so the orchestrator EventRouter picks it up immediately. Used by Bucket B/C
948
+ * e2e helpers to simulate what `agent ctx.emit()` does from inside a step
949
+ * execution — but without needing an actual running step. Returns the event id.
950
+ *
951
+ * Fixed `chain_depth=0` and `expires_at=NOW() + 1h` match emitInternalEvent()
952
+ * in e2e/helpers/internal-webhook.ts, which this helper supersedes.
953
+ */
954
+ async function emitKiciEventDirect(databaseUrl, opts) {
955
+ const pool = createPool(databaseUrl);
956
+ try {
957
+ const eventId = (await pool.query(`INSERT INTO kici_events (
958
+ event_name, payload, source_routing_key, source_repo,
959
+ chain_depth, expires_at
960
+ )
961
+ VALUES ($1, $2, $3, $4, 0, NOW() + INTERVAL '1 hour')
962
+ RETURNING id`, [
963
+ opts.eventName,
964
+ JSON.stringify(opts.payload),
965
+ opts.sourceRoutingKey ?? "",
966
+ opts.sourceRepo ?? ""
967
+ ])).rows[0].id;
968
+ await pool.query(`SELECT pg_notify('kici_event_channel', $1)`, [eventId]);
969
+ return { eventId };
970
+ } finally {
971
+ await pool.end();
972
+ }
973
+ }
974
+ /**
975
+ * Upsert a row into `generic_webhook_sources` for internal E2E tests. Uses
976
+ * `ON CONFLICT (routing_key) DO UPDATE` so warm-start mode (where the source
977
+ * may already exist from a prior run) is idempotent.
978
+ *
979
+ * IMPORTANT: callers must invoke this BEFORE the orchestrator starts, because
980
+ * GenericSourceManager caches sources at boot and does not reload them later.
981
+ * This helper supersedes seedGenericWebhookSource() in
982
+ * e2e/helpers/internal-webhook.ts.
983
+ */
984
+ async function seedGenericWebhookSourceDirect(databaseUrl, opts) {
985
+ const pool = createPool(databaseUrl);
986
+ try {
987
+ await pool.query(`INSERT INTO generic_webhook_sources (
988
+ id, customer_id, name, routing_key,
989
+ verification_method, verification_config,
990
+ event_type_header, dedup_window_seconds,
991
+ max_payload_bytes, rate_limit_rpm, enabled,
992
+ provider_type
993
+ ) VALUES ($1, $2, $3, $4, $5, '{}', 'x-event-type', 300, 10485760, 600, true, $6)
994
+ ON CONFLICT (routing_key) DO UPDATE SET
995
+ customer_id = EXCLUDED.customer_id,
996
+ name = EXCLUDED.name,
997
+ verification_method = EXCLUDED.verification_method,
998
+ verification_config = EXCLUDED.verification_config,
999
+ provider_type = EXCLUDED.provider_type,
1000
+ enabled = true,
1001
+ deleted_at = NULL,
1002
+ updated_at = NOW()`, [
1003
+ opts.sourceId,
1004
+ opts.orgId,
1005
+ opts.name,
1006
+ opts.routingKey,
1007
+ opts.verificationMethod ?? "none",
1008
+ opts.providerType ?? "generic"
1009
+ ]);
1010
+ } finally {
1011
+ await pool.end();
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Return `{ current: true }` if the applied migration count matches the
1016
+ * provider's migration count AND the content hash in `_migration_content_hash`
1017
+ * matches the provider's current hash. Otherwise return `{ current: false,
1018
+ * reason }` with a human-readable reason.
1019
+ *
1020
+ * Callers use this as a warm-start freshness gate — if not current, do a cold
1021
+ * start (`db fresh`).
1022
+ */
1023
+ async function isSchemaCurrent(pool, provider) {
1024
+ const migrations = await provider.getMigrations();
1025
+ const expectedCount = Object.keys(migrations).length;
1026
+ let appliedCount;
1027
+ try {
1028
+ appliedCount = (await pool.query("SELECT COUNT(*)::int AS count FROM \"kysely_migration\"")).rows[0]?.count ?? 0;
1029
+ } catch {
1030
+ return {
1031
+ current: false,
1032
+ reason: "kysely_migration table missing"
1033
+ };
1034
+ }
1035
+ if (appliedCount !== expectedCount) return {
1036
+ current: false,
1037
+ reason: `migration count mismatch (applied=${appliedCount}, expected=${expectedCount})`
1038
+ };
1039
+ const expectedHash = await computeMigrationsHash(provider);
1040
+ const storedHash = await readStoredMigrationContentHash(pool);
1041
+ if (storedHash !== expectedHash) return {
1042
+ current: false,
1043
+ reason: storedHash === null ? "content hash missing" : "content hash mismatch"
1044
+ };
1045
+ return { current: true };
1046
+ }
1047
+ /**
1048
+ * Purge backends whose encrypted `config` column can no longer be decrypted
1049
+ * (e.g. warm-start E2E where KICI_SECRET_KEY rotated between categories).
1050
+ *
1051
+ * Only rows with a non-empty `config_encrypted` are affected — the default
1052
+ * `pg` backend is seeded by the initial migration with `config_encrypted = ''`
1053
+ * as a sentinel (loadAllStores() skips decryption for it), so it is never the
1054
+ * source of the decryption failure and must be preserved. Deleting it breaks
1055
+ * downstream tests that rely on the default backend being registered.
1056
+ */
1057
+ async function purgeSecretBackendsDirect(databaseUrl) {
1058
+ const pool = createPool(databaseUrl);
1059
+ try {
1060
+ return { deleted: (await pool.query("DELETE FROM secret_backends WHERE config_encrypted IS NOT NULL AND config_encrypted <> ''")).rowCount ?? 0 };
1061
+ } finally {
1062
+ await pool.end();
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Check if an API key exists in the Platform DB (api_keys table — orchestrator-
1067
+ * managed, NOT user_api_keys). Returns true when a row matches the hashed key.
1068
+ */
1069
+ async function apiKeyExistsDirect(databaseUrl, apiKey) {
1070
+ const keyHash = createHash("sha256").update(apiKey).digest("hex");
1071
+ const pool = createPool(databaseUrl);
1072
+ try {
1073
+ return (await pool.query("SELECT 1 FROM api_keys WHERE key_hash = $1", [keyHash])).rows.length > 0;
1074
+ } finally {
1075
+ await pool.end();
1076
+ }
1077
+ }
1078
+ /**
1079
+ * Insert an api_keys row (Platform-side orchestrator credential). Used by
1080
+ * e2e setup to seed an orchestrator authentication token. Returns the id.
1081
+ */
1082
+ async function seedApiKeyInlineDirect(databaseUrl, opts) {
1083
+ const keyHash = createHash("sha256").update(opts.fullKey).digest("hex");
1084
+ const keyPrefix = opts.fullKey.slice(0, 16);
1085
+ const pool = createPool(databaseUrl);
1086
+ try {
1087
+ return { keyId: (await pool.query(`INSERT INTO api_keys (key_hash, key_prefix, name, org_id)
1088
+ VALUES ($1, $2, $3, $4)
1089
+ RETURNING id`, [
1090
+ keyHash,
1091
+ keyPrefix,
1092
+ opts.keyName,
1093
+ opts.orgId
1094
+ ])).rows[0].id };
1095
+ } finally {
1096
+ await pool.end();
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Lookup a `platform_connections` row by connection_id. Returns true when
1101
+ * present. Used by the orphan-sweeper e2e test.
1102
+ */
1103
+ async function platformConnectionExistsDirect(databaseUrl, connectionId) {
1104
+ const pool = createPool(databaseUrl);
1105
+ try {
1106
+ const res = await pool.query(`SELECT 1 FROM platform_connections WHERE connection_id = $1 LIMIT 1`, [connectionId]);
1107
+ return res.rowCount !== null && res.rowCount > 0;
1108
+ } finally {
1109
+ await pool.end();
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Count `webhook_sources` rows for a given orchestrator_connection_id.
1114
+ * Used by the orphan-sweeper e2e test to assert the FK CASCADE introduced
1115
+ * by Platform migration 017 actually fires when the parent
1116
+ * `platform_connections` row is deleted.
1117
+ */
1118
+ async function countWebhookSourcesByConnectionIdDirect(databaseUrl, connectionId) {
1119
+ const pool = createPool(databaseUrl);
1120
+ try {
1121
+ const res = await pool.query(`SELECT COUNT(*)::int AS cnt
1122
+ FROM webhook_sources
1123
+ WHERE orchestrator_connection_id = $1`, [connectionId]);
1124
+ return Number(res.rows[0]?.cnt ?? 0);
1125
+ } finally {
1126
+ await pool.end();
1127
+ }
1128
+ }
1129
+ /**
1130
+ * Find a `user_api_keys.id` preferring rows scoped to `preferredOrgId`, else
1131
+ * any row in the table. Used by orphan-sweeper test to get a realistic
1132
+ * `key_id` for platform_connections seeding.
1133
+ */
1134
+ async function findAnyUserApiKeyIdDirect(databaseUrl, preferredOrgId) {
1135
+ const pool = createPool(databaseUrl);
1136
+ try {
1137
+ const scoped = await pool.query(`SELECT id FROM user_api_keys WHERE org_id = $1 LIMIT 1`, [preferredOrgId]);
1138
+ if (scoped.rows.length > 0) return String(scoped.rows[0].id);
1139
+ const any = await pool.query(`SELECT id FROM user_api_keys LIMIT 1`);
1140
+ return any.rows.length > 0 ? String(any.rows[0].id) : null;
1141
+ } finally {
1142
+ await pool.end();
1143
+ }
1144
+ }
1145
+ /**
1146
+ * Seed or refresh a synthetic GitHub webhook source on the Platform DB.
1147
+ * Used by HMAC E2E tests that post to `/webhook/:orgId/github`.
1148
+ * Idempotent — refreshes the secret/org_id via ON CONFLICT DO UPDATE,
1149
+ * and clears stale rows with a different routing_key under the same
1150
+ * (org_id, provider, connection_id) triple.
1151
+ */
1152
+ async function seedSyntheticGithubSourceDirect(databaseUrl, opts) {
1153
+ const pool = createPool(databaseUrl);
1154
+ try {
1155
+ await pool.query(`INSERT INTO platform_connections (
1156
+ org_id, instance_id, connection_id, key_id, routing_keys
1157
+ )
1158
+ VALUES ($1, 'e2e-synthetic-instance', 'e2e-synthetic',
1159
+ '00000000-0000-4000-8000-000000000000', $2::text)
1160
+ ON CONFLICT (connection_id) DO UPDATE
1161
+ SET org_id = $1,
1162
+ routing_keys = $2::text,
1163
+ last_heartbeat_at = NOW()`, [opts.orgId, JSON.stringify([opts.routingKey])]);
1164
+ await pool.query(`DELETE FROM webhook_sources
1165
+ WHERE org_id = $1 AND provider = 'github'
1166
+ AND orchestrator_connection_id = 'e2e-synthetic'
1167
+ AND routing_key != $2`, [opts.orgId, opts.routingKey]);
1168
+ await pool.query(`INSERT INTO webhook_sources (routing_key, provider, orchestrator_connection_id, org_id)
1169
+ VALUES ($1, 'github', 'e2e-synthetic', $2)
1170
+ ON CONFLICT (routing_key, orchestrator_connection_id) DO UPDATE SET org_id = $2`, [opts.routingKey, opts.orgId]);
1171
+ } finally {
1172
+ await pool.end();
1173
+ }
1174
+ }
1175
+ /**
1176
+ * Seed a webhook secret into the orchestrator's `scoped_secrets` table,
1177
+ * encrypted with the caller-supplied key. Ensures a `sources` row exists
1178
+ * for the routing_key. Used by e2e setup on the orchestrator DB side.
1179
+ *
1180
+ * encryptFn takes plaintext + AAD and returns ciphertext bytes. The
1181
+ * caller owns the crypto primitive so this helper stays decoupled from
1182
+ * the orchestrator's PgSecretStore crypto module.
1183
+ */
1184
+ async function seedWebhookSecretDirect(databaseUrl, opts) {
1185
+ const { randomBytes } = await import("node:crypto");
1186
+ const pool = createPool(databaseUrl);
1187
+ try {
1188
+ let sourceId;
1189
+ const sourceResult = await pool.query("SELECT id FROM sources WHERE routing_key = $1", [opts.routingKey]);
1190
+ if (sourceResult.rows.length > 0) sourceId = sourceResult.rows[0].id;
1191
+ else {
1192
+ sourceId = randomBytes(16).toString("hex");
1193
+ const [provider, appId] = opts.routingKey.split(":");
1194
+ await pool.query(`INSERT INTO sources (id, provider, name, routing_key, config)
1195
+ VALUES ($1, $2, $3, $4, $5)
1196
+ ON CONFLICT (routing_key) DO NOTHING`, [
1197
+ sourceId,
1198
+ provider || "github",
1199
+ `e2e-${appId}`,
1200
+ opts.routingKey,
1201
+ JSON.stringify({ appId: appId || "" })
1202
+ ]);
1203
+ sourceId = (await pool.query("SELECT id FROM sources WHERE routing_key = $1", [opts.routingKey])).rows[0].id;
1204
+ }
1205
+ const scope = `__source__/${sourceId}`;
1206
+ const orgId = "__system__";
1207
+ const aad = `${orgId}:${scope}:webhookSecret`;
1208
+ const encrypted = opts.encryptFn(opts.webhookSecret, aad);
1209
+ await pool.query(`INSERT INTO scoped_secrets (org_id, scope, key, encrypted_value, backend_type, key_version)
1210
+ VALUES ($1, $2, 'webhookSecret', $3, 'pg', 1)
1211
+ ON CONFLICT (org_id, scope, key) DO UPDATE SET encrypted_value = $3, updated_at = now()`, [
1212
+ orgId,
1213
+ scope,
1214
+ encrypted
1215
+ ]);
1216
+ return { sourceId };
1217
+ } finally {
1218
+ await pool.end();
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Seed a source private key into the orchestrator's `scoped_secrets` table.
1223
+ * encryptFn signature matches seedWebhookSecretDirect. Returns null if the
1224
+ * sources row is missing (matches legacy warning-and-skip behaviour).
1225
+ */
1226
+ async function seedSourcePrivateKeyDirect(databaseUrl, opts) {
1227
+ const pool = createPool(databaseUrl);
1228
+ try {
1229
+ const sourceResult = await pool.query("SELECT id FROM sources WHERE routing_key = $1", [opts.routingKey]);
1230
+ if (sourceResult.rows.length === 0) return null;
1231
+ const sourceId = sourceResult.rows[0].id;
1232
+ const scope = `__source__/${sourceId}`;
1233
+ const orgId = "__system__";
1234
+ const aad = `${orgId}:${scope}:privateKey`;
1235
+ const encrypted = opts.encryptFn(opts.privateKey, aad);
1236
+ await pool.query(`INSERT INTO scoped_secrets (org_id, scope, key, encrypted_value, backend_type, key_version)
1237
+ VALUES ($1, $2, 'privateKey', $3, 'pg', 1)
1238
+ ON CONFLICT (org_id, scope, key) DO UPDATE SET encrypted_value = $3, updated_at = now()`, [
1239
+ orgId,
1240
+ scope,
1241
+ encrypted
1242
+ ]);
1243
+ return { sourceId };
1244
+ } finally {
1245
+ await pool.end();
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Bump `registry_versions.version` for the default registry and return the
1250
+ * new value. Used by cron-scheduler e2e to retrigger index after a manual
1251
+ * workflow change.
1252
+ */
1253
+ async function bumpRegistryVersionDirect(databaseUrl) {
1254
+ const pool = createPool(databaseUrl);
1255
+ try {
1256
+ return (await pool.query(`UPDATE registry_versions
1257
+ SET version = version + 1, updated_at = NOW()
1258
+ WHERE id = 'default'
1259
+ RETURNING version`)).rows[0].version;
1260
+ } finally {
1261
+ await pool.end();
1262
+ }
1263
+ }
1264
+ /**
1265
+ * Poll `kici_events` for an event matching `eventName` created after `since`.
1266
+ * Returns the newest match, or throws on timeout. Used by e2e event-routing tests.
1267
+ */
1268
+ async function pollKiciEventsDirect(databaseUrl, opts) {
1269
+ const timeoutMs = opts.timeoutMs ?? 6e4;
1270
+ const pollInterval = opts.pollIntervalMs ?? 2e3;
1271
+ const deadline = Date.now() + timeoutMs;
1272
+ const pool = createPool(databaseUrl);
1273
+ try {
1274
+ while (Date.now() < deadline) {
1275
+ const filter = opts.payloadFilter;
1276
+ const result = filter ? await pool.query(`SELECT * FROM kici_events
1277
+ WHERE event_name = $1 AND created_at > $2 AND payload->>$3 = $4
1278
+ ORDER BY created_at DESC
1279
+ LIMIT 1`, [
1280
+ opts.eventName,
1281
+ opts.since.toISOString(),
1282
+ filter.key,
1283
+ filter.value
1284
+ ]) : await pool.query(`SELECT * FROM kici_events
1285
+ WHERE event_name = $1 AND created_at > $2
1286
+ ORDER BY created_at DESC
1287
+ LIMIT 1`, [opts.eventName, opts.since.toISOString()]);
1288
+ if (result.rows.length > 0) return result.rows[0];
1289
+ await new Promise((r) => setTimeout(r, pollInterval));
1290
+ }
1291
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for kici_event '${opts.eventName}' since ${opts.since.toISOString()}`);
1292
+ } finally {
1293
+ await pool.end();
1294
+ }
1295
+ }
1296
+ /**
1297
+ * Ping a PostgreSQL database; retries until `SELECT 1` succeeds or the
1298
+ * timeout expires. Used by e2e startup to wait for Postgres readiness.
1299
+ */
1300
+ async function waitForPostgresDirect(databaseUrl, opts) {
1301
+ const timeout = opts?.timeoutMs ?? 3e4;
1302
+ const interval = opts?.intervalMs ?? 2e3;
1303
+ const deadline = Date.now() + timeout;
1304
+ while (Date.now() < deadline) {
1305
+ const pool = createPool(databaseUrl);
1306
+ try {
1307
+ await pool.query("SELECT 1");
1308
+ await pool.end();
1309
+ return;
1310
+ } catch {
1311
+ await pool.end();
1312
+ }
1313
+ await new Promise((r) => setTimeout(r, interval));
1314
+ }
1315
+ throw new Error(`PostgreSQL at ${databaseUrl} did not become available within ${timeout}ms`);
1316
+ }
1317
+ /**
1318
+ * Wait for a specific execution_runs row to reach a terminal status.
1319
+ * Used by test-pipeline e2e to gate on run completion. Terminal statuses
1320
+ * are success / failed / cancelled / timed_out_stale.
1321
+ */
1322
+ async function waitForRunCompletionDirect(databaseUrl, runId, opts) {
1323
+ const timeoutMs = opts?.timeoutMs ?? 12e4;
1324
+ const intervalMs = opts?.intervalMs ?? 2e3;
1325
+ const terminalStatuses = [
1326
+ "success",
1327
+ "failed",
1328
+ "cancelled",
1329
+ "timed_out_stale"
1330
+ ];
1331
+ const deadline = Date.now() + timeoutMs;
1332
+ const pool = createPool(databaseUrl);
1333
+ try {
1334
+ while (Date.now() < deadline) {
1335
+ const result = await pool.query(`SELECT status FROM execution_runs WHERE run_id = $1 LIMIT 1`, [runId]);
1336
+ if (result.rows.length > 0) {
1337
+ const status = result.rows[0].status;
1338
+ if (terminalStatuses.includes(status)) return { status };
1339
+ }
1340
+ await new Promise((r) => setTimeout(r, intervalMs));
1341
+ }
1342
+ throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
1343
+ } finally {
1344
+ await pool.end();
1345
+ }
1346
+ }
1347
+ /**
1348
+ * DELETE from execution_jobs + execution_runs where started_at > since.
1349
+ * Used by e2e cleanup for tests that want explicit post-test row cleanup.
1350
+ */
1351
+ async function cleanupExecutionRowsDirect(databaseUrl, since) {
1352
+ const pool = createPool(databaseUrl);
1353
+ try {
1354
+ const jobsResult = await pool.query(`DELETE FROM execution_jobs WHERE run_id IN (
1355
+ SELECT run_id FROM execution_runs WHERE started_at > $1
1356
+ )`, [since]);
1357
+ return {
1358
+ runs: (await pool.query(`DELETE FROM execution_runs WHERE started_at > $1`, [since])).rowCount ?? 0,
1359
+ jobs: jobsResult.rowCount ?? 0
1360
+ };
1361
+ } finally {
1362
+ await pool.end();
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Check whether the schema is "current" by comparing applied migration count
1367
+ * and content hash against a caller-supplied set of migration files.
1368
+ * Returns false if the migration table is missing, counts mismatch, or the
1369
+ * stored hash differs from the caller-supplied hash. Used by e2e warm-start.
1370
+ */
1371
+ async function isSchemaCurrentFromFilesDirect(databaseUrl, opts) {
1372
+ const tableName = opts.tableName ?? "kysely_migration";
1373
+ const pool = createPool(databaseUrl);
1374
+ try {
1375
+ try {
1376
+ if (((await pool.query(`SELECT COUNT(*)::int AS count FROM "${tableName}"`)).rows[0]?.count ?? 0) !== opts.expectedCount) return false;
1377
+ const hashResult = await pool.query(`SELECT hash FROM ${MIGRATION_HASH_TABLE} WHERE table_name = $1`, [tableName]);
1378
+ if (hashResult.rows.length === 0 || hashResult.rows[0].hash !== opts.expectedContentHash) return false;
1379
+ return true;
1380
+ } catch {
1381
+ return false;
1382
+ }
1383
+ } finally {
1384
+ await pool.end();
1385
+ }
1386
+ }
1387
+ /**
1388
+ * Store a migration content hash in the `_migration_content_hash` marker
1389
+ * table. Creates the table if missing. Used by e2e freshDatabase() after
1390
+ * migrations run so warm-start detection can compare on next run.
1391
+ */
1392
+ async function storeMigrationContentHashInTableDirect(databaseUrl, opts) {
1393
+ const tableName = opts.tableName ?? "kysely_migration";
1394
+ const pool = createPool(databaseUrl);
1395
+ try {
1396
+ await pool.query(`
1397
+ CREATE TABLE IF NOT EXISTS ${MIGRATION_HASH_TABLE} (
1398
+ table_name text PRIMARY KEY,
1399
+ hash text NOT NULL
1400
+ )
1401
+ `);
1402
+ await pool.query(`INSERT INTO ${MIGRATION_HASH_TABLE} (table_name, hash) VALUES ($1, $2)
1403
+ ON CONFLICT (table_name) DO UPDATE SET hash = $2`, [tableName, opts.contentHash]);
1404
+ } finally {
1405
+ await pool.end();
1406
+ }
1407
+ }
1408
+ /**
1409
+ * Insert a join_tokens row (orchestrator DB) for cluster peer auth.
1410
+ * Used by cluster e2e helpers to provision a shared secret the second
1411
+ * orchestrator will use when joining the cluster.
1412
+ */
1413
+ async function createJoinTokenDirect(databaseUrl, opts) {
1414
+ const pool = createPool(databaseUrl);
1415
+ try {
1416
+ await pool.query(`INSERT INTO join_tokens (id, token_hash, routing_info, role, created_by, expires_at)
1417
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
1418
+ opts.id,
1419
+ opts.tokenHash,
1420
+ JSON.stringify(opts.routingInfo),
1421
+ opts.role,
1422
+ opts.createdBy,
1423
+ opts.expiresAt
1424
+ ]);
1425
+ } finally {
1426
+ await pool.end();
1427
+ }
1428
+ }
1429
+ /**
1430
+ * Update the routing_key on `sources` rows for a given provider. Used by
1431
+ * cluster e2e to swap the staging routing key for an isolated test key
1432
+ * (and to restore it on teardown).
1433
+ *
1434
+ * `whereRoutingKey`: optional filter on the current routing_key. When
1435
+ * present only rows matching it are updated; when absent the provider
1436
+ * filter alone is used (with an implicit `!= newRoutingKey` guard so the
1437
+ * update is idempotent).
1438
+ */
1439
+ async function updateSourceRoutingKeyDirect(databaseUrl, opts) {
1440
+ const pool = createPool(databaseUrl);
1441
+ try {
1442
+ return { updated: (opts.whereRoutingKey ? await pool.query(`UPDATE sources SET routing_key = $1 WHERE provider = $2 AND routing_key = $3`, [
1443
+ opts.newRoutingKey,
1444
+ opts.provider,
1445
+ opts.whereRoutingKey
1446
+ ]) : await pool.query(`UPDATE sources SET routing_key = $1 WHERE provider = $2 AND routing_key != $1`, [opts.newRoutingKey, opts.provider])).rowCount ?? 0 };
1447
+ } finally {
1448
+ await pool.end();
1449
+ }
1450
+ }
1451
+ /**
1452
+ * Delete peer_credentials rows whose instance_id does NOT match a pattern.
1453
+ * Used by cluster e2e to wipe stale staging peer credentials while leaving
1454
+ * e2e-* peers intact.
1455
+ */
1456
+ async function prunePeerCredentialsDirect(databaseUrl, opts) {
1457
+ const pool = createPool(databaseUrl);
1458
+ try {
1459
+ return { deleted: (await pool.query(`DELETE FROM peer_credentials WHERE instance_id NOT LIKE $1`, [opts.keepInstanceIdPattern])).rowCount ?? 0 };
1460
+ } finally {
1461
+ await pool.end();
1462
+ }
1463
+ }
1464
+ /**
1465
+ * Poll Platform until at least `minRegistrations` distinct orchestrator
1466
+ * connections are BOTH registered for the routing key (row in webhook_sources)
1467
+ * AND live (row in platform_connections with status='connected'). The live-
1468
+ * connection join is critical — Platform's webhook_sources rows persist after
1469
+ * a connection disconnects (the orphan sweeper eventually reaps them), so a
1470
+ * naive COUNT(DISTINCT) on webhook_sources alone would inflate the number
1471
+ * and mask a missing coordinator registration.
1472
+ */
1473
+ async function waitForPlatformRegistrationsDirect(platformDbUrl, routingKey, opts) {
1474
+ const minRegistrations = opts?.minRegistrations ?? 1;
1475
+ const timeoutMs = opts?.timeoutMs ?? 3e4;
1476
+ const intervalMs = opts?.intervalMs ?? 2e3;
1477
+ const deadline = Date.now() + timeoutMs;
1478
+ const pool = createPool(platformDbUrl);
1479
+ try {
1480
+ while (Date.now() < deadline) {
1481
+ if (((await pool.query(`SELECT COUNT(DISTINCT ws.orchestrator_connection_id)::int AS cnt
1482
+ FROM webhook_sources ws
1483
+ JOIN platform_connections pc
1484
+ ON pc.connection_id = ws.orchestrator_connection_id
1485
+ AND pc.status = 'connected'
1486
+ WHERE ws.routing_key = $1
1487
+ AND ws.orchestrator_connection_id != 'e2e-synthetic'`, [routingKey])).rows[0]?.cnt ?? 0) >= minRegistrations) return;
1488
+ await new Promise((r) => setTimeout(r, intervalMs));
1489
+ }
1490
+ throw new Error(`Timed out waiting for ${minRegistrations} orchestrator registration(s) for routing key ${routingKey} (waited ${timeoutMs}ms)`);
1491
+ } finally {
1492
+ await pool.end();
1493
+ }
1494
+ }
1495
+ async function seedUniversalGitSourceDirect(databaseUrl, opts) {
1496
+ const eventTypeHeader = opts.eventTypeHeader ?? "x-gitea-event";
1497
+ const pool = createPool(databaseUrl);
1498
+ try {
1499
+ await pool.query(`INSERT INTO generic_webhook_sources (
1500
+ id, customer_id, name, routing_key,
1501
+ verification_method, verification_config,
1502
+ event_type_header, dedup_window_seconds,
1503
+ max_payload_bytes, rate_limit_rpm, enabled,
1504
+ provider_type, git_config
1505
+ ) VALUES ($1, $2, $3, $4, 'none', '{}', $6, 300, 10485760, 600, true,
1506
+ 'generic', $5::jsonb)
1507
+ ON CONFLICT (routing_key) DO UPDATE SET
1508
+ customer_id = EXCLUDED.customer_id,
1509
+ name = EXCLUDED.name,
1510
+ event_type_header = EXCLUDED.event_type_header,
1511
+ git_config = EXCLUDED.git_config,
1512
+ enabled = true,
1513
+ deleted_at = NULL,
1514
+ updated_at = NOW()`, [
1515
+ opts.sourceId,
1516
+ opts.orgId,
1517
+ opts.sourceName,
1518
+ opts.routingKey,
1519
+ JSON.stringify(opts.gitConfig),
1520
+ eventTypeHeader
1521
+ ]);
1522
+ } finally {
1523
+ await pool.end();
1524
+ }
1525
+ }
1526
+ async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1527
+ const envName = opts.envName ?? "ci-security-env";
1528
+ const sourceName = opts.sourceName ?? "ci-security-dashboard-resolver";
1529
+ const sourceRoutingKey = opts.sourceRoutingKey ?? `generic:${opts.orgId}:ci-security-dashboard`;
1530
+ const pool = createPool(databaseUrl);
1531
+ try {
1532
+ await pool.query(`INSERT INTO sources (id, provider, name, routing_key, config, customer_id)
1533
+ VALUES (gen_random_uuid(), \'generic\', $1, $2, \'{}\', $3)
1534
+ ON CONFLICT (routing_key) DO UPDATE SET customer_id = EXCLUDED.customer_id`, [
1535
+ sourceName,
1536
+ sourceRoutingKey,
1537
+ opts.orgId
1538
+ ]);
1539
+ const envId = (await pool.query(`INSERT INTO environments (org_id, name, type, enabled)
1540
+ VALUES ($1, $2, \'fixed\', true)
1541
+ ON CONFLICT (org_id, name) DO UPDATE SET enabled = true
1542
+ RETURNING id`, [opts.orgId, envName])).rows[0].id;
1543
+ await pool.query(`INSERT INTO execution_runs (
1544
+ run_id, workflow_name, provider, repo_identifier,
1545
+ ref, sha, delivery_id, status, trust_tier, lock_file_source,
1546
+ contributor_username, routing_key
1547
+ ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1548
+ \'abc123\', $2, \'pending\', \'unknown\', \'base\', \'unknown-dev\', $3)`, [
1549
+ opts.unknownRunId,
1550
+ opts.unknownDeliveryId,
1551
+ opts.runsRoutingKey
1552
+ ]);
1553
+ await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1554
+ VALUES ($1, $2, \'security-test-job\', \'pending\')`, [opts.unknownJobId, opts.unknownRunId]);
1555
+ const heldRunId = (await pool.query(`INSERT INTO held_runs (org_id, run_id, job_id, environment_id, hold_type, queue_type, reason, expires_at)
1556
+ VALUES ($1, $2, $3, $4, \'unknown_contributor\', \'security\',
1557
+ \'Unknown contributor requires approval\', NOW() + INTERVAL \'72 hours\')
1558
+ RETURNING id`, [
1559
+ opts.orgId,
1560
+ opts.unknownRunId,
1561
+ opts.unknownJobId,
1562
+ envId
1563
+ ])).rows[0].id;
1564
+ await pool.query(`INSERT INTO execution_runs (
1565
+ run_id, workflow_name, provider, repo_identifier,
1566
+ ref, sha, delivery_id, status, trust_tier, lock_file_source,
1567
+ contributor_username, routing_key
1568
+ ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1569
+ \'def456\', $2, \'running\', \'trusted\', \'head\', \'trusted-dev\', $3)`, [
1570
+ opts.trustedRunId,
1571
+ opts.trustedDeliveryId,
1572
+ opts.runsRoutingKey
1573
+ ]);
1574
+ await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1575
+ VALUES ($1, $2, \'security-test-job\', \'running\')`, [opts.trustedJobId, opts.trustedRunId]);
1576
+ return {
1577
+ envId,
1578
+ heldRunId
1579
+ };
1580
+ } finally {
1581
+ await pool.end();
1582
+ }
1583
+ }
1584
+ /**
1585
+ * Poll `execution_runs` for at least one row matching `status` whose
1586
+ * `started_at > since`. Used by cluster/job-reroute tests to gate on
1587
+ * a workflow reaching the terminal state after a webhook trigger.
1588
+ */
1589
+ async function waitForExecutionRunStatusSinceDirect(databaseUrl, opts) {
1590
+ const timeoutMs = opts.timeoutMs ?? 12e4;
1591
+ const intervalMs = opts.intervalMs ?? 5e3;
1592
+ const deadline = Date.now() + timeoutMs;
1593
+ const pool = createPool(databaseUrl);
1594
+ try {
1595
+ while (Date.now() < deadline) {
1596
+ if ((await pool.query(`SELECT 1 FROM execution_runs
1597
+ WHERE started_at > $1 AND status = $2
1598
+ LIMIT 1`, [opts.since, opts.status])).rows.length > 0) return { found: true };
1599
+ await new Promise((r) => setTimeout(r, intervalMs));
1600
+ }
1601
+ return { found: false };
1602
+ } finally {
1603
+ await pool.end();
1604
+ }
1605
+ }
1606
+ async function latestExecutionRunByStatusDirect(databaseUrl, opts) {
1607
+ const pool = createPool(databaseUrl);
1608
+ try {
1609
+ const runResult = await pool.query(`SELECT run_id, workflow_name, status FROM execution_runs
1610
+ WHERE status = $1
1611
+ ORDER BY started_at DESC LIMIT 1`, [opts.status]);
1612
+ if (runResult.rows.length === 0) return null;
1613
+ const run = runResult.rows[0];
1614
+ return {
1615
+ run,
1616
+ jobs: (await pool.query(`SELECT job_id, job_name, status FROM execution_jobs WHERE run_id = $1`, [run.run_id])).rows
1617
+ };
1618
+ } finally {
1619
+ await pool.end();
1620
+ }
1621
+ }
1622
+ async function waitForLatestExecutionJobStatusDirect(databaseUrl, opts) {
1623
+ const terminal = opts.terminalStatuses ?? [
1624
+ "success",
1625
+ "completed",
1626
+ "failed"
1627
+ ];
1628
+ const timeoutMs = opts.timeoutMs ?? 18e4;
1629
+ const intervalMs = opts.intervalMs ?? 3e3;
1630
+ const deadline = Date.now() + timeoutMs;
1631
+ const pool = createPool(databaseUrl);
1632
+ try {
1633
+ let status = null;
1634
+ let errorMessage = null;
1635
+ let runId = null;
1636
+ while (Date.now() < deadline) {
1637
+ const result = await pool.query(`SELECT j.status, j.error_message, r.run_id
1638
+ FROM execution_jobs j
1639
+ JOIN execution_runs r ON r.run_id = j.run_id
1640
+ WHERE r.workflow_name = $1
1641
+ AND r.started_at >= $2
1642
+ ORDER BY r.started_at DESC
1643
+ LIMIT 1`, [opts.workflowName, opts.since]);
1644
+ if (result.rows.length > 0) {
1645
+ status = result.rows[0].status;
1646
+ errorMessage = result.rows[0].error_message;
1647
+ runId = result.rows[0].run_id;
1648
+ if (status && terminal.includes(status)) break;
1649
+ }
1650
+ await new Promise((r) => setTimeout(r, intervalMs));
1651
+ }
1652
+ return {
1653
+ status,
1654
+ errorMessage,
1655
+ runId
1656
+ };
1657
+ } finally {
1658
+ await pool.end();
1659
+ }
1660
+ }
1661
+ async function describeTableColumnsDirect(databaseUrl, opts) {
1662
+ const pool = createPool(databaseUrl);
1663
+ try {
1664
+ return (await pool.query(`SELECT column_name, data_type FROM information_schema.columns
1665
+ WHERE table_name = $1
1666
+ ORDER BY ordinal_position`, [opts.tableName])).rows.map((r) => ({
1667
+ name: r.column_name,
1668
+ dataType: r.data_type
1669
+ }));
1670
+ } finally {
1671
+ await pool.end();
1672
+ }
1673
+ }
1674
+ /**
1675
+ * READ-ONLY: return true if a table exists in the public schema.
1676
+ */
1677
+ async function tableExistsDirect(databaseUrl, opts) {
1678
+ const pool = createPool(databaseUrl);
1679
+ try {
1680
+ return (await pool.query(`SELECT 1 FROM information_schema.tables
1681
+ WHERE table_schema = $1 AND table_name = $2`, [opts.schema ?? "public", opts.tableName])).rows.length > 0;
1682
+ } finally {
1683
+ await pool.end();
1684
+ }
1685
+ }
1686
+ async function insertKiciEventRawDirect(databaseUrl, opts) {
1687
+ const expiresIn = opts.expiresIn ?? "1 hour";
1688
+ const pool = createPool(databaseUrl);
1689
+ try {
1690
+ return { id: (await pool.query(`INSERT INTO kici_events (event_name, payload, source_routing_key, chain_depth, expires_at)
1691
+ VALUES ($1, $2, $3, $4, NOW() + ($5)::interval)
1692
+ RETURNING id`, [
1693
+ opts.eventName,
1694
+ JSON.stringify(opts.payload),
1695
+ opts.sourceRoutingKey ?? null,
1696
+ opts.chainDepth ?? 0,
1697
+ expiresIn
1698
+ ])).rows[0].id };
1699
+ } finally {
1700
+ await pool.end();
1701
+ }
1702
+ }
1703
+ async function showKiciEventDirect(databaseUrl, opts) {
1704
+ const pool = createPool(databaseUrl);
1705
+ try {
1706
+ return (await pool.query(`SELECT id, event_name, payload, chain_depth, source_routing_key,
1707
+ source_repo, processed, expires_at, created_at
1708
+ FROM kici_events WHERE id = $1`, [opts.id])).rows[0] ?? null;
1709
+ } finally {
1710
+ await pool.end();
1711
+ }
1712
+ }
1713
+ /**
1714
+ * READ-ONLY: list kici_events with filter hooks used by e2e tests:
1715
+ * by `event_name`, minimum `chain_depth`, expiry window. Returns the
1716
+ * rows with chain_depth ordered ascending. Callers apply assertions.
1717
+ */
1718
+ async function listKiciEventsDirect(databaseUrl, opts = {}) {
1719
+ const pool = createPool(databaseUrl);
1720
+ try {
1721
+ const clauses = [];
1722
+ const params = [];
1723
+ let idx = 1;
1724
+ if (opts.eventName !== void 0) {
1725
+ clauses.push(`event_name = $${idx}`);
1726
+ params.push(opts.eventName);
1727
+ idx += 1;
1728
+ }
1729
+ if (opts.minChainDepth !== void 0) {
1730
+ clauses.push(`chain_depth > $${idx}`);
1731
+ params.push(opts.minChainDepth);
1732
+ idx += 1;
1733
+ }
1734
+ if (opts.onlyExpired) clauses.push(`expires_at < NOW()`);
1735
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
1736
+ const limit = Math.max(1, Math.min(1e4, opts.limit ?? 1e3));
1737
+ return (await pool.query(`SELECT id, event_name, payload, chain_depth, source_routing_key,
1738
+ source_repo, processed, expires_at, created_at
1739
+ FROM kici_events
1740
+ ${where}
1741
+ ORDER BY chain_depth ASC, created_at ASC
1742
+ LIMIT ${limit}`, params)).rows;
1743
+ } finally {
1744
+ await pool.end();
1745
+ }
1746
+ }
1747
+ /**
1748
+ * DELETE kici_events by id or event_name. Returns the number of rows
1749
+ * deleted. Used by event-routing teardown.
1750
+ */
1751
+ async function deleteKiciEventsDirect(databaseUrl, opts) {
1752
+ if (!opts.id && !opts.eventName) throw new Error("deleteKiciEventsDirect: one of {id, eventName} is required");
1753
+ const pool = createPool(databaseUrl);
1754
+ try {
1755
+ return { deleted: (opts.id ? await pool.query(`DELETE FROM kici_events WHERE id = $1`, [opts.id]) : await pool.query(`DELETE FROM kici_events WHERE event_name = $1`, [opts.eventName])).rowCount ?? 0 };
1756
+ } finally {
1757
+ await pool.end();
1758
+ }
1759
+ }
1760
+ /**
1761
+ * Simulate a NOTIFY on `kici_event_channel` and verify a LISTEN client
1762
+ * receives it. Used by event-routing e2e to prove the infrastructure
1763
+ * the EventRouter uses for real-time delivery is functional. Owns the
1764
+ * pool for the full test to keep the listen/notify correlated.
1765
+ */
1766
+ async function verifyKiciEventNotifyDirect(databaseUrl, opts) {
1767
+ const waitMs = opts.waitMs ?? 1e3;
1768
+ const pool = createPool(databaseUrl);
1769
+ const client = await pool.connect();
1770
+ try {
1771
+ const notifications = [];
1772
+ client.on("notification", (msg) => {
1773
+ if (msg.channel === "kici_event_channel") notifications.push(msg.payload ?? "");
1774
+ });
1775
+ await client.query("LISTEN kici_event_channel");
1776
+ await pool.query(`SELECT pg_notify('kici_event_channel', $1)`, [opts.payload]);
1777
+ await new Promise((r) => setTimeout(r, waitMs));
1778
+ await client.query("UNLISTEN kici_event_channel");
1779
+ return { received: notifications };
1780
+ } finally {
1781
+ client.release();
1782
+ await pool.end();
1783
+ }
1784
+ }
1785
+ /**
1786
+ * INSERT a cross_repo_trust row. Returns the new id and allowed_events
1787
+ * array. Used by generic-webhook + event-routing e2e until a proper
1788
+ * `kici-admin trust` CLI ships.
1789
+ */
1790
+ async function seedCrossRepoTrustDirect(databaseUrl, opts) {
1791
+ const pool = createPool(databaseUrl);
1792
+ try {
1793
+ const result = await pool.query(`INSERT INTO cross_repo_trust
1794
+ (source_repo, source_routing_key, target_repo, target_routing_key, allowed_events)
1795
+ VALUES ($1, $2, $3, $4, $5)
1796
+ RETURNING id, allowed_events`, [
1797
+ opts.sourceRepo,
1798
+ opts.sourceRoutingKey,
1799
+ opts.targetRepo,
1800
+ opts.targetRoutingKey,
1801
+ opts.allowedEvents ? JSON.stringify(opts.allowedEvents) : null
1802
+ ]);
1803
+ return {
1804
+ id: result.rows[0].id,
1805
+ allowedEvents: result.rows[0].allowed_events
1806
+ };
1807
+ } finally {
1808
+ await pool.end();
1809
+ }
1810
+ }
1811
+ /**
1812
+ * READ-ONLY: list cross_repo_trust rows by source_routing_key. Used by
1813
+ * generic-webhook e2e to find a trust row it just inserted.
1814
+ */
1815
+ async function listCrossRepoTrustBySourceRoutingKeyDirect(databaseUrl, opts) {
1816
+ const pool = createPool(databaseUrl);
1817
+ try {
1818
+ return (await pool.query(`SELECT id, source_repo, source_routing_key, target_repo, target_routing_key, allowed_events
1819
+ FROM cross_repo_trust WHERE source_routing_key = $1`, [opts.sourceRoutingKey])).rows;
1820
+ } finally {
1821
+ await pool.end();
1822
+ }
1823
+ }
1824
+ /**
1825
+ * DELETE cross_repo_trust rows. Deletes by id, or asserts uniqueness
1826
+ * check error on duplicate INSERT (handled by caller). Returns row count.
1827
+ */
1828
+ async function deleteCrossRepoTrustDirect(databaseUrl, opts) {
1829
+ const pool = createPool(databaseUrl);
1830
+ try {
1831
+ return { deleted: (await pool.query(`DELETE FROM cross_repo_trust WHERE id = $1`, [opts.id])).rowCount ?? 0 };
1832
+ } finally {
1833
+ await pool.end();
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Direct INSERT with duplicate-key detection. Used by event-routing
1838
+ * e2e to verify the unique constraint enforces. Throws on duplicate;
1839
+ * caller asserts the error message matches /unique/i.
1840
+ */
1841
+ async function insertCrossRepoTrustStrictDirect(databaseUrl, opts) {
1842
+ const pool = createPool(databaseUrl);
1843
+ try {
1844
+ await pool.query(`INSERT INTO cross_repo_trust (source_repo, source_routing_key, target_repo, target_routing_key)
1845
+ VALUES ($1, $2, $3, $4)`, [
1846
+ opts.sourceRepo,
1847
+ opts.sourceRoutingKey,
1848
+ opts.targetRepo,
1849
+ opts.targetRoutingKey
1850
+ ]);
1851
+ } finally {
1852
+ await pool.end();
1853
+ }
1854
+ }
1855
+ /**
1856
+ * Cleanup helper for workflow_registrations. Accepts id, routingKey,
1857
+ * or repoIdentifier filter (exactly one). Returns delete count.
1858
+ */
1859
+ async function deleteWorkflowRegistrationsDirect(databaseUrl, opts) {
1860
+ if ([
1861
+ opts.id,
1862
+ opts.routingKey,
1863
+ opts.repoIdentifier
1864
+ ].filter((v) => v !== void 0).length !== 1) throw new Error("deleteWorkflowRegistrationsDirect: exactly one of {id, routingKey, repoIdentifier} required");
1865
+ const pool = createPool(databaseUrl);
1866
+ try {
1867
+ let result;
1868
+ if (opts.id) result = await pool.query(`DELETE FROM workflow_registrations WHERE id = $1`, [opts.id]);
1869
+ else if (opts.routingKey) result = await pool.query(`DELETE FROM workflow_registrations WHERE routing_key = $1`, [opts.routingKey]);
1870
+ else result = await pool.query(`DELETE FROM workflow_registrations WHERE repo_identifier = $1`, [opts.repoIdentifier]);
1871
+ return { deleted: result.rowCount ?? 0 };
1872
+ } finally {
1873
+ await pool.end();
1874
+ }
1875
+ }
1876
+ async function getWorkflowRegistrationByIdDirect(databaseUrl, opts) {
1877
+ const pool = createPool(databaseUrl);
1878
+ try {
1879
+ return (await pool.query(`SELECT id, repo_identifier, workflow_name, routing_key, customer_id,
1880
+ trigger_types, disabled, is_global, commit_sha, source_file,
1881
+ created_at, updated_at, lock_entry
1882
+ FROM workflow_registrations
1883
+ WHERE id = $1`, [opts.id])).rows[0] ?? null;
1884
+ } finally {
1885
+ await pool.end();
1886
+ }
1887
+ }
1888
+ async function listRegistrationsByRoutingKeyDirect(databaseUrl, opts) {
1889
+ const pool = createPool(databaseUrl);
1890
+ try {
1891
+ const globalFilter = opts.onlyGlobal ? `AND is_global = TRUE` : "";
1892
+ const result = await pool.query(`SELECT workflow_name, is_global, updated_at
1893
+ FROM workflow_registrations
1894
+ WHERE routing_key = $1 ${globalFilter}`, [opts.routingKey]);
1895
+ let latest = null;
1896
+ for (const r of result.rows) {
1897
+ const d = r.updated_at ? new Date(r.updated_at) : null;
1898
+ if (d && (!latest || d.getTime() > latest.getTime())) latest = d;
1899
+ }
1900
+ return {
1901
+ count: result.rows.length,
1902
+ latestUpdatedAt: latest,
1903
+ rows: result.rows.map((r) => ({
1904
+ workflow_name: r.workflow_name,
1905
+ is_global: r.is_global
1906
+ }))
1907
+ };
1908
+ } finally {
1909
+ await pool.end();
1910
+ }
1911
+ }
1912
+ /**
1913
+ * Poll `workflow_registrations` for at least `minCount` rows for a
1914
+ * routing key. Returns the rows or throws on timeout (keeps parity with
1915
+ * the forgejo helper pattern it replaces).
1916
+ */
1917
+ async function waitForRegistrationsByRoutingKeyDirect(databaseUrl, opts) {
1918
+ const minCount = opts.minCount ?? 1;
1919
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1920
+ const intervalMs = opts.intervalMs ?? 1e3;
1921
+ const deadline = Date.now() + timeoutMs;
1922
+ let rows = [];
1923
+ while (Date.now() < deadline) {
1924
+ rows = (await listRegistrationsByRoutingKeyDirect(databaseUrl, {
1925
+ routingKey: opts.routingKey,
1926
+ onlyGlobal: opts.onlyGlobal
1927
+ })).rows;
1928
+ if (rows.length >= minCount) return rows;
1929
+ await new Promise((r) => setTimeout(r, intervalMs));
1930
+ }
1931
+ return rows;
1932
+ }
1933
+ /**
1934
+ * Wait for `workflow_registrations.updated_at` for a routing key to
1935
+ * exceed `baselineUpdatedAt`. Used by forgejo rotated-PAT e2e.
1936
+ */
1937
+ async function waitForRegistrationsUpdatedAtAdvanceDirect(databaseUrl, opts) {
1938
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1939
+ const intervalMs = opts.intervalMs ?? 1e3;
1940
+ const deadline = Date.now() + timeoutMs;
1941
+ let latest = null;
1942
+ while (Date.now() < deadline) {
1943
+ latest = (await listRegistrationsByRoutingKeyDirect(databaseUrl, { routingKey: opts.routingKey })).latestUpdatedAt;
1944
+ if (latest && (!opts.baselineUpdatedAt || latest.getTime() > opts.baselineUpdatedAt.getTime())) return {
1945
+ advanced: true,
1946
+ latestUpdatedAt: latest
1947
+ };
1948
+ await new Promise((r) => setTimeout(r, intervalMs));
1949
+ }
1950
+ return {
1951
+ advanced: false,
1952
+ latestUpdatedAt: latest
1953
+ };
1954
+ }
1955
+ /**
1956
+ * UPDATE a workflow_registrations row's commit_sha. Used by cron-scheduler
1957
+ * e2e where the manual-schedule handler rejects null commit SHAs.
1958
+ */
1959
+ async function updateWorkflowRegistrationCommitShaDirect(databaseUrl, opts) {
1960
+ const pool = createPool(databaseUrl);
1961
+ try {
1962
+ await pool.query(`UPDATE workflow_registrations SET commit_sha = $1 WHERE id = $2`, [opts.commitSha, opts.id]);
1963
+ } finally {
1964
+ await pool.end();
1965
+ }
1966
+ }
1967
+ /**
1968
+ * Lock-entry seeding for the admin-API e2e, which uses a custom
1969
+ * shape (cron schedule). Returns the inserted id.
1970
+ */
1971
+ async function insertWorkflowRegistrationRawDirect(databaseUrl, opts) {
1972
+ const pool = createPool(databaseUrl);
1973
+ try {
1974
+ const idClause = opts.id ? "$7" : "gen_random_uuid()";
1975
+ const extras = [];
1976
+ if (opts.id) extras.push(opts.id);
1977
+ const columns = [];
1978
+ const placeholders = [];
1979
+ if (opts.isGlobal !== void 0) {
1980
+ columns.push("is_global");
1981
+ placeholders.push(`$${7 + extras.length}`);
1982
+ extras.push(opts.isGlobal);
1983
+ }
1984
+ if (opts.disabled !== void 0) {
1985
+ columns.push("disabled");
1986
+ placeholders.push(`$${7 + extras.length}`);
1987
+ extras.push(opts.disabled);
1988
+ }
1989
+ if (opts.commitSha !== void 0) {
1990
+ columns.push("commit_sha");
1991
+ placeholders.push(`$${7 + extras.length}`);
1992
+ extras.push(opts.commitSha);
1993
+ }
1994
+ const extraColumns = columns.length > 0 ? `, ${columns.join(", ")}` : "";
1995
+ const extraPlaceholders = placeholders.length > 0 ? `, ${placeholders.join(", ")}` : "";
1996
+ const sql = `INSERT INTO workflow_registrations
1997
+ (routing_key, repo_identifier, workflow_name, lock_entry, trigger_types, customer_id${opts.id ? ", id" : ""}${extraColumns})
1998
+ VALUES ($1, $2, $3, $4, $5, $6${opts.id ? `, ${idClause}` : ""}${extraPlaceholders})
1999
+ ON CONFLICT (routing_key, repo_identifier, workflow_name) DO UPDATE SET
2000
+ lock_entry = EXCLUDED.lock_entry,
2001
+ trigger_types = EXCLUDED.trigger_types,
2002
+ customer_id = EXCLUDED.customer_id
2003
+ RETURNING id`;
2004
+ return { id: (await pool.query(sql, [
2005
+ opts.routingKey,
2006
+ opts.repoIdentifier,
2007
+ opts.workflowName,
2008
+ JSON.stringify(opts.lockEntry),
2009
+ opts.triggerTypes,
2010
+ opts.customerId,
2011
+ ...extras
2012
+ ])).rows[0].id };
2013
+ } finally {
2014
+ await pool.end();
2015
+ }
2016
+ }
2017
+ /**
2018
+ * INSERT with a STRICT shape (no ON CONFLICT). Used by registration-schema
2019
+ * e2e to prove the unique constraint rejects duplicates. Throws the raw
2020
+ * DB error (caller matches /unique/i).
2021
+ */
2022
+ async function insertWorkflowRegistrationStrictDirect(databaseUrl, opts) {
2023
+ const pool = createPool(databaseUrl);
2024
+ try {
2025
+ return { id: (await pool.query(`INSERT INTO workflow_registrations
2026
+ (routing_key, repo_identifier, workflow_name, lock_entry, trigger_types, customer_id)
2027
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6)
2028
+ RETURNING id`, [
2029
+ opts.routingKey,
2030
+ opts.repoIdentifier,
2031
+ opts.workflowName,
2032
+ opts.lockEntryJson ?? "{}",
2033
+ opts.triggerTypes,
2034
+ opts.customerId
2035
+ ])).rows[0]?.id ?? null };
2036
+ } finally {
2037
+ await pool.end();
2038
+ }
2039
+ }
2040
+ /**
2041
+ * READ-ONLY: latest registry_versions.version, or null if the default
2042
+ * row is missing. Used by registration-admin-api e2e to bracket a
2043
+ * refresh call.
2044
+ */
2045
+ async function getRegistryVersionDirect(databaseUrl, opts = {}) {
2046
+ const pool = createPool(databaseUrl);
2047
+ try {
2048
+ return (await pool.query(`SELECT version FROM registry_versions WHERE id = $1`, [opts.id ?? "default"])).rows[0]?.version ?? null;
2049
+ } finally {
2050
+ await pool.end();
2051
+ }
2052
+ }
2053
+ /**
2054
+ * UPDATE `registry_versions.version = version + 1` WHERE id (default).
2055
+ * Simpler than `bumpRegistryVersionDirect` — used by global-workflow
2056
+ * e2e which wants the side-effect (force index refresh) without the
2057
+ * return value.
2058
+ */
2059
+ async function bumpRegistryVersionSimpleDirect(databaseUrl, opts = {}) {
2060
+ const pool = createPool(databaseUrl);
2061
+ try {
2062
+ await pool.query(`UPDATE registry_versions SET version = version + 1 WHERE id = $1`, [opts.id ?? "default"]);
2063
+ } finally {
2064
+ await pool.end();
2065
+ }
2066
+ }
2067
+ /**
2068
+ * Seed cron_last_fired for a registration with an `-INTERVAL` offset so
2069
+ * the scheduler fires on the next evaluation. Upsert via ON CONFLICT.
2070
+ */
2071
+ async function upsertCronLastFiredDirect(databaseUrl, opts) {
2072
+ const pool = createPool(databaseUrl);
2073
+ try {
2074
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at)
2075
+ VALUES ($1, NOW() - ($2)::interval)
2076
+ ON CONFLICT (registration_id) DO UPDATE SET
2077
+ last_fired_at = NOW() - ($2)::interval,
2078
+ updated_at = NOW()`, [opts.registrationId, opts.agoInterval]);
2079
+ } finally {
2080
+ await pool.end();
2081
+ }
2082
+ }
2083
+ /**
2084
+ * READ-ONLY: count cron_last_fired rows for a registration. Used by
2085
+ * registration-schema e2e to assert the FK cascade deletes the row.
2086
+ */
2087
+ async function countCronLastFiredDirect(databaseUrl, opts) {
2088
+ const pool = createPool(databaseUrl);
2089
+ try {
2090
+ return (await pool.query(`SELECT COUNT(*)::int AS cnt FROM cron_last_fired WHERE registration_id = $1`, [opts.registrationId])).rows[0]?.cnt ?? 0;
2091
+ } finally {
2092
+ await pool.end();
2093
+ }
2094
+ }
2095
+ /**
2096
+ * INSERT a cron_last_fired row with an explicit timestamp. Used by
2097
+ * registration-schema e2e to set up the FK cascade test.
2098
+ */
2099
+ async function insertCronLastFiredNowDirect(databaseUrl, opts) {
2100
+ const pool = createPool(databaseUrl);
2101
+ try {
2102
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at) VALUES ($1, NOW())`, [opts.registrationId]);
2103
+ } finally {
2104
+ await pool.end();
2105
+ }
2106
+ }
2107
+ /**
2108
+ * DELETE cron_last_fired rows for a registration. Teardown helper.
2109
+ */
2110
+ async function deleteCronLastFiredDirect(databaseUrl, opts) {
2111
+ const pool = createPool(databaseUrl);
2112
+ try {
2113
+ await pool.query(`DELETE FROM cron_last_fired WHERE registration_id = $1`, [opts.registrationId]);
2114
+ } finally {
2115
+ await pool.end();
2116
+ }
2117
+ }
2118
+ /**
2119
+ * DELETE execution_runs by workflow_name. Teardown helper for
2120
+ * manual-schedule e2e.
2121
+ */
2122
+ async function deleteExecutionRunsByWorkflowNameDirect(databaseUrl, opts) {
2123
+ const pool = createPool(databaseUrl);
2124
+ try {
2125
+ return { deleted: (await pool.query(`DELETE FROM execution_runs WHERE workflow_name = $1`, [opts.workflowName])).rowCount ?? 0 };
2126
+ } finally {
2127
+ await pool.end();
2128
+ }
2129
+ }
2130
+ /**
2131
+ * READ-ONLY: SELECT generic_webhook_sources by routing_key.
2132
+ * Used by forgejo e2e to assert the source exists with correct git_config.
2133
+ */
2134
+ async function getGenericWebhookSourceByRoutingKeyDirect(databaseUrl, opts) {
2135
+ const pool = createPool(databaseUrl);
2136
+ try {
2137
+ return (await pool.query(`SELECT id, git_config, customer_id FROM generic_webhook_sources WHERE routing_key = $1`, [opts.routingKey])).rows[0] ?? null;
2138
+ } finally {
2139
+ await pool.end();
2140
+ }
2141
+ }
2142
+ /**
2143
+ * READ-ONLY: list active (enabled=true) generic_webhook_sources.
2144
+ * Used by cluster-leader-failover e2e to prove the seeded source exists
2145
+ * before a leader crash.
2146
+ */
2147
+ async function listActiveGenericWebhookSourcesDirect(databaseUrl) {
2148
+ const pool = createPool(databaseUrl);
2149
+ try {
2150
+ return (await pool.query(`SELECT id, customer_id, routing_key FROM generic_webhook_sources WHERE enabled = true`)).rows;
2151
+ } finally {
2152
+ await pool.end();
2153
+ }
2154
+ }
2155
+ /**
2156
+ * UPDATE generic_webhook_sources.verification_config for a source by
2157
+ * name + customer_id. Used by the generic-webhook-auth e2e which seeds
2158
+ * sources and then writes custom auth configs.
2159
+ */
2160
+ async function updateGenericWebhookVerificationConfigDirect(databaseUrl, opts) {
2161
+ const pool = createPool(databaseUrl);
2162
+ try {
2163
+ await pool.query(`UPDATE generic_webhook_sources
2164
+ SET verification_config = $1
2165
+ WHERE name = $2 AND customer_id = $3`, [
2166
+ JSON.stringify(opts.verificationConfig),
2167
+ opts.name,
2168
+ opts.customerId
2169
+ ]);
2170
+ } finally {
2171
+ await pool.end();
2172
+ }
2173
+ }
2174
+ /**
2175
+ * DELETE generic_webhook_sources by name list. Teardown for the auth
2176
+ * e2e, which seeded 3 sources by name.
2177
+ */
2178
+ async function deleteGenericWebhookSourcesByNameDirect(databaseUrl, opts) {
2179
+ const pool = createPool(databaseUrl);
2180
+ try {
2181
+ return { deleted: (await pool.query(`DELETE FROM generic_webhook_sources WHERE name = ANY($1::text[])`, [opts.names])).rowCount ?? 0 };
2182
+ } finally {
2183
+ await pool.end();
2184
+ }
2185
+ }
2186
+ /**
2187
+ * UPDATE generic_webhook_sources.deleted_at = NULL for a row by id.
2188
+ * Used by the generic-webhook e2e to restore a soft-deleted source
2189
+ * after the soft-delete test ran — there is no CLI-level undelete.
2190
+ */
2191
+ async function restoreSoftDeletedGenericWebhookSourceDirect(databaseUrl, opts) {
2192
+ const pool = createPool(databaseUrl);
2193
+ try {
2194
+ await pool.query(`UPDATE generic_webhook_sources SET deleted_at = NULL WHERE id = $1`, [opts.id]);
2195
+ } finally {
2196
+ await pool.end();
2197
+ }
2198
+ }
2199
+ async function upsertOrgSettingsGlobalWorkflowsDirect(databaseUrl, opts) {
2200
+ const pool = createPool(databaseUrl);
2201
+ try {
2202
+ await pool.query(`INSERT INTO org_settings (
2203
+ customer_id, global_workflows_enabled,
2204
+ global_workflow_allowed_repos,
2205
+ global_workflow_denied_repos,
2206
+ global_workflow_elevated_repos
2207
+ ) VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb)
2208
+ ON CONFLICT (customer_id) DO UPDATE SET
2209
+ global_workflows_enabled = EXCLUDED.global_workflows_enabled,
2210
+ global_workflow_allowed_repos = EXCLUDED.global_workflow_allowed_repos,
2211
+ global_workflow_denied_repos = EXCLUDED.global_workflow_denied_repos,
2212
+ global_workflow_elevated_repos = EXCLUDED.global_workflow_elevated_repos,
2213
+ updated_at = NOW()`, [
2214
+ opts.customerId,
2215
+ opts.globalWorkflowsEnabled,
2216
+ opts.allowedRepos == null ? null : JSON.stringify(opts.allowedRepos),
2217
+ opts.deniedRepos == null ? null : JSON.stringify(opts.deniedRepos),
2218
+ opts.elevatedRepos == null ? null : JSON.stringify(opts.elevatedRepos)
2219
+ ]);
2220
+ } finally {
2221
+ await pool.end();
2222
+ }
2223
+ }
2224
+ /**
2225
+ * UPDATE org_settings.global_workflow_denied_repos for a customer/org id.
2226
+ * Assumes the row exists (upsertOrgSettingsGlobalWorkflowsDirect was
2227
+ * called earlier in the test).
2228
+ */
2229
+ async function updateOrgSettingsDeniedReposDirect(databaseUrl, opts) {
2230
+ const pool = createPool(databaseUrl);
2231
+ try {
2232
+ await pool.query(`UPDATE org_settings
2233
+ SET global_workflow_denied_repos = $2::jsonb,
2234
+ updated_at = NOW()
2235
+ WHERE customer_id = $1`, [opts.customerId, opts.deniedRepos == null ? null : JSON.stringify(opts.deniedRepos)]);
2236
+ } finally {
2237
+ await pool.end();
2238
+ }
2239
+ }
2240
+ /**
2241
+ * DELETE org_settings by customer/org id. Teardown helper.
2242
+ */
2243
+ async function deleteOrgSettingsByCustomerIdDirect(databaseUrl, opts) {
2244
+ const pool = createPool(databaseUrl);
2245
+ try {
2246
+ return { deleted: (await pool.query(`DELETE FROM org_settings WHERE customer_id = $1`, [opts.customerId])).rowCount ?? 0 };
2247
+ } finally {
2248
+ await pool.end();
2249
+ }
2250
+ }
2251
+ async function getExecutionRunSecurityDirect(databaseUrl, opts) {
2252
+ const pool = createPool(databaseUrl);
2253
+ try {
2254
+ const result = await pool.query(`SELECT run_id, trust_tier, lock_file_source, contributor_username, status
2255
+ FROM execution_runs WHERE run_id = $1`, [opts.runId]);
2256
+ if (result.rows.length === 0) throw new Error(`execution_runs: row not found (run_id=${opts.runId})`);
2257
+ return result.rows[0];
2258
+ } finally {
2259
+ await pool.end();
2260
+ }
2261
+ }
2262
+ async function getHeldRunByIdDirect(databaseUrl, opts) {
2263
+ const pool = createPool(databaseUrl);
2264
+ try {
2265
+ return (await pool.query(`SELECT id, run_id, hold_type, queue_type, status, reason,
2266
+ expires_at, approved_by, resolved_at
2267
+ FROM held_runs WHERE id = $1`, [opts.id])).rows[0] ?? null;
2268
+ } finally {
2269
+ await pool.end();
2270
+ }
2271
+ }
2272
+ /**
2273
+ * READ-ONLY: count held_runs rows matching run_id + queue_type.
2274
+ * Used by ci-security e2e to prove no security hold exists for a
2275
+ * trusted contributor PR.
2276
+ */
2277
+ async function countHeldRunsByRunIdDirect(databaseUrl, opts) {
2278
+ const pool = createPool(databaseUrl);
2279
+ try {
2280
+ return (opts.queueType ? await pool.query(`SELECT COUNT(*)::int AS cnt FROM held_runs WHERE run_id = $1 AND queue_type = $2`, [opts.runId, opts.queueType]) : await pool.query(`SELECT COUNT(*)::int AS cnt FROM held_runs WHERE run_id = $1`, [opts.runId])).rows[0]?.cnt ?? 0;
2281
+ } finally {
2282
+ await pool.end();
2283
+ }
2284
+ }
2285
+ /**
2286
+ * Wait for Platform-side `execution_runs.status = <status>` where
2287
+ * `created_at > since`. Returns the final status + failure_reason or
2288
+ * null if the timeout elapsed. Used by webhook-pipeline (failed) and
2289
+ * orchestrator-never-reconnects (timed_out_stale).
2290
+ */
2291
+ async function waitForPlatformExecutionRunStatusDirect(databaseUrl, opts) {
2292
+ const timeoutMs = opts.timeoutMs ?? 3e4;
2293
+ const intervalMs = opts.intervalMs ?? 2e3;
2294
+ const deadline = Date.now() + timeoutMs;
2295
+ const pool = createPool(databaseUrl);
2296
+ try {
2297
+ while (Date.now() < deadline) {
2298
+ const result = await pool.query(`SELECT status, failure_reason FROM execution_runs
2299
+ WHERE created_at > $1 AND status = $2
2300
+ ORDER BY created_at DESC LIMIT 1`, [opts.since, opts.status]);
2301
+ if (result.rows.length > 0) return result.rows[0];
2302
+ await new Promise((r) => setTimeout(r, intervalMs));
2303
+ }
2304
+ return null;
2305
+ } finally {
2306
+ await pool.end();
2307
+ }
2308
+ }
2309
+ /**
2310
+ * Wait for Platform `event_log` to show at least `minDistinctRouted`
2311
+ * distinct `routed_to` values since a given timestamp. Used by
2312
+ * cluster-round-robin to prove at least 2 orchestrators received
2313
+ * deliveries. Returns the observed count (0 if timed out).
2314
+ */
2315
+ async function waitForPlatformEventLogDistinctRoutedDirect(databaseUrl, opts) {
2316
+ const timeoutMs = opts.timeoutMs ?? 6e4;
2317
+ const intervalMs = opts.intervalMs ?? 3e3;
2318
+ const deadline = Date.now() + timeoutMs;
2319
+ const pool = createPool(databaseUrl);
2320
+ try {
2321
+ let distinct = 0;
2322
+ while (Date.now() < deadline) {
2323
+ distinct = (await pool.query(`SELECT COUNT(DISTINCT routed_to)::int AS cnt FROM event_log WHERE received_at > $1`, [opts.since])).rows[0]?.cnt ?? 0;
2324
+ if (distinct >= opts.minDistinctRouted) return { distinctRouted: distinct };
2325
+ await new Promise((r) => setTimeout(r, intervalMs));
2326
+ }
2327
+ return { distinctRouted: distinct };
2328
+ } finally {
2329
+ await pool.end();
2330
+ }
2331
+ }
2332
+ async function waitForEventLogRowByDeliveryIdDirect(databaseUrl, opts) {
2333
+ const timeoutMs = opts.timeoutMs ?? 1e4;
2334
+ const intervalMs = opts.intervalMs ?? 200;
2335
+ const deadline = Date.now() + timeoutMs;
2336
+ const pool = createPool(databaseUrl);
2337
+ try {
2338
+ while (Date.now() < deadline) {
2339
+ const res = await pool.query(`SELECT org_id, delivery_id, status, source, event, provider, payload_hash,
2340
+ payload_omitted, payload_key, payload_size_bytes, matched_count, run_id
2341
+ FROM event_log WHERE delivery_id = $1`, [opts.deliveryId]);
2342
+ if (res.rowCount && res.rowCount > 0) return res.rows[0];
2343
+ await new Promise((r) => setTimeout(r, intervalMs));
2344
+ }
2345
+ return null;
2346
+ } finally {
2347
+ await pool.end();
2348
+ }
2349
+ }
2350
+ /**
2351
+ * Resolve a Platform `webhook_sources.routing_key` for an org.
2352
+ *
2353
+ * - With `routingKeyLikePrefix` only: returns the newest match. Note that
2354
+ * this picks whichever generic source was registered most recently — if
2355
+ * tests have created additional generic sources (e.g. universal-git-forgejo
2356
+ * creating `stg-universal-git-forgejo`), the newest may NOT be the
2357
+ * `stg-generic` default seeded by `pnpm deploy:stg`.
2358
+ * - With `orchDbUrl` + `nameInOrchDb`: looks up the source by name in the
2359
+ * orchestrator's `generic_webhook_sources` table, then verifies the
2360
+ * resulting routing key is registered in Platform's `webhook_sources`.
2361
+ * This is the disambiguating path Bucket-B tests should use when other
2362
+ * generic sources may exist alongside the default.
2363
+ */
2364
+ async function resolvePlatformWebhookSourceRoutingKeyDirect(platformDbUrl, opts) {
2365
+ if (opts.orchDbUrl && opts.nameInOrchDb) {
2366
+ const orchPool = createPool(opts.orchDbUrl);
2367
+ let candidateRoutingKey = null;
2368
+ try {
2369
+ const id = (await orchPool.query(`SELECT id FROM generic_webhook_sources
2370
+ WHERE customer_id = $1 AND name = $2 AND deleted_at IS NULL
2371
+ LIMIT 1`, [opts.orgId, opts.nameInOrchDb])).rows[0]?.id;
2372
+ if (!id) return null;
2373
+ candidateRoutingKey = `generic:${opts.orgId}:${id}`;
2374
+ } finally {
2375
+ await orchPool.end();
2376
+ }
2377
+ const platformPool = createPool(platformDbUrl);
2378
+ try {
2379
+ return (await platformPool.query(`SELECT routing_key FROM webhook_sources
2380
+ WHERE org_id = $1 AND routing_key = $2 LIMIT 1`, [opts.orgId, candidateRoutingKey])).rows[0]?.routing_key ?? null;
2381
+ } finally {
2382
+ await platformPool.end();
2383
+ }
2384
+ }
2385
+ const pool = createPool(platformDbUrl);
2386
+ try {
2387
+ return (await pool.query(`SELECT routing_key FROM webhook_sources
2388
+ WHERE org_id = $1 AND routing_key LIKE $2
2389
+ ORDER BY registered_at DESC LIMIT 1`, [opts.orgId, `${opts.routingKeyLikePrefix}%`])).rows[0]?.routing_key ?? null;
2390
+ } finally {
2391
+ await pool.end();
2392
+ }
2393
+ }
2394
+ /**
2395
+ * Idempotent seeding of an E2E regular user + org membership + owner
2396
+ * role on the Platform DB. Used by stg-ha-smoke before seeding a user
2397
+ * API key. Requires the org to already have an owner role.
2398
+ */
2399
+ async function ensureOrgOwnerMemberDirect(platformDbUrl, opts) {
2400
+ const pool = createPool(platformDbUrl);
2401
+ try {
2402
+ const ownerRole = await pool.query(`SELECT id FROM roles WHERE org_id = $1 AND is_owner = true LIMIT 1`, [opts.orgId]);
2403
+ if (ownerRole.rows.length === 0) throw new Error(`ensureOrgOwnerMemberDirect: no owner role found for org ${opts.orgId}`);
2404
+ const ownerRoleId = ownerRole.rows[0].id;
2405
+ await pool.query(`INSERT INTO users (zitadel_sub, email, display_name)
2406
+ VALUES ($1, $2, $3)
2407
+ ON CONFLICT (zitadel_sub) DO NOTHING`, [
2408
+ opts.zitadelSub,
2409
+ opts.email,
2410
+ opts.displayName
2411
+ ]);
2412
+ await pool.query(`INSERT INTO org_members (org_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [opts.orgId, opts.zitadelSub]);
2413
+ await pool.query(`INSERT INTO role_assignments (org_id, user_id, role_id, assigned_by)
2414
+ VALUES ($1, $2, $3, $2)
2415
+ ON CONFLICT (org_id, user_id, role_id) DO NOTHING`, [
2416
+ opts.orgId,
2417
+ opts.zitadelSub,
2418
+ ownerRoleId
2419
+ ]);
2420
+ return { ownerRoleId };
2421
+ } finally {
2422
+ await pool.end();
2423
+ }
2424
+ }
2425
+ /**
2426
+ * Cleanup peer_credentials by instance_id LIKE pattern. Used by the
2427
+ * cluster-peer-credentials e2e before/after each test to wipe its
2428
+ * own seeded rows without touching real cluster credentials.
2429
+ */
2430
+ async function deletePeerCredentialsByInstanceIdLikeDirect(databaseUrl, opts) {
2431
+ const pool = createPool(databaseUrl);
2432
+ try {
2433
+ return { deleted: (await pool.query(`DELETE FROM peer_credentials WHERE instance_id LIKE $1`, [opts.pattern])).rowCount ?? 0 };
2434
+ } finally {
2435
+ await pool.end();
2436
+ }
2437
+ }
2438
+ /**
2439
+ * Insert a peer_credentials row with an explicit expires_at. Used by
2440
+ * the "expired credential is not returned" e2e — the store's save()
2441
+ * always computes a future expiry, so tests that need a pre-expired
2442
+ * row must bypass it.
2443
+ */
2444
+ async function insertPeerCredentialExpiredDirect(databaseUrl, opts) {
2445
+ const pool = createPool(databaseUrl);
2446
+ try {
2447
+ await pool.query(`INSERT INTO peer_credentials (instance_id, credential_hash, role, routing_keys, expires_at)
2448
+ VALUES ($1, $2, $3, $4::text[], $5)`, [
2449
+ opts.instanceId,
2450
+ opts.credentialHash,
2451
+ opts.role,
2452
+ Array.from(opts.routingKeys),
2453
+ opts.expiresAt
2454
+ ]);
2455
+ } finally {
2456
+ await pool.end();
2457
+ }
2458
+ }
2459
+ /**
2460
+ * READ-ONLY: `revoked_at` for a peer_credentials row by hash. Used
2461
+ * by the "save revokes old credential" e2e to assert revocation.
2462
+ * Returns null when the row is missing entirely (distinct from
2463
+ * "present but not revoked").
2464
+ */
2465
+ async function getPeerCredentialRevokedAtDirect(databaseUrl, opts) {
2466
+ const pool = createPool(databaseUrl);
2467
+ try {
2468
+ const result = await pool.query(`SELECT revoked_at FROM peer_credentials WHERE credential_hash = $1`, [opts.credentialHash]);
2469
+ if (result.rows.length === 0) return {
2470
+ present: false,
2471
+ revokedAt: null
2472
+ };
2473
+ return {
2474
+ present: true,
2475
+ revokedAt: result.rows[0].revoked_at
2476
+ };
2477
+ } finally {
2478
+ await pool.end();
2479
+ }
2480
+ }
2481
+ /**
2482
+ * READ-ONLY: list active peer_credentials ids EXCLUDING a
2483
+ * `instance_id LIKE` pattern. Used by revokeAll e2e to snapshot
2484
+ * real cluster credentials it will restore later.
2485
+ */
2486
+ async function listActivePeerCredentialsExcludingDirect(databaseUrl, opts) {
2487
+ const pool = createPool(databaseUrl);
2488
+ try {
2489
+ return { ids: (await pool.query(`SELECT id FROM peer_credentials
2490
+ WHERE revoked_at IS NULL AND instance_id NOT LIKE $1`, [opts.excludeInstanceIdPattern])).rows.map((r) => r.id) };
2491
+ } finally {
2492
+ await pool.end();
2493
+ }
2494
+ }
2495
+ /**
2496
+ * Clear revoked_at on a set of peer_credentials ids. Used by revokeAll
2497
+ * e2e to restore real cluster credentials after the destructive test.
2498
+ */
2499
+ async function clearPeerCredentialsRevokedAtByIdsDirect(databaseUrl, opts) {
2500
+ if (opts.ids.length === 0) return { updated: 0 };
2501
+ const pool = createPool(databaseUrl);
2502
+ try {
2503
+ return { updated: (await pool.query(`UPDATE peer_credentials SET revoked_at = NULL WHERE id = ANY($1::uuid[])`, [opts.ids])).rowCount ?? 0 };
2504
+ } finally {
2505
+ await pool.end();
2506
+ }
2507
+ }
2508
+ /**
2509
+ * READ-ONLY: count currently-active (non-revoked, non-expired)
2510
+ * peer_credentials rows for a given instance_id. Used by the
2511
+ * concurrent-save e2e to assert 1 <= count <= 2.
2512
+ */
2513
+ async function countActivePeerCredentialsByInstanceDirect(databaseUrl, opts) {
2514
+ const pool = createPool(databaseUrl);
2515
+ try {
2516
+ return (await pool.query(`SELECT count(*)::int AS cnt FROM peer_credentials
2517
+ WHERE instance_id = $1 AND revoked_at IS NULL AND expires_at > now()`, [opts.instanceId])).rows[0]?.cnt ?? 0;
2518
+ } finally {
2519
+ await pool.end();
2520
+ }
2521
+ }
2522
+ //#endregion
2523
+ export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createDbRole, createEnvironmentTemplateDirect, createJoinTokenDirect, createReadOnlyDbUser, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedCrossRepoTrustDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, showEnvironmentDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2524
+
2525
+ //# sourceMappingURL=db-admin.js.map