@stigmer/server 3.38.3 → 3.39.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 (90) hide show
  1. package/dist/domain/agent/controller.d.ts +2 -2
  2. package/dist/domain/agent/controller.d.ts.map +1 -1
  3. package/dist/domain/agent/controller.js +7 -3
  4. package/dist/domain/agent/controller.js.map +1 -1
  5. package/dist/domain/agentchannel/controller.d.ts +2 -2
  6. package/dist/domain/agentchannel/controller.d.ts.map +1 -1
  7. package/dist/domain/agentchannel/controller.js +10 -5
  8. package/dist/domain/agentchannel/controller.js.map +1 -1
  9. package/dist/domain/agentinstance/controller.d.ts +2 -2
  10. package/dist/domain/agentinstance/controller.d.ts.map +1 -1
  11. package/dist/domain/agentinstance/controller.js +7 -3
  12. package/dist/domain/agentinstance/controller.js.map +1 -1
  13. package/dist/domain/agentshare/controller.d.ts +2 -2
  14. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  15. package/dist/domain/agentshare/controller.js +7 -3
  16. package/dist/domain/agentshare/controller.js.map +1 -1
  17. package/dist/domain/organization/controller.d.ts.map +1 -1
  18. package/dist/domain/organization/controller.js +33 -14
  19. package/dist/domain/organization/controller.js.map +1 -1
  20. package/dist/domain/organization/slug-ledger.d.ts +86 -0
  21. package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
  22. package/dist/domain/organization/slug-ledger.js +128 -0
  23. package/dist/domain/organization/slug-ledger.js.map +1 -0
  24. package/dist/domain/organization/steps.d.ts +12 -3
  25. package/dist/domain/organization/steps.d.ts.map +1 -1
  26. package/dist/domain/organization/steps.js +28 -6
  27. package/dist/domain/organization/steps.js.map +1 -1
  28. package/dist/extensions/gate-slots.d.ts +7 -5
  29. package/dist/extensions/gate-slots.d.ts.map +1 -1
  30. package/dist/extensions/gate-slots.js.map +1 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/pipeline/errors.d.ts +8 -0
  36. package/dist/pipeline/errors.d.ts.map +1 -1
  37. package/dist/pipeline/errors.js +19 -0
  38. package/dist/pipeline/errors.js.map +1 -1
  39. package/dist/store/interface.d.ts +67 -0
  40. package/dist/store/interface.d.ts.map +1 -1
  41. package/dist/store/interface.js.map +1 -1
  42. package/dist/store/organization-slug-history.d.ts +71 -0
  43. package/dist/store/organization-slug-history.d.ts.map +1 -0
  44. package/dist/store/organization-slug-history.js +88 -0
  45. package/dist/store/organization-slug-history.js.map +1 -0
  46. package/dist/store/postgres/migrations.d.ts +3 -1
  47. package/dist/store/postgres/migrations.d.ts.map +1 -1
  48. package/dist/store/postgres/migrations.js +69 -1
  49. package/dist/store/postgres/migrations.js.map +1 -1
  50. package/dist/store/postgres/store.d.ts +2 -1
  51. package/dist/store/postgres/store.d.ts.map +1 -1
  52. package/dist/store/postgres/store.js +49 -0
  53. package/dist/store/postgres/store.js.map +1 -1
  54. package/dist/store/sqlite/migrations.d.ts +3 -1
  55. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  56. package/dist/store/sqlite/migrations.js +54 -1
  57. package/dist/store/sqlite/migrations.js.map +1 -1
  58. package/dist/store/sqlite/store.d.ts +2 -1
  59. package/dist/store/sqlite/store.d.ts.map +1 -1
  60. package/dist/store/sqlite/store.js +55 -0
  61. package/dist/store/sqlite/store.js.map +1 -1
  62. package/package.json +6 -6
  63. package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
  64. package/src/domain/agent/controller.ts +9 -5
  65. package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
  66. package/src/domain/agentchannel/controller.ts +12 -7
  67. package/src/domain/agentinstance/__tests__/store-faults.test.ts +110 -0
  68. package/src/domain/agentinstance/controller.ts +9 -5
  69. package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
  70. package/src/domain/agentshare/controller.ts +9 -5
  71. package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
  72. package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
  73. package/src/domain/organization/controller.ts +36 -14
  74. package/src/domain/organization/slug-ledger.ts +209 -0
  75. package/src/domain/organization/steps.ts +28 -6
  76. package/src/extensions/gate-slots.ts +7 -5
  77. package/src/index.ts +11 -0
  78. package/src/pipeline/__tests__/blind-not-found.test.ts +0 -4
  79. package/src/pipeline/errors.ts +23 -0
  80. package/src/store/__tests__/organization-slug-history.test.ts +109 -0
  81. package/src/store/__tests__/store-contract.ts +86 -1
  82. package/src/store/interface.ts +70 -0
  83. package/src/store/organization-slug-history.ts +159 -0
  84. package/src/store/postgres/__tests__/migrations.test.ts +138 -2
  85. package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
  86. package/src/store/postgres/migrations.ts +84 -1
  87. package/src/store/postgres/store.ts +84 -0
  88. package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
  89. package/src/store/sqlite/migrations.ts +69 -1
  90. package/src/store/sqlite/store.ts +84 -0
