@kici-dev/shared 0.1.26 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db-admin.js CHANGED
@@ -2,6 +2,7 @@ import "./rolldown-runtime-ClRpJifh.js";
2
2
  import { createPool } from "./db.js";
3
3
  import { createHash } from "node:crypto";
4
4
  import pg from "pg";
5
+ import { HoldType, unknownContributorHoldReason } from "@kici-dev/engine";
5
6
  //#region src/db-admin.ts
6
7
  /**
7
8
  * Admin/DB-operations helpers shared between `kici-admin` (orchestrator DB)
@@ -341,18 +342,18 @@ async function purgeScopedSecretsDirect(databaseUrl, orgId) {
341
342
  }
342
343
  }
343
344
  /**
344
- * Bulk-delete `environments` (and their FK-dependent rows) for an org, or for
345
- * every org when `orgId` is omitted. `environment_bindings` /
346
- * `environment_variables` / `environment_source_overrides` cascade
345
+ * Bulk-delete `contexts` (and their FK-dependent rows) for an org, or for
346
+ * every org when `orgId` is omitted. `context_bindings` /
347
+ * `context_variables` / `context_source_overrides` cascade
347
348
  * automatically (ON DELETE CASCADE). `held_runs` and `execution_runs` reference
348
- * `environments(id)` with ON DELETE SET NULL, so deleting environments alone
349
- * would leave orphaned `held_runs` rows carrying a null environment reference;
349
+ * `contexts(id)` with ON DELETE SET NULL, so deleting contexts alone
350
+ * would leave orphaned `held_runs` rows carrying a null context reference;
350
351
  * this helper deletes the org's `held_runs` too so a warm-start reset gets a
351
352
  * clean slate. Runs in a transaction so both deletes commit atomically. Used by
352
- * the E2E warm-start reset (so seeded environments don't leak between
353
- * categories) and exposed via `kici-admin environment purge`.
353
+ * the E2E warm-start reset (so seeded contexts don't leak between
354
+ * categories) and exposed via `kici-admin context purge`.
354
355
  */
