@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.
- package/dist/domain/agent/controller.d.ts +2 -2
- package/dist/domain/agent/controller.d.ts.map +1 -1
- package/dist/domain/agent/controller.js +7 -3
- package/dist/domain/agent/controller.js.map +1 -1
- package/dist/domain/agentchannel/controller.d.ts +2 -2
- package/dist/domain/agentchannel/controller.d.ts.map +1 -1
- package/dist/domain/agentchannel/controller.js +10 -5
- package/dist/domain/agentchannel/controller.js.map +1 -1
- package/dist/domain/agentinstance/controller.d.ts +2 -2
- package/dist/domain/agentinstance/controller.d.ts.map +1 -1
- package/dist/domain/agentinstance/controller.js +7 -3
- package/dist/domain/agentinstance/controller.js.map +1 -1
- package/dist/domain/agentshare/controller.d.ts +2 -2
- package/dist/domain/agentshare/controller.d.ts.map +1 -1
- package/dist/domain/agentshare/controller.js +7 -3
- package/dist/domain/agentshare/controller.js.map +1 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +33 -14
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/organization/slug-ledger.d.ts +86 -0
- package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
- package/dist/domain/organization/slug-ledger.js +128 -0
- package/dist/domain/organization/slug-ledger.js.map +1 -0
- package/dist/domain/organization/steps.d.ts +12 -3
- package/dist/domain/organization/steps.d.ts.map +1 -1
- package/dist/domain/organization/steps.js +28 -6
- package/dist/domain/organization/steps.js.map +1 -1
- package/dist/extensions/gate-slots.d.ts +7 -5
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/errors.d.ts +8 -0
- package/dist/pipeline/errors.d.ts.map +1 -1
- package/dist/pipeline/errors.js +19 -0
- package/dist/pipeline/errors.js.map +1 -1
- package/dist/store/interface.d.ts +67 -0
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js.map +1 -1
- package/dist/store/organization-slug-history.d.ts +71 -0
- package/dist/store/organization-slug-history.d.ts.map +1 -0
- package/dist/store/organization-slug-history.js +88 -0
- package/dist/store/organization-slug-history.js.map +1 -0
- package/dist/store/postgres/migrations.d.ts +3 -1
- package/dist/store/postgres/migrations.d.ts.map +1 -1
- package/dist/store/postgres/migrations.js +69 -1
- package/dist/store/postgres/migrations.js.map +1 -1
- package/dist/store/postgres/store.d.ts +2 -1
- package/dist/store/postgres/store.d.ts.map +1 -1
- package/dist/store/postgres/store.js +49 -0
- package/dist/store/postgres/store.js.map +1 -1
- package/dist/store/sqlite/migrations.d.ts +3 -1
- package/dist/store/sqlite/migrations.d.ts.map +1 -1
- package/dist/store/sqlite/migrations.js +54 -1
- package/dist/store/sqlite/migrations.js.map +1 -1
- package/dist/store/sqlite/store.d.ts +2 -1
- package/dist/store/sqlite/store.d.ts.map +1 -1
- package/dist/store/sqlite/store.js +55 -0
- package/dist/store/sqlite/store.js.map +1 -1
- package/package.json +6 -6
- package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
- package/src/domain/agent/controller.ts +9 -5
- package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
- package/src/domain/agentchannel/controller.ts +12 -7
- package/src/domain/agentinstance/__tests__/store-faults.test.ts +110 -0
- package/src/domain/agentinstance/controller.ts +9 -5
- package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
- package/src/domain/agentshare/controller.ts +9 -5
- package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
- package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
- package/src/domain/organization/controller.ts +36 -14
- package/src/domain/organization/slug-ledger.ts +209 -0
- package/src/domain/organization/steps.ts +28 -6
- package/src/extensions/gate-slots.ts +7 -5
- package/src/index.ts +11 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -4
- package/src/pipeline/errors.ts +23 -0
- package/src/store/__tests__/organization-slug-history.test.ts +109 -0
- package/src/store/__tests__/store-contract.ts +86 -1
- package/src/store/interface.ts +70 -0
- package/src/store/organization-slug-history.ts +159 -0
- package/src/store/postgres/__tests__/migrations.test.ts +138 -2
- package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
- package/src/store/postgres/migrations.ts +84 -1
- package/src/store/postgres/store.ts +84 -0
- package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
- package/src/store/sqlite/migrations.ts +69 -1
- 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 {
|
|
535
|
+
const { db } = v9Database();
|
|
536
|
+
cleanups.push(() => db.close());
|
|
529
537
|
// Bytes no schema decodes, under a kind the level never applied to.
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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 =
|
|
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
|
// =============================================================================
|