@@ -11,7 +11,11 @@
11
11
  * and leaves every other row's bytes as they were; a row it cannot decode
12
12
  * fails the step and leaves the database at v9. v11 adds the list index's
13
13
  * schema only, and a Go-written row of a declared kind is derived by the
14
- * store's reconciliation at open and read through the index.
14
+ * store's reconciliation at open and read through the index. v12 creates
15
+ * the organization-slug ledger and fills it: every live organization
16
+ * unretired, every organization a surviving scoped row names with none live
17
+ * retired, across keyset pages; a scoped row it cannot decode fails the
18
+ * step and leaves the database at v11 with no ledger.
15
19
  */
16
20
  import { mkdtempSync, rmSync } from "node:fs";
17
21
  import { tmpdir } from "node:os";
@@ -35,9 +39,11 @@ import { SqliteStore } from "../store.js";
35
39
  import {
36
40
  CURRENT_SCHEMA_VERSION,
37
41
  SCHEMA_VERSION_10,
42
+ SCHEMA_VERSION_12,
38
43
  getSchemaVersion,
39
44
  runMigrations,
40
45
  } from "../migrations.js";
46
+ import { HISTORY_PAGE_SIZE } from "../../organization-slug-history.js";
41
47
  import { PUBLIC_ROW_KINDS_AT_RETIREMENT } from "../../public-visibility-retired.js";
42
48
  import { materializeGoFixture } from "./support.js";
43
49
 
@@ -85,6 +91,7 @@ describe("fresh database", () => {
85
91
  "signal_dedupe",
86
92
  "oauth_grant",
87
93
  "pending_oauth_state",
94
+ "organization_slugs",
88
95
  ]) {
89
96
  expect(tables, `table ${expected} should exist`).toContain(expected);
90
97
  }
@@ -100,7 +107,7 @@ describe("fresh database", () => {
100
107
  .prepare(`SELECT version FROM schema_version ORDER BY version`)
101
108
  .all() as Array<{ version: number }>;
102
109
  expect(rows.map((row) => row.version)).toEqual([
103
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
110
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
104
111
  ]);
105
112
  });
106
113
 
@@ -525,16 +532,15 @@ describe("v10: the retired public level leaves every row", () => {
525
532
  });
526
533
 