355
- async function purgeEnvironmentsDirect(databaseUrl, orgId) {
356
+ async function purgeContextsDirect(databaseUrl, orgId) {
356
357
  const pool = createPool(databaseUrl);
357
358
  const client = await pool.connect();
358
359
  try {
@@ -360,10 +361,10 @@ async function purgeEnvironmentsDirect(databaseUrl, orgId) {
360
361
  const where = orgId ? "WHERE org_id = $1" : "";
361
362
  const params = orgId ? [orgId] : [];
362
363
  const held = await client.query(`DELETE FROM held_runs ${where}`, params);
363
- const envs = await client.query(`DELETE FROM environments ${where}`, params);
364
+ const envs = await client.query(`DELETE FROM contexts ${where}`, params);
364
365
  await client.query("COMMIT");
365
366
  return {
366
- environmentsDeleted: envs.rowCount ?? 0,
367
+ contextsDeleted: envs.rowCount ?? 0,
367
368
  heldRunsDeleted: held.rowCount ?? 0
368
369
  };
369
370
  } catch (err) {
@@ -375,7 +376,7 @@ async function purgeEnvironmentsDirect(databaseUrl, orgId) {
375
376
  }
376
377
  }
377
378
  /**
378
- * Allowed policy field names for `setEnvironmentPolicyDirect`. Kept as an
379
+ * Allowed policy field names for `setContextPolicyDirect`. Kept as an
379
380
  * explicit allowlist so the column-name interpolation in the UPDATE string
380
381
  * can never be driven by unsanitised caller input.
381
382
  */
@@ -389,13 +390,19 @@ const ENV_POLICY_COLUMNS = /* @__PURE__ */ new Set([
389
390
  "allow_local_execution"
390
391
  ]);
391
392
  /**
392
- * Upsert an environment row keyed by (org_id, name). Returns the env id and
393
+ * Upsert a context row keyed by (org_id, name). Returns the env id and
393
394
  * whether the row was newly inserted. `branchRestrictions` / `requiredReviewers`
394
395
  * are JSON-serialised server-side; pass them as plain arrays or objects.
395
- */
396
- async function seedEnvironmentDirect(databaseUrl, opts) {
397
- if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`environment: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
398
- if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`environment: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
396
+ *
397
+ * An omitted `holdExpirySeconds` is written as NULL rather than a literal
398
+ * window: the column carries no DDL default, so "never set" and "cleared" both
399
+ * land on NULL and resolve through the one `DEFAULT_HOLD_EXPIRY_SECONDS`
400
+ * fallback on read. Writing a literal here would give this path a second,
401
+ * longer default that no read-side code knows about.
402
+ */
403
+ async function seedContextDirect(databaseUrl, opts) {
404
+ if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`context: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
405
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
399
406
  const pool = new pg.Pool({
400
407
  connectionString: databaseUrl,
401
408
  max: 1
@@ -403,20 +410,20 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
403
410
  try {
404
411
  const branchJson = JSON.stringify(opts.branchRestrictions ?? []);
405
412
  const reviewersJson = opts.requiredReviewers === void 0 ? null : JSON.stringify(opts.requiredReviewers);
406
- const row = (await pool.query(`INSERT INTO environments
413
+ const row = (await pool.query(`INSERT INTO contexts
407
414
  (org_id, name, type, enabled, branch_restrictions, required_reviewers,
408
415
  wait_timer_seconds, hold_expiry_seconds, minimum_trust, glob_pattern)
409
416
  VALUES ($1, $2, COALESCE($3, 'fixed'), COALESCE($4, true), $5::jsonb, $6::jsonb,
410
- $7, COALESCE($8, 86400), $9, $10)
417
+ $7, $8, $9, $10)
411
418
  ON CONFLICT (org_id, name) DO UPDATE SET
412
- type = COALESCE(EXCLUDED.type, environments.type),
419
+ type = COALESCE(EXCLUDED.type, contexts.type),
413
420
  enabled = EXCLUDED.enabled,
414
421
  branch_restrictions = EXCLUDED.branch_restrictions,
415
422
  required_reviewers = EXCLUDED.required_reviewers,
416
423
  wait_timer_seconds = EXCLUDED.wait_timer_seconds,
417
424
  hold_expiry_seconds = EXCLUDED.hold_expiry_seconds,
418
425
  minimum_trust = EXCLUDED.minimum_trust,
419
- glob_pattern = COALESCE(EXCLUDED.glob_pattern, environments.glob_pattern),
426
+ glob_pattern = COALESCE(EXCLUDED.glob_pattern, contexts.glob_pattern),
420
427
  updated_at = now()
421
428
  RETURNING id, (xmax = 0) AS inserted`, [
422
429
  opts.orgId,
@@ -430,7 +437,7 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
430
437
  opts.minimumTrust ?? null,
431
438
  opts.globPattern ?? null
432
439
  ])).rows[0];
433
- if (!row) throw new Error(`environment: upsert returned no row for ${opts.name}`);
440
+ if (!row) throw new Error(`context: upsert returned no row for ${opts.name}`);
434
441
  return {
435
442
  envId: row.id,
436
443
  created: row.inserted
@@ -440,47 +447,47 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
440
447
  }
441
448
  }
442
449
  /**
443
- * Delete an environment keyed by (org_id, name). Returns whether a row was
444
- * removed. The `environment_bindings`, `environment_variables`, and
445
- * `environment_source_overrides` children all carry
446
- * `FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE`,
447
- * so a single DELETE on `environments` cascades to those children. The
450
+ * Delete a context keyed by (org_id, name). Returns whether a row was
451
+ * removed. The `context_bindings`, `context_variables`, and
452
+ * `context_source_overrides` children all carry
453
+ * `FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE CASCADE`,
454
+ * so a single DELETE on `contexts` cascades to those children. The
448
455
  * `held_runs` FK uses `ON DELETE SET NULL`, so terminal held-run history
449
- * survives the delete with a null environment reference. Pending held runs
450
- * still reference the environment, so this helper pre-checks their count and
456
+ * survives the delete with a null context reference. Pending held runs
457
+ * still reference the context, so this helper pre-checks their count and
451
458
  * throws before issuing the DELETE — approve or reject them first.
452
459
  */
453
- async function deleteEnvironmentDirect(databaseUrl, opts) {
460
+ async function deleteContextDirect(databaseUrl, opts) {
454
461
  const pool = new pg.Pool({
455
462
  connectionString: databaseUrl,
456
463
  max: 1
457
464
  });
458
465
  try {
459
466
  const pending = await pool.query(`SELECT count(*)::text AS count FROM held_runs hr
460
- JOIN environments e ON e.id = hr.environment_id
467
+ JOIN contexts e ON e.id = hr.context_id
461
468
  WHERE e.org_id = $1 AND e.name = $2 AND hr.status = 'pending'`, [opts.orgId, opts.name]);
462
469
  const pendingCount = Number(pending.rows[0]?.count ?? 0);
463
- if (pendingCount > 0) throw new Error(`environment has ${pendingCount} pending held run(s) — approve or reject them first`);
464
- return { deleted: (await pool.query(`DELETE FROM environments WHERE org_id = $1 AND name = $2 RETURNING id`, [opts.orgId, opts.name])).rows.length > 0 };
470
+ if (pendingCount > 0) throw new Error(`context has ${pendingCount} pending held run(s) — approve or reject them first`);
471
+ return { deleted: (await pool.query(`DELETE FROM contexts WHERE org_id = $1 AND name = $2 RETURNING id`, [opts.orgId, opts.name])).rows.length > 0 };
465
472
  } finally {
466
473
  await pool.end();
467
474
  }
468
475
  }
469
476
  /**
470
- * Upsert an `environment_bindings` row connecting `envName` to `scopePattern`
471
- * (scoped to `hostPattern`, default `'**'`). Throws if the environment does
477
+ * Upsert an `context_bindings` row connecting `contextName` to `scopePattern`
478
+ * (scoped to `hostPattern`, default `'**'`). Throws if the context does
472
479
  * not exist.
473
480
  */
474
- async function seedEnvironmentBindingDirect(databaseUrl, opts) {
481
+ async function seedContextBindingDirect(databaseUrl, opts) {
475
482
  const pool = new pg.Pool({
476
483
  connectionString: databaseUrl,
477
484
  max: 1
478
485
  });
479
486
  try {
480
- const envRow = await pool.query(`SELECT id FROM environments WHERE org_id = $1 AND name = $2`, [opts.orgId, opts.envName]);
481
- if (envRow.rows.length === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.envName})`);
487
+ const envRow = await pool.query(`SELECT id FROM contexts WHERE org_id = $1 AND name = $2`, [opts.orgId, opts.contextName]);
488
+ if (envRow.rows.length === 0) throw new Error(`context: not found (org=${opts.orgId}, name=${opts.contextName})`);
482
489
  const envId = envRow.rows[0].id;
483
- return { created: (await pool.query(`INSERT INTO environment_bindings (org_id, environment_id, scope_pattern, host_pattern)
490
+ return { created: (await pool.query(`INSERT INTO context_bindings (org_id, context_id, scope_pattern, host_pattern)
484
491
  VALUES ($1, $2, $3, $4)
485
492
  ON CONFLICT DO NOTHING
486
493
  RETURNING (xmax = 0) AS inserted`, [
@@ -495,16 +502,16 @@ async function seedEnvironmentBindingDirect(databaseUrl, opts) {
495
502
  }
496
503
  /**
497
504
  * UPDATE only the policy fields that were explicitly provided. Columns that
498
- * were NOT in `opts` are left untouched. Throws if the environment is missing.
505
+ * were NOT in `opts` are left untouched. Throws if the context is missing.
499
506
  */
500
- async function setEnvironmentPolicyDirect(databaseUrl, opts) {
501
- if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`environment: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
502
- if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`environment: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
507
+ async function setContextPolicyDirect(databaseUrl, opts) {
508
+ if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`context: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
509
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
503
510
  const setClauses = [];
504
511
  const params = [];
505
512
  let idx = 1;
506
513
  const addSet = (column, value, cast) => {
507
- if (!ENV_POLICY_COLUMNS.has(column)) throw new Error(`environment: unknown policy column ${column}`);
514
+ if (!ENV_POLICY_COLUMNS.has(column)) throw new Error(`context: unknown policy column ${column}`);
508
515
  setClauses.push(`${column} = $${idx}${cast ? `::${cast}` : ""}`);
509
516
  params.push(value);
510
517
  idx += 1;
@@ -516,36 +523,36 @@ async function setEnvironmentPolicyDirect(databaseUrl, opts) {
516
523
  if (opts.minimumTrust !== void 0) addSet("minimum_trust", opts.minimumTrust);
517
524
  if (opts.enabled !== void 0) addSet("enabled", opts.enabled);
518
525
  if (opts.allowLocalExecution !== void 0) addSet("allow_local_execution", opts.allowLocalExecution);
519
- if (setClauses.length === 0) throw new Error("environment: setEnvironmentPolicy requires at least one policy field");
526
+ if (setClauses.length === 0) throw new Error("context: setContextPolicy requires at least one policy field");
520
527
  const pool = new pg.Pool({
521
528
  connectionString: databaseUrl,
522
529
  max: 1
523
530
  });
524
531
  try {
525
- params.push(opts.orgId, opts.envName);
532
+ params.push(opts.orgId, opts.contextName);
526
533
  const orgParam = `$${idx}`;
527
534
  const nameParam = `$${idx + 1}`;
528
- const sql = `UPDATE environments
535
+ const sql = `UPDATE contexts
529
536
  SET ${setClauses.join(", ")}, updated_at = now()
530
537
  WHERE org_id = ${orgParam} AND name = ${nameParam}`;
531
- if (((await pool.query(sql, params)).rowCount ?? 0) === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.envName})`);
538
+ if (((await pool.query(sql, params)).rowCount ?? 0) === 0) throw new Error(`context: not found (org=${opts.orgId}, name=${opts.contextName})`);
532
539
  } finally {
533
540
  await pool.end();
534
541
  }
535
542
  }
536
543
  /**
537
- * SELECT * FROM environments WHERE org_id = $1, ordered by name.
544
+ * SELECT * FROM contexts WHERE org_id = $1, ordered by name.
538
545
  */
539
- async function listEnvironmentsDirect(databaseUrl, opts) {
546
+ async function listContextsDirect(databaseUrl, opts) {
540
547
  const pool = new pg.Pool({
541
548
  connectionString: databaseUrl,
542
549
  max: 1
543
550
  });
544
551
  try {
545
- return { environments: (await pool.query(`SELECT id, org_id, name, type, enabled, branch_restrictions, required_reviewers,
552
+ return { contexts: (await pool.query(`SELECT id, org_id, name, type, enabled, branch_restrictions, required_reviewers,
546
553
  wait_timer_seconds, hold_expiry_seconds, minimum_trust,
547
554
  created_at, updated_at
548
- FROM environments
555
+ FROM contexts
549
556
  WHERE org_id = $1
550
557
  ORDER BY name`, [opts.orgId])).rows };
551
558
  } finally {
@@ -553,10 +560,10 @@ async function listEnvironmentsDirect(databaseUrl, opts) {
553
560
  }
554
561
  }
555
562
  /**
556
- * Fetch a single environment row joined with its variables and bindings.
557
- * Throws if the environment does not exist.
563
+ * Fetch a single context row joined with its variables and bindings.
564
+ * Throws if the context does not exist.
558
565
  */
559
- async function showEnvironmentDirect(databaseUrl, opts) {
566
+ async function showContextDirect(databaseUrl, opts) {
560
567
  const pool = new pg.Pool({
561
568
  connectionString: databaseUrl,
562
569
  max: 1
@@ -565,20 +572,20 @@ async function showEnvironmentDirect(databaseUrl, opts) {
565
572
  const envResult = await pool.query(`SELECT id, org_id, name, type, enabled, branch_restrictions, required_reviewers,
566
573
  wait_timer_seconds, hold_expiry_seconds, minimum_trust,
567
574
  created_at, updated_at
568
- FROM environments
575
+ FROM contexts
569
576
  WHERE org_id = $1 AND name = $2`, [opts.orgId, opts.name]);
570
- if (envResult.rows.length === 0) throw new Error(`environment: not found (org=${opts.orgId}, name=${opts.name})`);
577
+ if (envResult.rows.length === 0) throw new Error(`context: not found (org=${opts.orgId}, name=${opts.name})`);
571
578
  const env = envResult.rows[0];
572
579
  const variables = await pool.query(`SELECT key, value, locked, updated_at
573
- FROM environment_variables
574
- WHERE environment_id = $1
580
+ FROM context_variables
581
+ WHERE context_id = $1
575
582
  ORDER BY key`, [env.id]);
576
583
  const bindings = await pool.query(`SELECT scope_pattern, host_pattern, created_at
577
- FROM environment_bindings
578
- WHERE environment_id = $1
584
+ FROM context_bindings
585
+ WHERE context_id = $1
579
586
  ORDER BY scope_pattern, host_pattern`, [env.id]);
580
587
  return {
581
- environment: env,
588
+ context: env,
582
589
  variables: variables.rows,
583
590
  bindings: bindings.rows
584
591
  };
@@ -587,11 +594,16 @@ async function showEnvironmentDirect(databaseUrl, opts) {
587
594
  }
588
595
  }
589
596
  /**
590
- * Create (or update) an environment template + its seed variables in one
591
- * transaction. Templates are represented as environments with `type='template'`
597
+ * Create (or update) a context template + its seed variables in one
598
+ * transaction. Templates are represented as contexts with `type='template'`
592
599
  * by convention. Returns `{ envId, variablesSet }`.
600
+ *
601
+ * An omitted `holdExpirySeconds` is written as NULL rather than a literal
602
+ * window, for the same reason as `seedContextDirect`: the column has no DDL
603
+ * default and every read resolves NULL through `DEFAULT_HOLD_EXPIRY_SECONDS`.
593
604
  */
594
- async function createEnvironmentTemplateDirect(databaseUrl, opts) {
605
+ async function createContextTemplateDirect(databaseUrl, opts) {
606
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
595
607
  const pool = new pg.Pool({
596
608
  connectionString: databaseUrl,
597
609
  max: 1
@@ -599,12 +611,12 @@ async function createEnvironmentTemplateDirect(databaseUrl, opts) {
599
611
  const client = await pool.connect();
600
612
  try {
601
613
  await client.query("BEGIN");
602
- const row = (await client.query(`INSERT INTO environments
614
+ const row = (await client.query(`INSERT INTO contexts
603
615
  (org_id, name, type, enabled, branch_restrictions, required_reviewers,
604
616
  wait_timer_seconds, hold_expiry_seconds, minimum_trust)
605
- VALUES ($1, $2, COALESCE($3, 'template'), true, $4::jsonb, $5::jsonb, $6, COALESCE($7, 86400), $8)
617
+ VALUES ($1, $2, COALESCE($3, 'template'), true, $4::jsonb, $5::jsonb, $6, $7, $8)
606
618
  ON CONFLICT (org_id, name) DO UPDATE SET
607
- type = COALESCE(EXCLUDED.type, environments.type),
619
+ type = COALESCE(EXCLUDED.type, contexts.type),
608
620
  branch_restrictions = EXCLUDED.branch_restrictions,
609
621
  required_reviewers = EXCLUDED.required_reviewers,
610
622
  wait_timer_seconds = EXCLUDED.wait_timer_seconds,
@@ -621,12 +633,12 @@ async function createEnvironmentTemplateDirect(databaseUrl, opts) {
621
633
  opts.holdExpirySeconds ?? null,
622
634
  opts.minimumTrust ?? null
623
635
  ])).rows[0];
624
- if (!row) throw new Error(`environment: template upsert returned no row`);
636
+ if (!row) throw new Error(`context: template upsert returned no row`);
625
637
  let variablesSet = 0;
626
638
  if (opts.variables) for (const [key, value] of Object.entries(opts.variables)) {
627
- await client.query(`INSERT INTO environment_variables (org_id, environment_id, key, value, locked)
639
+ await client.query(`INSERT INTO context_variables (org_id, context_id, key, value, locked)
628
640
  VALUES ($1, $2, $3, $4, false)
629
- ON CONFLICT (org_id, environment_id, key) DO UPDATE SET
641
+ ON CONFLICT (org_id, context_id, key) DO UPDATE SET
630
642
  value = EXCLUDED.value,
631
643
  updated_at = now()`, [
632
644
  opts.orgId,
@@ -651,14 +663,14 @@ async function createEnvironmentTemplateDirect(databaseUrl, opts) {
651
663
  }
652
664
  }
653
665
  /**
654
- * UPSERT a scoped_secrets row keyed by (org_id, scope=environment, key).
666
+ * UPSERT a scoped_secrets row keyed by (org_id, scope=context, key).
655
667
  * Writes the value verbatim — the caller is responsible for encryption
656
668
  * (matches the stage-4 deferral noted in the plan).
657
669
  */
658
- async function setEnvironmentSecretDirect(databaseUrl, opts) {
659
- if (!opts.orgId) throw new Error("environment: orgId required");
660
- if (!opts.environment) throw new Error("environment: environment name required");
661
- if (!opts.key) throw new Error("environment: key required");
670
+ async function setContextSecretDirect(databaseUrl, opts) {
671
+ if (!opts.orgId) throw new Error("context: orgId required");
672
+ if (!opts.context) throw new Error("context: context name required");
673
+ if (!opts.key) throw new Error("context: key required");
662
674
  const pool = new pg.Pool({
663
675
  connectionString: databaseUrl,
664
676
  max: 1
@@ -671,7 +683,7 @@ async function setEnvironmentSecretDirect(databaseUrl, opts) {
671
683
  updated_at = now()
672
684
  RETURNING (xmax = 0) AS inserted`, [
673
685
  opts.orgId,
674
- opts.environment,
686
+ opts.context,
675
687
  opts.key,
676
688
  opts.encryptedValue
677
689
  ])).rows[0]?.inserted ?? false };
@@ -789,7 +801,7 @@ async function listExecutionRunsDirect(databaseUrl, opts = {}) {
789
801
  const limit = Math.max(1, Math.min(1e3, opts.limit ?? 100));
790
802
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
791
803
  return { runs: (await pool.query(`SELECT id, run_id, workflow_name, status, provider, repo_identifier,
792
- ref, sha, routing_key, environment, trust_tier, created_at,
804
+ ref, sha, routing_key, context, trust_tier, created_at,
793
805
  started_at, completed_at, duration_ms
794
806
  FROM execution_runs
795
807
  ${where}
@@ -800,6 +812,38 @@ async function listExecutionRunsDirect(databaseUrl, opts = {}) {
800
812
  }
801
813
  }
802
814
  /**
815
+ * READ-ONLY: SELECT check_run_tracking rows for a commit. One row per
816
+ * `(provider, owner, repo, sha, check_name)`, ordered by check_name.
817
+ *
818
+ * Each column answers a different question. `check_run_id` is written once,
819
+ * when the check run is created in the `queued` state, so it answers "did we
820
+ * create it?". `terminal_sent_at` is stamped only after the provider accepts
821
+ * the terminal `completed` PATCH, so it answers "did we complete it?". Every
822
+ * write here is best-effort, so a null column is "no record", never proof of
823
+ * failure — see the per-field notes for what each one does and does not prove.
824
+ */
825
+ async function listCheckRunTrackingDirect(databaseUrl, opts) {
826
+ const pool = createPool(databaseUrl);
827
+ try {
828
+ const clauses = ["sha = $1"];
829
+ const params = [opts.sha];
830
+ if (opts.checkName !== void 0) {
831
+ clauses.push(`check_name = $2`);
832
+ params.push(opts.checkName);
833
+ }
834
+ const limit = Math.max(1, Math.min(1e3, opts.limit ?? 50));
835
+ return { rows: (await pool.query(`SELECT provider, owner, repo, sha, check_name,
836
+ check_run_id::text AS check_run_id,
837
+ build_creation_state, run_id, in_progress_sent_at, terminal_sent_at
838
+ FROM check_run_tracking
839
+ WHERE ${clauses.join(" AND ")}
840
+ ORDER BY check_name
841
+ LIMIT ${limit}`, params)).rows };
842
+ } finally {
843
+ await pool.end();
844
+ }
845
+ }
846
+ /**
803
847
  * READ-ONLY: fetch a single run by run_id AND its jobs. Throws if no run
804
848
  * matches the run_id. Jobs list may be empty for pending runs.
805
849
  */
@@ -807,7 +851,7 @@ async function showExecutionRunDirect(databaseUrl, opts) {
807
851
  const pool = createPool(databaseUrl);
808
852
  try {
809
853
  const runResult = await pool.query(`SELECT id, run_id, workflow_name, status, provider, repo_identifier,
810
- ref, sha, routing_key, environment, trust_tier, created_at,
854
+ ref, sha, routing_key, context, trust_tier, created_at,
811
855
  started_at, completed_at, duration_ms
812
856
  FROM execution_runs
813
857
  WHERE run_id = $1`, [opts.runId]);
@@ -816,7 +860,7 @@ async function showExecutionRunDirect(databaseUrl, opts) {
816
860
  return {
817
861
  run,
818
862
  jobs: (await pool.query(`SELECT id, run_id, job_id, job_name, status, agent_id,
819
- started_at, completed_at, duration_ms, created_at, error_message, environments
863
+ started_at, completed_at, duration_ms, created_at, error_message, contexts
820
864
  FROM execution_jobs
821
865
  WHERE run_id = $1
822
866
  ORDER BY created_at ASC`, [run.run_id])).rows
@@ -834,7 +878,7 @@ async function listExecutionJobsDirect(databaseUrl, opts) {
834
878
  try {
835
879
  return { jobs: (await pool.query(`SELECT j.id, j.run_id, j.job_id, j.job_name, j.status, j.agent_id,
836
880
  j.started_at, j.completed_at, j.duration_ms, j.created_at, j.error_message,
837
- j.environments
881
+ j.contexts
838
882
  FROM execution_jobs j
839
883
  INNER JOIN execution_runs r ON r.run_id = j.run_id
840
884
  WHERE r.run_id::text = $1 OR r.id::text = $1
@@ -1648,8 +1692,10 @@ async function seedUniversalGitSourceDirect(databaseUrl, opts) {
1648
1692
  await pool.end();
1649
1693
  }
1650
1694
  }
1695
+ /** repo_identifier used for the different-repo isolation hold. */
1696
+ const CI_SECURITY_OTHER_REPO = "other/repo";
1651
1697
  async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1652
- const envName = opts.envName ?? "ci-security-env";
1698
+ const contextName = opts.contextName ?? "ci-security-env";
1653
1699
  const sourceName = opts.sourceName ?? "ci-security-dashboard-resolver";
1654
1700
  const sourceRoutingKey = opts.sourceRoutingKey ?? `generic:${opts.orgId}:ci-security-dashboard`;
1655
1701
  const pool = createPool(databaseUrl);
@@ -1661,37 +1707,89 @@ async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1661
1707
  sourceRoutingKey,
1662
1708
  opts.orgId
1663
1709
  ]);
1664
- const envId = (await pool.query(`INSERT INTO environments (org_id, name, type, enabled)
1710
+ const envId = (await pool.query(`INSERT INTO contexts (org_id, name, type, enabled)
1665
1711
  VALUES ($1, $2, \'fixed\', true)
1666
1712
  ON CONFLICT (org_id, name) DO UPDATE SET enabled = true
1667
- RETURNING id`, [opts.orgId, envName])).rows[0].id;
1668
- await pool.query(`INSERT INTO execution_runs (
1669
- run_id, workflow_name, provider, repo_identifier,
1670
- ref, sha, delivery_id, status, trust_tier, lock_file_source,
1671
- contributor_username, routing_key
1672
- ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1673
- \'abc123\', $2, \'pending\', \'unknown\', \'base\', \'unknown-dev\', $3)`, [
1674
- opts.unknownRunId,
1675
- opts.unknownDeliveryId,
1676
- opts.runsRoutingKey
1677
- ]);
1678
- await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1679
- VALUES ($1, $2, \'security-test-job\', \'pending\')`, [opts.unknownJobId, opts.unknownRunId]);
1680
- const heldRunId = (await pool.query(`INSERT INTO held_runs (org_id, run_id, job_id, environment_id, hold_type, queue_type, reason, expires_at)
1681
- VALUES ($1, $2, $3, $4, \'unknown_contributor\', \'security\',
1682
- \'Unknown contributor requires approval\', NOW() + INTERVAL \'72 hours\')
1683
- RETURNING id`, [
1684
- opts.orgId,
1685
- opts.unknownRunId,
1686
- opts.unknownJobId,
1687
- envId
1688
- ])).rows[0].id;
1713
+ RETURNING id`, [opts.orgId, contextName])).rows[0].id;
1714
+ async function seedPrHold(args) {
1715
+ await pool.query(`INSERT INTO execution_runs (
1716
+ run_id, workflow_name, provider, repo_identifier,
1717
+ ref, sha, delivery_id, status, trust_tier, lock_file_source,
1718
+ contributor_username, routing_key, pr_number
1719
+ ) VALUES ($1, \'e2e-security-wf\', \'internal\', $4, \'refs/heads/feature\',
1720
+ $5, $2, \'pending\', \'unknown\', \'base\', \'unknown-dev\', $3, $6)`, [
1721
+ args.runId,
1722
+ args.deliveryId,
1723
+ opts.runsRoutingKey,
1724
+ args.repoIdentifier,
1725
+ args.sha,
1726
+ args.prNumber
1727
+ ]);
1728
+ await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1729
+ VALUES ($1, $2, \'security-test-job\', \'pending\')`, [args.jobId, args.runId]);
1730
+ return (await pool.query(`INSERT INTO held_runs (org_id, run_id, job_id, context_id, hold_type, queue_type, reason, expires_at)
1731
+ VALUES ($1, $2, $3, $4, $5, \'security\', $6, NOW() + INTERVAL \'72 hours\')
1732
+ RETURNING id`, [
1733
+ opts.orgId,
1734
+ args.runId,
1735
+ args.jobId,
1736
+ args.contextId === void 0 ? envId : args.contextId,
1737
+ args.holdType ?? HoldType.enum.security,
1738
+ args.reason ?? unknownContributorHoldReason(contextName)
1739
+ ])).rows[0].id;
1740
+ }
1741
+ const heldRunId = await seedPrHold({
1742
+ runId: opts.unknownRunId,
1743
+ deliveryId: opts.unknownDeliveryId,
1744
+ jobId: opts.unknownJobId,
1745
+ repoIdentifier: ".",
1746
+ prNumber: 1,
1747
+ sha: "abc123"
1748
+ });
1749
+ const secondHeldRunId = await seedPrHold({
1750
+ runId: opts.secondPrRunId,
1751
+ deliveryId: opts.secondPrDeliveryId,
1752
+ jobId: opts.secondPrJobId,
1753
+ repoIdentifier: ".",
1754
+ prNumber: 2,
1755
+ sha: "bbb222"
1756
+ });
1757
+ const otherRepoHeldRunId = await seedPrHold({
1758
+ runId: opts.otherRepoRunId,
1759
+ deliveryId: opts.otherRepoDeliveryId,
1760
+ jobId: opts.otherRepoJobId,
1761
+ repoIdentifier: CI_SECURITY_OTHER_REPO,
1762
+ prNumber: 1,
1763
+ sha: "ccc333"
1764
+ });
1765
+ const wfModHeldRunId = await seedPrHold({
1766
+ runId: opts.wfModRunId,
1767
+ deliveryId: opts.wfModDeliveryId,
1768
+ jobId: opts.wfModJobId,
1769
+ repoIdentifier: ".",
1770
+ prNumber: 3,
1771
+ sha: "ddd444",
1772
+ holdType: HoldType.enum.security,
1773
+ reason: "workflow_modification",
1774
+ contextId: null
1775
+ });
1776
+ const forkPrHeldRunId = await seedPrHold({
1777
+ runId: opts.forkPrRunId,
1778
+ deliveryId: opts.forkPrDeliveryId,
1779
+ jobId: opts.forkPrJobId,
1780
+ repoIdentifier: ".",
1781
+ prNumber: 4,
1782
+ sha: "eee555",
1783
+ holdType: HoldType.enum.security,
1784
+ reason: "fork_pr",
1785
+ contextId: null
1786
+ });
1689
1787
  await pool.query(`INSERT INTO execution_runs (
1690
1788
  run_id, workflow_name, provider, repo_identifier,
1691
1789
  ref, sha, delivery_id, status, trust_tier, lock_file_source,
1692
- contributor_username, routing_key
1790
+ contributor_username, routing_key, pr_number
1693
1791
  ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1694
- \'def456\', $2, \'running\', \'trusted\', \'head\', \'trusted-dev\', $3)`, [
1792
+ \'def456\', $2, \'running\', \'trusted\', \'head\', \'trusted-dev\', $3, 1)`, [
1695
1793
  opts.trustedRunId,
1696
1794
  opts.trustedDeliveryId,
1697
1795
  opts.runsRoutingKey
@@ -1699,31 +1797,43 @@ async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1699
1797
  await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1700
1798
  VALUES ($1, $2, \'security-test-job\', \'running\')`, [opts.trustedJobId, opts.trustedRunId]);
1701
1799
  return {
1800
+ contextName,
1702
1801
  envId,
1703
- heldRunId
1802
+ heldRunId,
1803
+ secondHeldRunId,
1804
+ otherRepoHeldRunId,
1805
+ wfModHeldRunId,
1806
+ forkPrHeldRunId
1704
1807
  };
1705
1808
  } finally {
1706
1809
  await pool.end();
1707
1810
  }
1708
1811
  }
1709
1812
  /**
1710
- * Poll `execution_runs` for at least one row matching `status` whose
1711
- * `started_at > since`. Used by cluster/job-reroute tests to gate on
1712
- * a workflow reaching the terminal state after a webhook trigger.
1813
+ * Poll `execution_runs` for the newest run started since `since` whose status
1814
+ * is in `statuses`, returning that status. Resolves `{ status: null }` if the
1815
+ * deadline passes before any run reaches a target status.
1816
+ *
1817
+ * Callers wanting "did the run finish?" pass the terminal status set and read
1818
+ * the landed status — a terminal failure is reported immediately rather than
1819
+ * indistinguishable from a timeout. Used by the cluster reroute tests to gate
1820
+ * on a workflow reaching a terminal state after a webhook trigger.
1713
1821
  */
1714
- async function waitForExecutionRunStatusSinceDirect(databaseUrl, opts) {
1715
- const timeoutMs = opts.timeoutMs ?? 12e4;
1822
+ async function waitForExecutionRunReachesStatusSinceDirect(databaseUrl, opts) {
1823
+ const timeoutMs = opts.timeoutMs ?? 24e4;
1716
1824
  const intervalMs = opts.intervalMs ?? 5e3;
1717
1825
  const deadline = Date.now() + timeoutMs;
1718
1826
  const pool = createPool(databaseUrl);
1719
1827
  try {
1720
1828
  while (Date.now() < deadline) {
1721
- if ((await pool.query(`SELECT 1 FROM execution_runs
1722
- WHERE started_at > $1 AND status = $2
1723
- LIMIT 1`, [opts.since, opts.status])).rows.length > 0) return { found: true };
1829
+ const result = await pool.query(`SELECT status FROM execution_runs
1830
+ WHERE started_at > $1 AND status = ANY($2)
1831
+ ORDER BY started_at DESC
1832
+ LIMIT 1`, [opts.since, [...opts.statuses]]);
1833
+ if (result.rows.length > 0) return { status: result.rows[0].status };
1724
1834
  await new Promise((r) => setTimeout(r, intervalMs));
1725
1835
  }
1726
- return { found: false };
1836
+ return { status: null };
1727
1837
  } finally {
1728
1838
  await pool.end();
1729
1839
  }
@@ -2269,11 +2379,15 @@ async function bumpRegistryVersionSimpleDirect(databaseUrl, opts = {}) {
2269
2379
  async function upsertCronLastFiredDirect(databaseUrl, opts) {
2270
2380
  const pool = createPool(databaseUrl);
2271
2381
  try {
2272
- await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at)
2273
- VALUES ($1, NOW() - ($2)::interval)
2274
- ON CONFLICT (registration_id) DO UPDATE SET
2382
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, schedule_key, last_fired_at)
2383
+ VALUES ($1, $3, NOW() - ($2)::interval)
2384
+ ON CONFLICT (registration_id, schedule_key) DO UPDATE SET
2275
2385
  last_fired_at = NOW() - ($2)::interval,
2276
- updated_at = NOW()`, [opts.registrationId, opts.agoInterval]);
2386
+ updated_at = NOW()`, [
2387
+ opts.registrationId,
2388
+ opts.agoInterval,
2389
+ opts.scheduleKey
2390
+ ]);
2277
2391
  } finally {
2278
2392
  await pool.end();
2279
2393
  }
@@ -2297,7 +2411,7 @@ async function countCronLastFiredDirect(databaseUrl, opts) {
2297
2411
  async function insertCronLastFiredNowDirect(databaseUrl, opts) {
2298
2412
  const pool = createPool(databaseUrl);
2299
2413
  try {
2300
- await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at) VALUES ($1, NOW())`, [opts.registrationId]);
2414
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, schedule_key, last_fired_at) VALUES ($1, $2, NOW())`, [opts.registrationId, opts.scheduleKey]);
2301
2415
  } finally {
2302
2416
  await pool.end();
2303
2417
  }
@@ -2754,6 +2868,6 @@ async function terminateIdleDbBackendsDirect(databaseUrl) {
2754
2868
  }
2755
2869
  }
2756
2870
  //#endregion
2757
- 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, deleteEnvironmentDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteJoinTokensByCreatedByDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventAtDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listHeldRunApprovalsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, paginateUnprocessedEventsKeysetDirect, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeEnvironmentsDirect, 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, terminateIdleDbBackendsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2871
+ export { CI_SECURITY_OTHER_REPO, MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createContextTemplateDirect, createDbRole, createJoinTokenDirect, createReadOnlyDbUser, deleteContextDirect, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteJoinTokensByCreatedByDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventAtDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCheckRunTrackingDirect, listContextsDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listExecutionJobsDirect, listExecutionRunsDirect, listHeldRunApprovalsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, paginateUnprocessedEventsKeysetDirect, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedContextBindingDirect, seedContextDirect, seedCrossRepoTrustDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setContextPolicyDirect, setContextSecretDirect, showContextDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, terminateIdleDbBackendsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunReachesStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2758
2872
 
2759
2873
  //# sourceMappingURL=db-admin.js.map