@stigmer/server 3.38.3 → 3.39.1-dev.20260928194002
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/agentinstance/steps.d.ts +3 -1
- package/dist/domain/agentinstance/steps.d.ts.map +1 -1
- package/dist/domain/agentinstance/steps.js +12 -6
- package/dist/domain/agentinstance/steps.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/domain/plugin/controller.d.ts.map +1 -1
- package/dist/domain/plugin/controller.js +10 -5
- package/dist/domain/plugin/controller.js.map +1 -1
- package/dist/domain/skill/controller.d.ts.map +1 -1
- package/dist/domain/skill/controller.js +7 -3
- package/dist/domain/skill/controller.js.map +1 -1
- package/dist/domain/workflow/controller.js +6 -3
- package/dist/domain/workflow/controller.js.map +1 -1
- package/dist/domain/workflowinstance/controller.d.ts +2 -2
- package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
- package/dist/domain/workflowinstance/controller.js +10 -5
- package/dist/domain/workflowinstance/controller.js.map +1 -1
- package/dist/domain/workflowinstance/steps.d.ts +5 -3
- package/dist/domain/workflowinstance/steps.d.ts.map +1 -1
- package/dist/domain/workflowinstance/steps.js +14 -9
- package/dist/domain/workflowinstance/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 +161 -0
- package/src/domain/agentinstance/controller.ts +9 -5
- package/src/domain/agentinstance/steps.ts +12 -6
- 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/domain/plugin/__tests__/store-faults.test.ts +104 -0
- package/src/domain/plugin/controller.ts +10 -5
- package/src/domain/skill/__tests__/store-faults.test.ts +103 -0
- package/src/domain/skill/controller.ts +7 -3
- package/src/domain/workflow/__tests__/store-faults.test.ts +107 -0
- package/src/domain/workflow/controller.ts +6 -3
- package/src/domain/workflowinstance/__tests__/store-faults.test.ts +172 -0
- package/src/domain/workflowinstance/controller.ts +12 -7
- package/src/domain/workflowinstance/steps.ts +14 -10
- package/src/extensions/gate-slots.ts +7 -5
- package/src/index.ts +11 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +4 -24
- 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
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Pipeline per RPC mirrors the Go step chains character-for-character.
|
|
15
15
|
* Proven by workflowinstance.conformance.test.ts
|
|
16
|
-
* (CONFORMANCE_TARGET=local) and the family
|
|
17
|
-
* ../workflow/__tests__/workflow.test.ts (the mutual edge makes the two
|
|
16
|
+
* (CONFORMANCE_TARGET=local), __tests__/store-faults.test.ts and the family
|
|
17
|
+
* test ../workflow/__tests__/workflow.test.ts (the mutual edge makes the two
|
|
18
18
|
* domains one testable unit).
|
|
19
19
|
*/
|
|
20
20
|
import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
|
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
newValidateVisibilityStep,
|
|
105
105
|
newValidateVisibilityUpdateStep,
|
|
106
106
|
} from "../../pipeline/steps/validate-visibility.js";
|
|
107
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
107
108
|
import type { Store } from "../../store/interface.js";
|
|
108
109
|
import { workflowInstanceSearchExtractor } from "./search-extractor.js";
|
|
109
110
|
import {
|
|
@@ -573,9 +574,10 @@ function newSetInstanceExecutionVisibilityStep(): PipelineStep<UpdateExecutionVi
|
|
|
573
574
|
// ---------------------------------------------------------------------------
|
|
574
575
|
|
|
575
576
|
/**
|
|
576
|
-
* Loads the instance by resource_id;
|
|
577
|
-
* accessor keeps the input shape compiler-checked (both
|
|
578
|
-
* inputs carry resource_id, but the compiler should prove it,
|
|
577
|
+
* Loads the instance by resource_id; a missing one answers NotFound, a store
|
|
578
|
+
* fault Internal. The accessor keeps the input shape compiler-checked (both
|
|
579
|
+
* targeted-update inputs carry resource_id, but the compiler should prove it,
|
|
580
|
+
* not a cast).
|
|
579
581
|
*/
|
|
580
582
|
function newLoadInstanceStep<Desc extends DescMessage>(
|
|
581
583
|
store: Store,
|
|
@@ -594,8 +596,11 @@ function newLoadInstanceStep<Desc extends DescMessage>(
|
|
|
594
596
|
resourceId,
|
|
595
597
|
WorkflowInstanceSchema,
|
|
596
598
|
);
|
|
597
|
-
} catch {
|
|
598
|
-
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (error instanceof ResourceNotFoundError) {
|
|
601
|
+
throw notFoundError("workflow instance", resourceId);
|
|
602
|
+
}
|
|
603
|
+
throw internalError(error, "failed to load workflow instance");
|
|
599
604
|
}
|
|
600
605
|
ctx.set(instanceKey, instance);
|
|
601
606
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the family test ../workflow/__tests__/workflow.test.ts (the mutual edge
|
|
9
9
|
* makes the two domains one testable unit).
|
|
10
10
|
*/
|
|
11
|
-
import { ConnectError } from "@connectrpc/connect";
|
|
11
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
12
12
|
import type { DescMessage } from "@bufbuild/protobuf";
|
|
13
13
|
|
|
14
14
|
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
@@ -55,9 +55,11 @@ export type ParentWorkflowLoaderProvider = () => ParentWorkflowLoader;
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* LoadParentWorkflow — create.go: loads the workflow the instance
|
|
58
|
-
* materializes and stashes it for the same-org rule.
|
|
59
|
-
*
|
|
60
|
-
* (capital-W copy is the
|
|
58
|
+
* materializes and stashes it for the same-org rule. The workflow is read
|
|
59
|
+
* through the in-process query client, so a missing one arrives as a Connect
|
|
60
|
+
* NotFound and answers NotFound("Workflow", id) (the capital-W copy is the
|
|
61
|
+
* wire contract); any other failure is a fault answered as Internal, never a
|
|
62
|
+
* missing parent.
|
|
61
63
|
*/
|
|
62
64
|
export function newLoadParentWorkflowStep(
|
|
63
65
|
loaderProvider: ParentWorkflowLoaderProvider,
|
|
@@ -74,12 +76,14 @@ export function newLoadParentWorkflowStep(
|
|
|
74
76
|
try {
|
|
75
77
|
parent = await loaderProvider().get(workflowId);
|
|
76
78
|
} catch (error) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
error
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
if (error instanceof ConnectError && error.code === Code.NotFound) {
|
|
80
|
+
logger.warn("Parent workflow not found", {
|
|
81
|
+
error: error.rawMessage,
|
|
82
|
+
workflowId,
|
|
83
|
+
});
|
|
84
|
+
throw notFoundError("Workflow", workflowId);
|
|
85
|
+
}
|
|
86
|
+
throw internalError(error, "failed to load parent workflow");
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
ctx.set(PARENT_WORKFLOW_KEY, parent);
|
|
@@ -55,11 +55,13 @@
|
|
|
55
55
|
*
|
|
56
56
|
* The tenth, `org-delete:pre-delete`: the organization delete chain after
|
|
57
57
|
* LoadExistingForDelete and before any write, so the organization exists
|
|
58
|
-
* and is loaded (EXISTING_RESOURCE_KEY) while its steps run.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
58
|
+
* and is loaded (EXISTING_RESOURCE_KEY) while its steps run. Whatever an
|
|
59
|
+
* edition keeps for the organization must go before the row does, or be
|
|
60
|
+
* refused: a row left behind is trust or state that nobody administers.
|
|
61
|
+
* (The slug itself is never taken again, domain/organization/slug-ledger.ts,
|
|
62
|
+
* so nothing left behind can pass to a new holder of the slug; the order
|
|
63
|
+
* still keeps an edition's rows from outliving their organization.) Its
|
|
64
|
+
* steps may refuse (Enterprise refuses while an
|
|
63
65
|
* identity provider still signs in platform-managed organizations) or
|
|
64
66
|
* remove the edition's own rows; either way a throw fails the delete with
|
|
65
67
|
* the organization intact, and the retry re-runs every step, so each owns
|
package/src/index.ts
CHANGED
|
@@ -293,6 +293,7 @@ export { RequestContext } from "./pipeline/request-context.js";
|
|
|
293
293
|
export {
|
|
294
294
|
abortedError,
|
|
295
295
|
alreadyExistsError,
|
|
296
|
+
alreadyExistsWithReasonError,
|
|
296
297
|
ERROR_REASON_DOMAIN,
|
|
297
298
|
failedPreconditionError,
|
|
298
299
|
internalError,
|
|
@@ -398,6 +399,16 @@ export {
|
|
|
398
399
|
AuditNotFoundError,
|
|
399
400
|
ResourceNotFoundError,
|
|
400
401
|
} from "./store/interface.js";
|
|
402
|
+
// The organization-slug ledger (`Store.organizationSlugs`): an
|
|
403
|
+
// organization's slug is its for good. A composition that keeps its own
|
|
404
|
+
// rows by organization id retires the slugs its history holds through it,
|
|
405
|
+
// and a create of a retired slug carries the reason below.
|
|
406
|
+
export type {
|
|
407
|
+
OrganizationSlugClaim,
|
|
408
|
+
OrganizationSlugEntry,
|
|
409
|
+
OrganizationSlugStore,
|
|
410
|
+
} from "./store/interface.js";
|
|
411
|
+
export { ORGANIZATION_SLUG_RESERVED } from "./domain/organization/slug-ledger.js";
|
|
401
412
|
// The list index's read shapes, which `Store.queryResources` speaks
|
|
402
413
|
// (store/list-index.ts). Declaring an index stays internal: the list is
|
|
403
414
|
// the composition root's (boot/list-indexes.ts), one per server.
|
|
@@ -20,9 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* The scan parses each module with the TypeScript compiler API rather than a
|
|
22
22
|
* regular expression, because a catch block's extent (nested braces, a log
|
|
23
|
-
* line before the throw) is a syntax question.
|
|
24
|
-
*
|
|
25
|
-
* blind site fails here, and so does a fixed site still listed.
|
|
23
|
+
* line before the throw) is a syntax question. The scan must find no blind
|
|
24
|
+
* site at all, so a new one fails here with its module and line.
|
|
26
25
|
*/
|
|
27
26
|
import { readFileSync, readdirSync } from "node:fs";
|
|
28
27
|
import path from "node:path";
|
|
@@ -34,23 +33,6 @@ import { describe, expect, it } from "vitest";
|
|
|
34
33
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
34
|
const SRC = path.resolve(HERE, "../..");
|
|
36
35
|
|
|
37
|
-
/** Known blind sites by module (relative to `src`), with their count. */
|
|
38
|
-
const PENDING: ReadonlyMap<string, number> = new Map([
|
|
39
|
-
// Store loads, stigmer/stigmer#1345 (fixed domain by domain).
|
|
40
|
-
["domain/agent/controller.ts", 1],
|
|
41
|
-
["domain/agentchannel/controller.ts", 1],
|
|
42
|
-
["domain/agentinstance/controller.ts", 1],
|
|
43
|
-
["domain/agentshare/controller.ts", 1],
|
|
44
|
-
["domain/plugin/controller.ts", 1],
|
|
45
|
-
["domain/skill/controller.ts", 1],
|
|
46
|
-
["domain/workflow/controller.ts", 1],
|
|
47
|
-
["domain/workflowinstance/controller.ts", 1],
|
|
48
|
-
// Parent loads through the in-process chain that fold every RPC error,
|
|
49
|
-
// validation included, stigmer/stigmer#1351.
|
|
50
|
-
["domain/agentinstance/steps.ts", 1],
|
|
51
|
-
["domain/workflowinstance/steps.ts", 1],
|
|
52
|
-
]);
|
|
53
|
-
|
|
54
36
|
interface BlindSite {
|
|
55
37
|
readonly module: string;
|
|
56
38
|
readonly line: number;
|
|
@@ -262,7 +244,7 @@ describe("the blind-NotFound rule", () => {
|
|
|
262
244
|
});
|
|
263
245
|
|
|
264
246
|
describe("the server source", () => {
|
|
265
|
-
it("has no blind NotFound
|
|
247
|
+
it("has no blind NotFound", () => {
|
|
266
248
|
const sources = serverSources();
|
|
267
249
|
const constructors = notFoundConstructors(sources);
|
|
268
250
|
const found = new Map<string, number[]>();
|
|
@@ -282,8 +264,6 @@ describe("the server source", () => {
|
|
|
282
264
|
const where = [...found]
|
|
283
265
|
.flatMap(([module, lines]) => lines.map((line) => `${module}:${line}`))
|
|
284
266
|
.join("\n");
|
|
285
|
-
expect(counts, `blind NotFound sites found:\n${where}`).toEqual(
|
|
286
|
-
Object.fromEntries(PENDING),
|
|
287
|
-
);
|
|
267
|
+
expect(counts, `blind NotFound sites found:\n${where}`).toEqual({});
|
|
288
268
|
});
|
|
289
269
|
});
|
package/src/pipeline/errors.ts
CHANGED
|
@@ -53,6 +53,29 @@ export function alreadyExistsError(resource: string, id: string): ConnectError {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* An AlreadyExists refusal a client acts on without parsing text: the
|
|
58
|
+
* authored message, and the reason carried as an ErrorInfo detail, exactly
|
|
59
|
+
* as failedPreconditionError carries one. The code stays AlreadyExists, so
|
|
60
|
+
* a caller that keys on the code alone (a retry with another name) keeps
|
|
61
|
+
* working.
|
|
62
|
+
*/
|
|
63
|
+
export function alreadyExistsWithReasonError(
|
|
64
|
+
message: string,
|
|
65
|
+
reason: RefusalReason,
|
|
66
|
+
): ConnectError {
|
|
67
|
+
return new ConnectError(message, Code.AlreadyExists, undefined, [
|
|
68
|
+
{
|
|
69
|
+
desc: ErrorInfoSchema,
|
|
70
|
+
value: create(ErrorInfoSchema, {
|
|
71
|
+
reason: reason.reason,
|
|
72
|
+
domain: ERROR_REASON_DOMAIN,
|
|
73
|
+
metadata: { ...reason.metadata },
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/**
|
|
57
80
|
* Go FailedPreconditionError — the system is not in a state required for
|
|
58
81
|
* the operation (vs AlreadyExists, which tells the caller to stop). With a
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the driver-neutral half of the organization-slug ledger's data
|
|
3
|
+
* migration (organization-slug-history.ts): the frozen kind table is
|
|
4
|
+
* exactly the kinds `api_resource_kind.proto` scoped to an organization
|
|
5
|
+
* when the ledger arrived, spelled out here as the step's statement about
|
|
6
|
+
* the store as it was, each with a schema whose rows carry the shared
|
|
7
|
+
* metadata; a row's
|
|
8
|
+
* organization is read from its bytes, a row with no metadata or an empty
|
|
9
|
+
* organization names none, and bytes that do not decode are a thrown
|
|
10
|
+
* fault, never a skipped row. The drivers' replay tests pin the SQL around
|
|
11
|
+
* these functions.
|
|
12
|
+
*/
|
|
13
|
+
import { create, toBinary } from "@bufbuild/protobuf";
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ORGANIZATION_SCOPED_KINDS_AT_LEDGER,
|
|
20
|
+
organizationNamedBy,
|
|
21
|
+
undecodableRowError,
|
|
22
|
+
} from "../organization-slug-history.js";
|
|
23
|
+
|
|
24
|
+
const AGENT = ORGANIZATION_SCOPED_KINDS_AT_LEDGER.find(
|
|
25
|
+
(entry) => entry.kind === "agent",
|
|
26
|
+
)!;
|
|
27
|
+
|
|
28
|
+
describe("the frozen kind table", () => {
|
|
29
|
+
it("is exactly the kinds api_resource_kind.proto scoped to an organization when the ledger arrived, in its order", () => {
|
|
30
|
+
expect(
|
|
31
|
+
ORGANIZATION_SCOPED_KINDS_AT_LEDGER.map((entry) => entry.kind),
|
|
32
|
+
).toEqual([
|
|
33
|
+
"iam_policy",
|
|
34
|
+
"invitation",
|
|
35
|
+
"identity_provider",
|
|
36
|
+
"oauth_app",
|
|
37
|
+
"platform_client",
|
|
38
|
+
"team",
|
|
39
|
+
"agent",
|
|
40
|
+
"session",
|
|
41
|
+
"skill",
|
|
42
|
+
"mcp_server",
|
|
43
|
+
"agent_instance",
|
|
44
|
+
"agent_share",
|
|
45
|
+
"agent_channel",
|
|
46
|
+
"channel_app",
|
|
47
|
+
"workflow",
|
|
48
|
+
"workflow_instance",
|
|
49
|
+
"workflow_execution",
|
|
50
|
+
"environment",
|
|
51
|
+
"artifact",
|
|
52
|
+
"schedule",
|
|
53
|
+
"memory",
|
|
54
|
+
"plugin",
|
|
55
|
+
"subscription",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("gives every kind a schema whose rows carry the metadata every resource shares", () => {
|
|
60
|
+
for (const entry of ORGANIZATION_SCOPED_KINDS_AT_LEDGER) {
|
|
61
|
+
const metadata = entry.schema.fields.find(
|
|
62
|
+
(field) => field.name === "metadata",
|
|
63
|
+
);
|
|
64
|
+
expect(metadata?.fieldKind, entry.kind).toBe("message");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("organizationNamedBy", () => {
|
|
70
|
+
it("reads the organization a row names", () => {
|
|
71
|
+
const bytes = toBinary(
|
|
72
|
+
AgentSchema,
|
|
73
|
+
create(AgentSchema, {
|
|
74
|
+
metadata: { id: "agt_1", slug: "helper", org: "acme" },
|
|
75
|
+
spec: { instructions: "a conformant instruction body" },
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
expect(organizationNamedBy(AGENT, bytes)).toBe("acme");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("names no organization for a row with no metadata or an empty org", () => {
|
|
82
|
+
expect(
|
|
83
|
+
organizationNamedBy(AGENT, toBinary(AgentSchema, create(AgentSchema))),
|
|
84
|
+
).toBe("");
|
|
85
|
+
expect(
|
|
86
|
+
organizationNamedBy(
|
|
87
|
+
AGENT,
|
|
88
|
+
toBinary(
|
|
89
|
+
AgentSchema,
|
|
90
|
+
create(AgentSchema, { metadata: { id: "agt_2", org: "" } }),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
).toBe("");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("throws on bytes that do not decode, and the driver's error names the row", () => {
|
|
97
|
+
const broken = new Uint8Array([0xff, 0xff, 0xff]);
|
|
98
|
+
expect(() => organizationNamedBy(AGENT, broken)).toThrow();
|
|
99
|
+
let caught: unknown;
|
|
100
|
+
try {
|
|
101
|
+
organizationNamedBy(AGENT, broken);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
caught = error;
|
|
104
|
+
}
|
|
105
|
+
expect(undecodableRowError(AGENT, "agt_broken", caught).message).toMatch(
|
|
106
|
+
/^agent 'agt_broken' cannot be read for the organization it names: /,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* token match / single-term prefix / AND; wire-ready 0–1 scores;
|
|
18
18
|
* list-mode newest-first at exactly 1.0 — search-mode ranking ORDER is
|
|
19
19
|
* deliberately NOT asserted here, it is driver-relative), the two-phase
|
|
20
|
-
* signal-dedupe hold (oss#442),
|
|
20
|
+
* signal-dedupe hold (oss#442), the organization-slug ledger (one winner
|
|
21
|
+
* of concurrent claims, a retire that sticks, a release that frees only
|
|
22
|
+
* its own unretired claim), OAuth grants, once-only pending-state
|
|
21
23
|
* redemption with its 10-minute TTL, the closed-store failure mode, and
|
|
22
24
|
* the list index (../list-index.ts): one organization's or one parent's
|
|
23
25
|
* rows newest first, a cursor walk with no gap and no duplicate, keys
|
|
@@ -1435,6 +1437,86 @@ export function describeStoreContract(
|
|
|
1435
1437
|
});
|
|
1436
1438
|
});
|
|
1437
1439
|
|
|
1440
|
+
describe("organization slugs", () => {
|
|
1441
|
+
it("claims a fresh slug once; a second claim loses to the entry that holds it", async () => {
|
|
1442
|
+
expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
|
|
1443
|
+
|
|
1444
|
+
const first = await fx.store.organizationSlugs.claim("acme");
|
|
1445
|
+
expect(first.claimed).toBe(true);
|
|
1446
|
+
expect(first.entry.slug).toBe("acme");
|
|
1447
|
+
expect(first.entry.retiredAt).toBe("");
|
|
1448
|
+
expect(Date.parse(first.entry.claimedAt)).not.toBeNaN();
|
|
1449
|
+
|
|
1450
|
+
const second = await fx.store.organizationSlugs.claim("acme");
|
|
1451
|
+
expect(second.claimed).toBe(false);
|
|
1452
|
+
expect(second.entry).toEqual(first.entry);
|
|
1453
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(
|
|
1454
|
+
first.entry,
|
|
1455
|
+
);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("of concurrent claims of one slug, exactly one wins", async () => {
|
|
1459
|
+
const claims = await Promise.all(
|
|
1460
|
+
Array.from({ length: 8 }, () =>
|
|
1461
|
+
fx.store.organizationSlugs.claim("contended"),
|
|
1462
|
+
),
|
|
1463
|
+
);
|
|
1464
|
+
expect(claims.filter((claim) => claim.claimed)).toHaveLength(1);
|
|
1465
|
+
const winner = claims.find((claim) => claim.claimed)!;
|
|
1466
|
+
for (const loser of claims.filter((claim) => !claim.claimed)) {
|
|
1467
|
+
expect(loser.entry).toEqual(winner.entry);
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("retire marks a held slug retired, keeps the first time, and records an unknown slug retired", async () => {
|
|
1472
|
+
const { entry } = await fx.store.organizationSlugs.claim("acme");
|
|
1473
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1474
|
+
const retired = await fx.store.organizationSlugs.find("acme");
|
|
1475
|
+
expect(retired?.claimedAt).toBe(entry.claimedAt);
|
|
1476
|
+
expect(retired?.retiredAt).not.toBe("");
|
|
1477
|
+
|
|
1478
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1479
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(retired);
|
|
1480
|
+
|
|
1481
|
+
// An organization created before the ledger existed has no entry;
|
|
1482
|
+
// its delete still retires the slug.
|
|
1483
|
+
await fx.store.organizationSlugs.retire("older");
|
|
1484
|
+
const older = await fx.store.organizationSlugs.find("older");
|
|
1485
|
+
expect(older?.retiredAt).not.toBe("");
|
|
1486
|
+
|
|
1487
|
+
const lost = await fx.store.organizationSlugs.claim("older");
|
|
1488
|
+
expect(lost.claimed).toBe(false);
|
|
1489
|
+
expect(lost.entry).toEqual(older);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
it("release frees only its own unretired claim", async () => {
|
|
1493
|
+
const { entry } = await fx.store.organizationSlugs.claim("acme");
|
|
1494
|
+
await fx.store.organizationSlugs.release(entry);
|
|
1495
|
+
expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
|
|
1496
|
+
const reclaimed = await fx.store.organizationSlugs.claim("acme");
|
|
1497
|
+
expect(reclaimed.claimed, "a released slug is claimable at once").toBe(
|
|
1498
|
+
true,
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
// Another create's claim of the same slug carries another time, so
|
|
1502
|
+
// a stale release never frees it.
|
|
1503
|
+
await fx.store.organizationSlugs.release({
|
|
1504
|
+
...reclaimed.entry,
|
|
1505
|
+
claimedAt: "2000-01-01T00:00:00.000Z",
|
|
1506
|
+
});
|
|
1507
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(
|
|
1508
|
+
reclaimed.entry,
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
// A retired slug is never freed, whoever asks.
|
|
1512
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1513
|
+
await fx.store.organizationSlugs.release(reclaimed.entry);
|
|
1514
|
+
expect(
|
|
1515
|
+
(await fx.store.organizationSlugs.find("acme"))?.retiredAt,
|
|
1516
|
+
).not.toBe("");
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1438
1520
|
describe("oauth grants", () => {
|
|
1439
1521
|
const grant: OAuthGrant = {
|
|
1440
1522
|
identityAccountId: "ida_1",
|
|
@@ -1880,6 +1962,9 @@ export function describeStoreContract(
|
|
|
1880
1962
|
await expect(fx.store.signalDedupe.release("o", "k")).rejects.toThrow(
|
|
1881
1963
|
"store is closed",
|
|
1882
1964
|
);
|
|
1965
|
+
await expect(fx.store.organizationSlugs.claim("o")).rejects.toThrow(
|
|
1966
|
+
"store is closed",
|
|
1967
|
+
);
|
|
1883
1968
|
});
|
|
1884
1969
|
});
|
|
1885
1970
|
}
|
package/src/store/interface.ts
CHANGED
|
@@ -328,6 +328,75 @@ export interface SignalDedupeStore {
|
|
|
328
328
|
release(org: string, idempotencyKey: string): Promise<void>;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// Organization slugs (the ledger of every slug an organization ever took)
|
|
333
|
+
// =============================================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* One slug's line in the ledger. An organization's id is its slug, so this
|
|
337
|
+
* is also the history of every organization id the store ever issued.
|
|
338
|
+
*/
|
|
339
|
+
export interface OrganizationSlugEntry {
|
|
340
|
+
readonly slug: string;
|
|
341
|
+
/**
|
|
342
|
+
* RFC-3339 time the ledger first recorded the slug: the create's claim,
|
|
343
|
+
* or, for a slug that predates the ledger, the migration or retire that
|
|
344
|
+
* wrote it.
|
|
345
|
+
*/
|
|
346
|
+
readonly claimedAt: string;
|
|
347
|
+
/** RFC-3339 time the organization holding it was deleted; "" while it is held. */
|
|
348
|
+
readonly retiredAt: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A claim's outcome: won with its entry, or lost to the entry that holds the slug. */
|
|
352
|
+
export type OrganizationSlugClaim =
|
|
353
|
+
| { readonly claimed: true; readonly entry: OrganizationSlugEntry }
|
|
354
|
+
| { readonly claimed: false; readonly entry: OrganizationSlugEntry };
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The organization-slug ledger: a slug is claimed once, atomically, by the
|
|
358
|
+
* create that takes it, and it is never released by the organization's
|
|
359
|
+
* delete, which retires it instead. The organization row can go; its slug
|
|
360
|
+
* stays taken for good, so nothing a deleted organization left behind
|
|
361
|
+
* (rows that name it in `metadata.org`, an edition's rows kept by its id)
|
|
362
|
+
* can ever pass to a new holder of the same slug.
|
|
363
|
+
*
|
|
364
|
+
* The ledger, not the organization row, decides "taken". A claim is the
|
|
365
|
+
* create's uniqueness guarantee, which the row cannot give: `saveResource`
|
|
366
|
+
* upserts, so two creates that both read "absent" would otherwise both
|
|
367
|
+
* write, the second over the first.
|
|
368
|
+
*
|
|
369
|
+
* An unretired entry is a slug held by a live organization, or by a create
|
|
370
|
+
* between its claim and its row; a retired entry is a slug whose
|
|
371
|
+
* organization was deleted. A caller tells the two apart, never "no row"
|
|
372
|
+
* alone, because a create in flight has a claim and no row yet.
|
|
373
|
+
*/
|
|
374
|
+
export interface OrganizationSlugStore {
|
|
375
|
+
/**
|
|
376
|
+
* Claims a slug if no entry holds it, atomically: of concurrent claims,
|
|
377
|
+
* exactly one wins. A loser receives the entry that holds the slug,
|
|
378
|
+
* retired or not.
|
|
379
|
+
*/
|
|
380
|
+
claim(slug: string): Promise<OrganizationSlugClaim>;
|
|
381
|
+
/**
|
|
382
|
+
* Marks the slug retired: sets `retiredAt` on its entry when it is
|
|
383
|
+
* empty, or records the slug already retired when no entry exists (an
|
|
384
|
+
* organization created before the ledger). Idempotent; a second retire
|
|
385
|
+
* keeps the first time.
|
|
386
|
+
*/
|
|
387
|
+
retire(slug: string): Promise<void>;
|
|
388
|
+
/**
|
|
389
|
+
* Frees a claim whose create failed before its organization was stored,
|
|
390
|
+
* so the caller's retry can claim again. Guarded: only the unretired
|
|
391
|
+
* entry carrying this exact `claimedAt` is removed, so a release can
|
|
392
|
+
* never free a retired slug or another create's claim. Anything else is
|
|
393
|
+
* a no-op.
|
|
394
|
+
*/
|
|
395
|
+
release(entry: OrganizationSlugEntry): Promise<void>;
|
|
396
|
+
/** The slug's entry, or undefined when no organization ever took it. */
|
|
397
|
+
find(slug: string): Promise<OrganizationSlugEntry | undefined>;
|
|
398
|
+
}
|
|
399
|
+
|
|
331
400
|
// =============================================================================
|
|
332
401
|
// MCP OAuth (Go: pkg/domain/mcpserver/oauth, tables consolidated by v7 / OD-3)
|
|
333
402
|
// =============================================================================
|
|
@@ -863,6 +932,7 @@ export interface Store {
|
|
|
863
932
|
|
|
864
933
|
readonly bootstrapState: BootstrapStateStore;
|
|
865
934
|
readonly signalDedupe: SignalDedupeStore;
|
|
935
|
+
readonly organizationSlugs: OrganizationSlugStore;
|
|
866
936
|
readonly oauthGrants: OAuthGrantStore;
|
|
867
937
|
readonly pendingOAuthStates: PendingOAuthStateStore;
|
|
868
938
|
|