527
534
  it("a row of a kind outside the frozen table is never decoded", () => {
528
- const { dbPath, db: setup } = v9Database();
535
+ const { db } = v9Database();
536
+ cleanups.push(() => db.close());
529
537
  // Bytes no schema decodes, under a kind the level never applied to.
530
- insert(setup, "session", "ses_opaque", new Uint8Array([0xff, 0xff]));
531
- setup.close();
538
+ // The chain stops at v10: v12 reads every organization-scoped row, so
539
+ // a later step would refuse these bytes for its own reason.
540
+ insert(db, "session", "ses_opaque", new Uint8Array([0xff, 0xff]));
532
541
 
533
- const store = SqliteStore.open(dbPath);
534
- cleanups.push(() => store.close());
535
- const db = new DatabaseSync(dbPath);
536
- cleanups.push(() => db.close());
537
- expect(getSchemaVersion(db)).toBe(CURRENT_SCHEMA_VERSION);
542
+ runMigrations(db, SCHEMA_VERSION_10);
543
+ expect(getSchemaVersion(db)).toBe(SCHEMA_VERSION_10);
538
544
  expect(row(db, "session", "ses_opaque").data).toEqual(
539
545
  new Uint8Array([0xff, 0xff]),
540
546
  );
@@ -560,3 +566,126 @@ describe("v10: the retired public level leaves every row", () => {
560
566
  expect(row(db, "agent", "agt_public").data).toEqual(publicBytes);
561
567
  });
562
568
  });
569
+
570
+ describe("v12: the organization-slug ledger records every slug taken before it", () => {
571
+ /** A v11 database: the chain replayed up to the step before v12. */
572
+ function v11Database(): { dbPath: string; db: DatabaseSync } {
573
+ const dbPath = tempDbPath();
574
+ const setup = new DatabaseSync(dbPath);
575
+ runMigrations(setup, SCHEMA_VERSION_12 - 1);
576
+ expect(getSchemaVersion(setup)).toBe(SCHEMA_VERSION_12 - 1);
577
+ return { dbPath, db: setup };
578
+ }
579
+
580
+ function agentNaming(id: string, org: string): Uint8Array {
581
+ return toBinary(
582
+ AgentSchema,
583
+ create(AgentSchema, {
584
+ metadata: { id, name: id, slug: id, org },
585
+ spec: { instructions: "a conformant instruction body" },
586
+ }),
587
+ );
588
+ }
589
+
590
+ function sessionNaming(id: string, org: string): Uint8Array {
591
+ return toBinary(
592
+ SessionSchema,
593
+ create(SessionSchema, { metadata: { id, name: id, slug: id, org } }),
594
+ );
595
+ }
596
+
597
+ function insert(
598
+ db: DatabaseSync,
599
+ kind: string,
600
+ id: string,
601
+ data: Uint8Array,
602
+ ): void {
603
+ db.prepare(`INSERT INTO resources (kind, id, data) VALUES (?, ?, ?)`).run(
604
+ kind,
605
+ id,
606
+ data,
607
+ );
608
+ }
609
+
610
+ function ledger(db: DatabaseSync): Array<{ slug: string; retired: number }> {
611
+ return db
612
+ .prepare(
613
+ `SELECT slug, retired_at IS NOT NULL AS retired FROM organization_slugs ORDER BY slug`,
614
+ )
615
+ .all() as Array<{ slug: string; retired: number }>;
616
+ }
617
+
618
+ it("records every live organization unretired and every organization a surviving row names with none live retired", () => {
619
+ const { dbPath, db: setup } = v11Database();
620
+ insert(setup, "organization", "acme", new Uint8Array([0x00]));
621
+ insert(setup, "agent", "agt_live", agentNaming("agt_live", "acme"));
622
+ insert(setup, "agent", "agt_gone", agentNaming("agt_gone", "deleted-one"));
623
+ insert(
624
+ setup,
625
+ "session",
626
+ "ses_gone",
627
+ sessionNaming("ses_gone", "deleted-two"),
628
+ );
629
+ insert(setup, "agent", "agt_orphan", agentNaming("agt_orphan", ""));
630
+ // A kind outside the table is never decoded, whatever it holds.
631
+ insert(
632
+ setup,
633
+ "identity_account",
634
+ "ida_opaque",
635
+ new Uint8Array([0xff, 0xff]),
636
+ );
637
+ setup.close();
638
+
639
+ const store = SqliteStore.open(dbPath);
640
+ cleanups.push(() => store.close());
641
+ const db = new DatabaseSync(dbPath);
642
+ cleanups.push(() => db.close());
643
+ expect(getSchemaVersion(db)).toBe(CURRENT_SCHEMA_VERSION);
644
+ expect(ledger(db)).toEqual([
645
+ { slug: "acme", retired: 0 },
646
+ { slug: "deleted-one", retired: 1 },
647
+ { slug: "deleted-two", retired: 1 },
648
+ ]);
649
+ });
650
+
651
+ it("reads a kind across keyset pages, missing no row past the first page", () => {
652
+ const { dbPath, db: setup } = v11Database();
653
+ const rows = HISTORY_PAGE_SIZE + 2;
654
+ for (let i = 0; i < rows; i++) {
655
+ const id = `agt_${String(i).padStart(4, "0")}`;
656
+ // The last row, past the first page, is the only one naming its
657
+ // organization.
658
+ insert(
659
+ setup,
660
+ "agent",
661
+ id,
662
+ agentNaming(id, i === rows - 1 ? "past-the-page" : "on-the-page"),
663
+ );
664
+ }
665
+ setup.close();
666
+
667
+ const store = SqliteStore.open(dbPath);
668
+ cleanups.push(() => store.close());
669
+ const db = new DatabaseSync(dbPath);
670
+ cleanups.push(() => db.close());
671
+ expect(ledger(db)).toEqual([
672
+ { slug: "on-the-page", retired: 1 },
673
+ { slug: "past-the-page", retired: 1 },
674
+ ]);
675
+ });
676
+
677
+ it("a scoped row that does not decode fails the step, names the row, and leaves the database at v11 with no ledger", () => {
678
+ const { dbPath, db: setup } = v11Database();
679
+ insert(setup, "agent", "agt_broken", new Uint8Array([0xff, 0xff, 0xff]));
680
+ setup.close();
681
+
682
+ expect(() => SqliteStore.open(dbPath)).toThrow(
683
+ /migrate to v12: .*agent 'agt_broken' cannot be read for the organization it names/,
684
+ );
685
+
686
+ const db = new DatabaseSync(dbPath);
687
+ cleanups.push(() => db.close());
688
+ expect(getSchemaVersion(db)).toBe(SCHEMA_VERSION_12 - 1);
689
+ expect(tableNames(db)).not.toContain("organization_slugs");
690
+ });
691
+ });
@@ -33,6 +33,12 @@
33
33
  */
34
34
  import type { DatabaseSync } from "node:sqlite";
35
35
 
36
+ import {
37
+ HISTORY_PAGE_SIZE,
38
+ ORGANIZATION_SCOPED_KINDS_AT_LEDGER,
39
+ organizationNamedBy,
40
+ undecodableRowError,
41
+ } from "../organization-slug-history.js";
36
42
  import {
37
43
  PUBLIC_ROW_KINDS_AT_RETIREMENT,
38
44
  movePublicRowToOrg,
@@ -54,9 +60,11 @@ export const SCHEMA_VERSION_9 = 9;
54
60
  export const SCHEMA_VERSION_10 = 10;
55
61
  /** v11: the list index's columns, key table and indexes (DDL only). */
56
62
  export const SCHEMA_VERSION_11 = 11;
63
+ /** v12: the organization-slug ledger, filled with every slug taken before it. */
64
+ export const SCHEMA_VERSION_12 = 12;
57
65
 
58
66
  /** Target version for new databases. */
59
- export const CURRENT_SCHEMA_VERSION = SCHEMA_VERSION_11;
67
+ export const CURRENT_SCHEMA_VERSION = SCHEMA_VERSION_12;
60
68
 
61
69
  /**
62
70
  * Applies every pending migration up to `targetVersion` in order — all of
@@ -87,6 +95,7 @@ export function runMigrations(
87
95
  [SCHEMA_VERSION_9, migrateToV9],
88
96
  [SCHEMA_VERSION_10, migrateToV10],
89
97
  [SCHEMA_VERSION_11, migrateToV11],
98
+ [SCHEMA_VERSION_12, migrateToV12],
90
99
  ];
91
100
 
92
101
  for (const [version, migrate] of chain) {
@@ -474,3 +483,62 @@ function migrateToV11(db: DatabaseSync): void {
474
483
  ON resource_list_keys (kind, key, value, created_at, id);
475
484
  `);
476
485
  }
486
+
487
+ /**
488
+ * v12: the organization-slug ledger, the Postgres driver's v7 in this
489
+ * engine's terms (postgres/migrations.ts gives the reasons for the table's
490
+ * shape and the fill's order; organization-slug-history.ts what the fill
491
+ * records and why an undecodable row fails the step). Runs inside
492
+ * applyInTransaction's BEGIN, so a throw rolls the whole step back and the
493
+ * boot stops on the row it names. INSERT OR IGNORE keeps a live
494
+ * organization's entry unretired whatever its rows say.
495
+ */
496
+ function migrateToV12(db: DatabaseSync): void {
497
+ db.exec(`
498
+ CREATE TABLE organization_slugs (
499
+ slug TEXT PRIMARY KEY,
500
+ claimed_at TEXT NOT NULL,
501
+ retired_at TEXT
502
+ ) WITHOUT ROWID;
503
+ `);
504
+ const recordedAt = new Date().toISOString();
505
+ db.prepare(
506
+ `INSERT OR IGNORE INTO organization_slugs (slug, claimed_at)
507
+ SELECT id, ? FROM resources WHERE kind = 'organization'`,
508
+ ).run(recordedAt);
509
+
510
+ const page = db.prepare(
511
+ `SELECT id, data FROM resources WHERE kind = ? AND id > ? ORDER BY id LIMIT ?`,
512
+ );
513
+ const named = new Set<string>();
514
+ for (const entry of ORGANIZATION_SCOPED_KINDS_AT_LEDGER) {
515
+ let after = "";
516
+ for (;;) {
517
+ const rows = page.all(entry.kind, after, HISTORY_PAGE_SIZE) as Array<{
518
+ id: string;
519
+ data: Uint8Array;
520
+ }>;
521
+ for (const row of rows) {
522
+ let org: string;
523
+ try {
524
+ org = organizationNamedBy(entry, row.data);
525
+ } catch (error) {
526
+ throw undecodableRowError(entry, row.id, error);
527
+ }
528
+ if (org !== "") {
529
+ named.add(org);
530
+ }
531
+ }
532
+ if (rows.length < HISTORY_PAGE_SIZE) {
533
+ break;
534
+ }
535
+ after = rows[rows.length - 1]!.id;
536
+ }
537
+ }
538
+ const retire = db.prepare(
539
+ `INSERT OR IGNORE INTO organization_slugs (slug, claimed_at, retired_at) VALUES (?, ?, ?)`,
540
+ );
541
+ for (const slug of named) {
542
+ retire.run(slug, recordedAt, recordedAt);
543
+ }
544
+ }
@@ -47,6 +47,9 @@ import type {
47
47
  ClaimResult,
48
48
  OAuthGrant,
49
49
  OAuthGrantStore,
50
+ OrganizationSlugClaim,
51
+ OrganizationSlugEntry,
52
+ OrganizationSlugStore,
50
53
  PendingOAuthState,
51
54
  PendingOAuthStateStore,
52
55
  RawResourceDocument,
@@ -156,6 +159,7 @@ const RECONCILE_BATCH = 500;
156
159
  export class SqliteStore implements Store {
157
160
  readonly bootstrapState: BootstrapStateStore;
158
161
  readonly signalDedupe: SignalDedupeStore;
162
+ readonly organizationSlugs: OrganizationSlugStore;
159
163
  readonly oauthGrants: OAuthGrantStore;
160
164
  readonly pendingOAuthStates: PendingOAuthStateStore;
161
165
 
@@ -176,6 +180,7 @@ export class SqliteStore implements Store {
176
180
  this.listIndexes = listIndexes;
177
181
  this.bootstrapState = new SqliteBootstrapStateStore(() => this.open());
178
182
  this.signalDedupe = new SqliteSignalDedupeStore(() => this.open(), logger);
183
+ this.organizationSlugs = new SqliteOrganizationSlugStore(() => this.open());
179
184
  this.oauthGrants = new SqliteOAuthGrantStore(() => this.open());
180
185
  this.pendingOAuthStates = new SqlitePendingOAuthStateStore(() =>
181
186
  this.open(),
@@ -1596,6 +1601,85 @@ function isUniqueConstraintError(error: unknown): boolean {
1596
1601
  );
1597
1602
  }
1598
1603
 
1604
+ // =============================================================================
1605
+ // Organization slugs
1606
+ // =============================================================================
1607
+
1608
+ /** A ledger row as the driver reads it (`retired_at` NULL while held). */
1609
+ interface OrganizationSlugRow {
1610
+ slug: string;
1611
+ claimed_at: string;
1612
+ retired_at: string | null;
1613
+ }
1614
+
1615
+ function organizationSlugEntryOf(
1616
+ row: OrganizationSlugRow,
1617
+ ): OrganizationSlugEntry {
1618
+ return {
1619
+ slug: row.slug,
1620
+ claimedAt: row.claimed_at,
1621
+ retiredAt: row.retired_at ?? "",
1622
+ };
1623
+ }
1624
+
1625
+ class SqliteOrganizationSlugStore implements OrganizationSlugStore {
1626
+ constructor(private readonly open: () => DatabaseSync) {}
1627
+
1628
+ async claim(slug: string): Promise<OrganizationSlugClaim> {
1629
+ const db = this.open();
1630
+ const claimedAt = new Date().toISOString();
1631
+ // INSERT OR IGNORE on the primary key: the one connection serialises
1632
+ // writers, and `changes` says whether this claim is the one that won.
1633
+ const inserted = db
1634
+ .prepare(
1635
+ `INSERT OR IGNORE INTO organization_slugs (slug, claimed_at) VALUES (?, ?)`,
1636
+ )
1637
+ .run(slug, claimedAt);
1638
+ if (Number(inserted.changes) === 1) {
1639
+ return { claimed: true, entry: { slug, claimedAt, retiredAt: "" } };
1640
+ }
1641
+ const holder = await this.find(slug);
1642
+ if (holder === undefined) {
1643
+ // A holder only disappears through its own create's release, so a
1644
+ // lost claim with no holder is a claim that raced that release:
1645
+ // surfaced as a fault rather than fabricating an entry.
1646
+ throw new Error(
1647
+ `organization slug '${slug}': its holder disappeared during the claim`,
1648
+ );
1649
+ }
1650
+ return { claimed: false, entry: holder };
1651
+ }
1652
+
1653
+ async retire(slug: string): Promise<void> {
1654
+ const retiredAt = new Date().toISOString();
1655
+ this.open()
1656
+ .prepare(
1657
+ `INSERT INTO organization_slugs (slug, claimed_at, retired_at) VALUES (?, ?, ?)
1658
+ ON CONFLICT (slug) DO UPDATE SET retired_at = excluded.retired_at
1659
+ WHERE organization_slugs.retired_at IS NULL`,
1660
+ )
1661
+ .run(slug, retiredAt, retiredAt);
1662
+ }
1663
+
1664
+ async release(entry: OrganizationSlugEntry): Promise<void> {
1665
+ this.open()
1666
+ .prepare(
1667
+ `DELETE FROM organization_slugs
1668
+ WHERE slug = ? AND claimed_at = ? AND retired_at IS NULL`,
1669
+ )
1670
+ .run(entry.slug, entry.claimedAt);
1671
+ }
1672
+
1673
+ async find(slug: string): Promise<OrganizationSlugEntry | undefined> {
1674
+ const row = this.open()
1675
+ .prepare(
1676
+ `SELECT slug, claimed_at, retired_at FROM organization_slugs WHERE slug = ?`,
1677
+ )
1678
+ .get(slug) as OrganizationSlugRow | undefined;
1679
+ return row === undefined ? undefined : organizationSlugEntryOf(row);
1680
+ }
1681
+ }
1682
+
1599
1683
  // =============================================================================
1600
1684
  // MCP OAuth grants (Go pkg/domain/mcpserver/oauth/grant_store.go)
1601
1685
  // =============================================================================