@axiom-lattice/protocols 4.11.0 → 5.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/protocols",
3
- "version": "4.11.0",
3
+ "version": "5.0.0",
4
4
  "description": "Unified protocol type definitions for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Normalized human-in-the-loop interrupt exposed by the Agent Server surface.
3
+ */
4
+ export interface AgentInterrupt {
5
+ /** Stable id derived from the LangGraph interrupt id or task/index. */
6
+ interruptId: string;
7
+ /** Normalized interrupt kind. */
8
+ type: "clarify" | "task_review" | "custom";
9
+ /** Thread whose graph is interrupted. */
10
+ threadId: string;
11
+ /** Task id that owns the interrupt, when available. */
12
+ taskId?: string;
13
+ /** Raw or normalized interrupt payload. */
14
+ payload: unknown;
15
+ }
@@ -14,7 +14,6 @@ import { BaseLatticeProtocol } from "./BaseLatticeProtocol";
14
14
  export enum AgentType {
15
15
  REACT = "react",
16
16
  DEEP_AGENT = "deep_agent",
17
- TEAM = "team",
18
17
  PROCESSING = "processing",
19
18
  /** Remote A2A agent — delegates to an external A2A-compatible server */
20
19
  A2A_REMOTE = "a2a_remote",
@@ -203,52 +202,6 @@ export interface ProcessingAgentConfig extends BaseAgentConfig {
203
202
  internalSubAgents?: AgentConfig[];
204
203
  }
205
204
 
206
- /**
207
- * Team teammate configuration -- describes an available teammate.
208
- */
209
- export interface TeamTeammateConfig {
210
- /** Unique name for this teammate (used as agent ID) */
211
- name: string;
212
- /** Role category (e.g. "research", "writing", "review") */
213
- role: string;
214
- /** Human-readable description of what this teammate does */
215
- description: string;
216
- /** Tool keys this teammate has access to */
217
- tools?: string[];
218
- /** Custom system prompt for this teammate */
219
- prompt?: string;
220
- /** Model key override for this teammate */
221
- modelKey?: string;
222
- }
223
-
224
- /**
225
- * TEAM agent configuration -- a team lead that dynamically creates teammates.
226
- * Teammates are created on-the-fly from create_team tool input (name, role, description).
227
- */
228
- export interface TeamAgentConfig extends BaseAgentConfig {
229
- type: AgentType.TEAM;
230
- /** Tool keys available to the team lead */
231
- tools?: string[];
232
- /** Maximum number of teammates running concurrently */
233
- maxConcurrency?: number;
234
- /**
235
- * Schedule lattice key for polling task list / mailbox.
236
- * When set, teammates use ScheduleLattice for periodic polling instead of event-driven wait.
237
- */
238
- scheduleLatticeKey?: string;
239
- /** Poll interval in ms when using schedule lattice (default: 5000) */
240
- pollIntervalMs?: number;
241
- }
242
-
243
- /**
244
- * Type guard to check if config is TeamAgentConfig
245
- */
246
- export function isTeamAgentConfig(
247
- config: AgentConfig
248
- ): config is TeamAgentConfig {
249
- return config.type === AgentType.TEAM;
250
- }
251
-
252
205
  // ─── A2A_REMOTE Agent ──────────────────────────────────────────────────────
253
206
 
254
207
  export interface LocalRuntimeConfig {
@@ -361,7 +314,6 @@ export function isWorkflowAgentConfig(
361
314
  export type AgentConfig =
362
315
  | ReactAgentConfig
363
316
  | DeepAgentConfig
364
- | TeamAgentConfig
365
317
  | ProcessingAgentConfig
366
318
  | A2ARemoteAgentConfig
367
319
  | WorkflowAgentConfig
@@ -372,7 +324,6 @@ export type AgentConfig =
372
324
  export type AgentConfigWithTools =
373
325
  | ReactAgentConfig
374
326
  | DeepAgentConfig
375
- | TeamAgentConfig
376
327
  | ProcessingAgentConfig
377
328
  | A2ARemoteAgentConfig
378
329
  | WorkflowAgentConfig
@@ -104,6 +104,8 @@ export type AgentWebAppErrorCode =
104
104
  | "THREAD_NOT_FOUND"
105
105
  | "STREAM_CONFLICT"
106
106
  | "STREAM_FAILED"
107
+ | "RUN_CANCEL_UNAVAILABLE"
108
+ | "RUN_CANCEL_STORE_UNAVAILABLE"
107
109
  | "INVALID_REQUEST"
108
110
  | "INTERNAL_ERROR";
109
111
 
@@ -87,6 +87,16 @@ export type MessageMiddleware = (
87
87
  export interface ChannelAdapter<TConfig = unknown> {
88
88
  readonly channel: string;
89
89
  readonly configSchema: z.ZodSchema<TConfig>;
90
+ /**
91
+ * Whether `sendReply` is safe to call from any Gateway replica.
92
+ *
93
+ * @remarks
94
+ * Only adapters whose reply transport is stateless/HTTP (for example Lark)
95
+ * may set this. Connection-bound adapters (WeCom/WeChat/Room) leave it
96
+ * undefined: their replies depend on process-local sockets or context tokens,
97
+ * so the durable cross-replica reply path must not claim their jobs.
98
+ */
99
+ readonly durableReply?: boolean;
90
100
  receive(
91
101
  rawPayload: unknown,
92
102
  installation: ChannelInstallation,
@@ -0,0 +1,184 @@
1
+ /** Lifecycle of one durable channel reply delivery attempt. */
2
+ export type ChannelReplyJobStatus = "pending" | "delivered" | "failed";
3
+
4
+ /**
5
+ * A durable, cross-replica channel reply job.
6
+ *
7
+ * @remarks
8
+ * The row is keyed by `runId` but always read/written inside a tenant scope.
9
+ * Claiming is a lease-based CAS: only a pending, due row with no live lease can
10
+ * be claimed, and terminal writes must present the current lease owner.
11
+ */
12
+ export interface ChannelReplyJob {
13
+ runId: string;
14
+ tenantId: string;
15
+ assistantId: string;
16
+ threadId: string;
17
+ adapterChannel: string;
18
+ channelInstallationId: string;
19
+ replyTarget: Record<string, unknown>;
20
+ inputMessageId: string;
21
+ /**
22
+ * Legacy chunk-buffer cursor captured before the run was created.
23
+ *
24
+ * @remarks Retained for schema compatibility. The durable dispatcher now
25
+ * reads final text from the thread checkpoint and does not use this field.
26
+ */
27
+ streamCursor?: string | null;
28
+ status: ChannelReplyJobStatus;
29
+ attempts: number;
30
+ /** Epoch milliseconds. */
31
+ availableAt: number;
32
+ leaseOwner: string | null;
33
+ /** Epoch milliseconds. */
34
+ leaseExpiresAt: number | null;
35
+ lastError: string | null;
36
+ outputMessageId: string | null;
37
+ /** Epoch milliseconds. */
38
+ createdAt: number;
39
+ /** Epoch milliseconds. */
40
+ updatedAt: number;
41
+ /** Epoch milliseconds. */
42
+ deliveredAt: number | null;
43
+ }
44
+
45
+ /** Fields accepted when enqueuing a reply job. Status/lease/output fields are assigned by the store. */
46
+ export interface CreateChannelReplyJobInput {
47
+ runId: string;
48
+ tenantId: string;
49
+ assistantId: string;
50
+ threadId: string;
51
+ adapterChannel: string;
52
+ channelInstallationId: string;
53
+ replyTarget: Record<string, unknown>;
54
+ inputMessageId: string;
55
+ /**
56
+ * Legacy chunk-buffer cursor captured before the run was created.
57
+ *
58
+ * @remarks Retained for schema compatibility. The durable dispatcher now
59
+ * reads final text from the thread checkpoint and does not use this field.
60
+ */
61
+ streamCursor?: string | null;
62
+ /** Earliest epoch-ms time the job may be claimed. */
63
+ availableAt: number;
64
+ }
65
+
66
+ /** Persistence boundary for durable channel reply jobs. */
67
+ export interface ChannelReplyJobStore {
68
+ /** Creates a pending job. */
69
+ create(input: CreateChannelReplyJobInput): Promise<ChannelReplyJob>;
70
+ /** Reads one job within a tenant scope. */
71
+ get(tenantId: string, runId: string): Promise<ChannelReplyJob | null>;
72
+ /** Lists claimable pending jobs that are due at `now`, oldest first. */
73
+ listPending(now: number, limit?: number): Promise<ChannelReplyJob[]>;
74
+ /**
75
+ * Atomically leases a pending, due job that is unleased or has an expired
76
+ * lease. Returns the claimed job, or `null` when another owner won.
77
+ */
78
+ claim(
79
+ tenantId: string,
80
+ runId: string,
81
+ owner: string,
82
+ leaseMs: number,
83
+ now?: number,
84
+ ): Promise<ChannelReplyJob | null>;
85
+ /** Marks a job delivered when `owner` still holds the lease. */
86
+ markDelivered(
87
+ tenantId: string,
88
+ runId: string,
89
+ owner: string,
90
+ outputMessageId?: string | null,
91
+ now?: number,
92
+ ): Promise<ChannelReplyJob | null>;
93
+ /** Returns a job to pending with an incremented attempt count. */
94
+ reschedule(
95
+ tenantId: string,
96
+ runId: string,
97
+ owner: string,
98
+ error: string,
99
+ availableAt: number,
100
+ now?: number,
101
+ ): Promise<ChannelReplyJob | null>;
102
+ /** Marks a job terminally failed. */
103
+ markFailed(
104
+ tenantId: string,
105
+ runId: string,
106
+ owner: string,
107
+ error: string,
108
+ now?: number,
109
+ ): Promise<ChannelReplyJob | null>;
110
+ /** Deletes one job. Returns whether a row was removed. */
111
+ delete(tenantId: string, runId: string): Promise<boolean>;
112
+ /**
113
+ * Deletes up to `limit` terminal (`delivered`/`failed`) jobs whose last update
114
+ * is strictly older than `before`.
115
+ *
116
+ * @param before Epoch milliseconds retention cutoff.
117
+ * @param limit Maximum rows to prune in this batch (normalized 1..200).
118
+ * @returns Number of rows removed.
119
+ */
120
+ deleteTerminalBefore(before: number, limit?: number): Promise<number>;
121
+ }
122
+
123
+ /** Smallest accepted page size for {@link ChannelReplyJobStore.listPending}. */
124
+ export const CHANNEL_REPLY_JOB_LIST_LIMIT_MIN = 1;
125
+ /** Largest accepted page size for {@link ChannelReplyJobStore.listPending}. */
126
+ export const CHANNEL_REPLY_JOB_LIST_LIMIT_MAX = 200;
127
+ /** Default page size for {@link ChannelReplyJobStore.listPending}. */
128
+ export const CHANNEL_REPLY_JOB_LIST_LIMIT_DEFAULT = 100;
129
+
130
+ /**
131
+ * Normalizes a caller-supplied page size into the shared 1..200 range.
132
+ *
133
+ * @remarks Zero and negative values clamp to 1; non-finite values fall back to
134
+ * the default. Stores must use this helper so InMemory and PostgreSQL agree.
135
+ */
136
+ export function normalizeChannelReplyJobListLimit(limit?: number): number {
137
+ if (limit === undefined || !Number.isFinite(limit)) {
138
+ return CHANNEL_REPLY_JOB_LIST_LIMIT_DEFAULT;
139
+ }
140
+ return Math.min(
141
+ CHANNEL_REPLY_JOB_LIST_LIMIT_MAX,
142
+ Math.max(CHANNEL_REPLY_JOB_LIST_LIMIT_MIN, Math.trunc(limit)),
143
+ );
144
+ }
145
+
146
+ /** Rejects missing/blank required fields and malformed create payloads at runtime. */
147
+ export function assertValidCreateChannelReplyJobInput(
148
+ input: CreateChannelReplyJobInput,
149
+ ): void {
150
+ if (input === null || typeof input !== "object") {
151
+ throw new Error("ChannelReplyJob create input is required");
152
+ }
153
+ const required: Array<[string, unknown]> = [
154
+ ["runId", input.runId],
155
+ ["tenantId", input.tenantId],
156
+ ["assistantId", input.assistantId],
157
+ ["threadId", input.threadId],
158
+ ["adapterChannel", input.adapterChannel],
159
+ ["channelInstallationId", input.channelInstallationId],
160
+ ["inputMessageId", input.inputMessageId],
161
+ ];
162
+ for (const [field, value] of required) {
163
+ if (typeof value !== "string" || value.trim() === "") {
164
+ throw new Error(`ChannelReplyJob requires a non-empty ${field}`);
165
+ }
166
+ }
167
+ if (
168
+ typeof input.replyTarget !== "object" ||
169
+ input.replyTarget === null ||
170
+ Array.isArray(input.replyTarget)
171
+ ) {
172
+ throw new Error("ChannelReplyJob requires a replyTarget object");
173
+ }
174
+ if (typeof input.availableAt !== "number" || !Number.isFinite(input.availableAt)) {
175
+ throw new Error("ChannelReplyJob requires a finite availableAt");
176
+ }
177
+ if (
178
+ input.streamCursor !== undefined &&
179
+ input.streamCursor !== null &&
180
+ typeof input.streamCursor !== "string"
181
+ ) {
182
+ throw new Error("ChannelReplyJob streamCursor must be a string");
183
+ }
184
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Checkpoint storage usage for a single thread.
3
+ *
4
+ * This is a read-only accounting view over the checkpointer's persisted rows.
5
+ */
6
+ export interface ThreadCheckpointStorage {
7
+ /** Thread whose checkpoint rows were measured. */
8
+ threadId: string;
9
+ /** Sum of checkpoint, channel-value, and pending-write bytes. */
10
+ totalBytes: number;
11
+ /** Persisted checkpoint rows (`checkpoints` table). */
12
+ checkpoints: { count: number; bytes: number };
13
+ /** Persisted channel values (`checkpoint_blobs` table). */
14
+ channelValues: { count: number; bytes: number };
15
+ /** Persisted pending writes (`checkpoint_writes` table). */
16
+ pendingWrites: { count: number; bytes: number };
17
+ }
18
+
19
+ /**
20
+ * Read-only checkpoint storage accounting.
21
+ *
22
+ * Implementations must not create, copy, prune, or mutate checkpoint rows.
23
+ */
24
+ export interface CheckpointStorage {
25
+ /**
26
+ * Measure the checkpoint storage used by one thread.
27
+ *
28
+ * @param threadId - Thread id whose checkpoint rows should be measured.
29
+ * @returns Counts and byte sizes grouped by checkpoint storage category.
30
+ */
31
+ get(threadId: string): Promise<ThreadCheckpointStorage>;
32
+ }
33
+
34
+ /**
35
+ * Checkpoint maintenance operations over persisted thread checkpoints.
36
+ *
37
+ * Implementations copy, delete, and prune raw checkpoint rows for a thread.
38
+ * Callers must verify thread ownership and refuse to run while the thread is
39
+ * active.
40
+ */
41
+ export interface CheckpointMaintenance {
42
+ /**
43
+ * Copy every checkpoint row from a source thread to an empty target thread.
44
+ *
45
+ * @param sourceThreadId - Thread whose rows are copied.
46
+ * @param targetThreadId - Empty thread that receives the rows.
47
+ * @returns Resolves when the copy commits; an empty source is a no-op.
48
+ * @throws When the target already has checkpoint rows.
49
+ */
50
+ copyThread(sourceThreadId: string, targetThreadId: string): Promise<void>;
51
+
52
+ /**
53
+ * Delete every checkpoint row for a thread.
54
+ *
55
+ * @param threadId - Thread whose checkpoint rows are deleted.
56
+ * @returns Resolves when the deletion commits.
57
+ */
58
+ deleteThread(threadId: string): Promise<void>;
59
+
60
+ /**
61
+ * Delete the checkpoints that descend from a base checkpoint.
62
+ *
63
+ * Preserves the base checkpoint and every checkpoint that is not descended
64
+ * from it. Descendants are discovered by following both same-namespace
65
+ * `parent_checkpoint_id` edges and cross-namespace `metadata.parents` edges
66
+ * (the `{ "<parent checkpoint_ns>": "<parent checkpoint id>" }` map that
67
+ * LangGraph writes for subgraph checkpoints), recursively across all
68
+ * namespaces. Checkpoint-id ordering is not used. LangGraph keys the first
69
+ * superstep's writes of a resumed execution to the parent/base checkpoint, so
70
+ * writes keyed to the base itself belong to the rolled-back run and are
71
+ * deleted with descendant writes. Channel blobs are removed only when no
72
+ * retained checkpoint references them.
73
+ *
74
+ * @param threadId - Thread whose later checkpoint history is deleted.
75
+ * @param baseCheckpointId - Checkpoint to retain as the new tip, or `null` to
76
+ * delete every checkpoint for the thread. An empty string is invalid and must
77
+ * never be treated as the `null` wildcard.
78
+ * @returns Resolves when the deletion commits; no descendants is a no-op.
79
+ * @throws When the base checkpoint does not exist on the thread. Callers must
80
+ * fail closed rather than deleting an unknown range.
81
+ * @remarks The operation is transactional and idempotent so rollback recovery
82
+ * can retry it.
83
+ */
84
+ deleteAfter(threadId: string, baseCheckpointId: string | null): Promise<void>;
85
+
86
+ /**
87
+ * Retain only the latest N checkpoints per checkpoint namespace.
88
+ *
89
+ * @param threadId - Thread whose checkpoint history is pruned.
90
+ * @param options - Retention options.
91
+ * @param options.keepLatest - Number of latest checkpoints to retain per namespace.
92
+ * @returns Counts of deleted checkpoint, blob, and write rows.
93
+ */
94
+ pruneThread(
95
+ threadId: string,
96
+ options: { keepLatest: number },
97
+ ): Promise<{ checkpoints: number; blobs: number; writes: number }>;
98
+ }
@@ -114,6 +114,14 @@ export type MessageChunkType = typeof MessageChunkTypes[keyof typeof MessageChun
114
114
 
115
115
  export interface MessageChunk {
116
116
  type: MessageChunkType;
117
+ /**
118
+ * Shared append-log cursor for this chunk.
119
+ *
120
+ * Set by streaming transports when the chunk is read from a cursor-addressable
121
+ * log (for example Redis Streams) so reconnects can resume via SSE
122
+ * `Last-Event-ID`. Omitted by in-memory buffers configured without a log.
123
+ */
124
+ cursor?: string;
117
125
  data: {
118
126
  id: string;
119
127
  content?: string;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Upper bound for every contract string. These payloads travel between
5
+ * replicas and are persisted with A2A tasks, so unbounded input is rejected
6
+ * at the shared boundary instead of relying on transport limits.
7
+ */
8
+ const MAX_FIELD_LENGTH = 64 * 1024;
9
+
10
+ const boundedString = z.string().min(1).max(MAX_FIELD_LENGTH);
11
+
12
+ /** Terminal cancellation commands delivered to the replica that currently owns a run. */
13
+ const RUN_CANCEL_REASONS = ["abort", "a2a.cancel", "shutdown"] as const;
14
+
15
+ /**
16
+ * A cancellation signal routed to the current owner of a run.
17
+ *
18
+ * `runId` is mandatory: a signal without it cannot unambiguously identify the
19
+ * execution to stop, so it is rejected rather than applied to the thread.
20
+ */
21
+ export interface RunCancelSignal {
22
+ commandId: string;
23
+ tenantId: string;
24
+ assistantId: string;
25
+ threadId: string;
26
+ runId: string;
27
+ reason: "abort" | "a2a.cancel" | "shutdown";
28
+ createdAt: string;
29
+ }
30
+
31
+ /**
32
+ * Identity of a human-in-the-loop interrupt answer, used for distributed
33
+ * dedupe so multiple replicas settle the same answer at most once.
34
+ */
35
+ export interface InterruptAnswerKey {
36
+ tenantId: string;
37
+ assistantId: string;
38
+ threadId: string;
39
+ interruptId: string;
40
+ }
41
+
42
+ /**
43
+ * Durable A2A event delivery cursor stored with the shared A2A task.
44
+ * `lastEventId` is the last event already handed to the client.
45
+ */
46
+ export interface A2AEventCursor {
47
+ taskId: string;
48
+ lastEventId: string;
49
+ updatedAt: string;
50
+ }
51
+
52
+ /** Strict validator for {@link RunCancelSignal}. */
53
+ export const runCancelSignalSchema: z.ZodType<RunCancelSignal> = z.object({
54
+ commandId: boundedString,
55
+ tenantId: boundedString,
56
+ assistantId: boundedString,
57
+ threadId: boundedString,
58
+ runId: boundedString,
59
+ reason: z.enum(RUN_CANCEL_REASONS),
60
+ createdAt: boundedString,
61
+ }).strict();
62
+
63
+ /** Strict validator for {@link InterruptAnswerKey}. */
64
+ export const interruptAnswerKeySchema: z.ZodType<InterruptAnswerKey> = z.object({
65
+ tenantId: boundedString,
66
+ assistantId: boundedString,
67
+ threadId: boundedString,
68
+ interruptId: boundedString,
69
+ }).strict();
70
+
71
+ /** Strict validator for {@link A2AEventCursor}. */
72
+ export const a2aEventCursorSchema: z.ZodType<A2AEventCursor> = z.object({
73
+ taskId: boundedString,
74
+ lastEventId: boundedString,
75
+ updatedAt: boundedString,
76
+ }).strict();
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A tenant-scoped namespaced key-value item.
3
+ */
4
+ export interface StoreItem {
5
+ /** Hierarchical namespace path. */
6
+ namespace: string[];
7
+ /** Item key, unique within `(tenantId, namespace)`. */
8
+ key: string;
9
+ /** Arbitrary JSON object stored under the key. */
10
+ value: Record<string, unknown>;
11
+ /** Creation timestamp. */
12
+ createdAt: Date;
13
+ /** Last write timestamp. */
14
+ updatedAt: Date;
15
+ /** Optional expiry timestamp; expired items are invisible. */
16
+ expiresAt?: Date;
17
+ }
18
+
19
+ /** Input for {@link StoreProtocol.put}. */
20
+ export interface StorePutInput {
21
+ /** Non-empty namespace path. */
22
+ namespace: string[];
23
+ /** Non-empty item key. */
24
+ key: string;
25
+ /** JSON object value. */
26
+ value: Record<string, unknown>;
27
+ /**
28
+ * Optional time-to-live in minutes.
29
+ *
30
+ * @remarks Positive finite numbers are accepted; stores compute `expiresAt`.
31
+ */
32
+ ttlMinutes?: number;
33
+ /**
34
+ * Optional index hint accepted for future indexing.
35
+ *
36
+ * @remarks No vector index is created in this slice.
37
+ */
38
+ index?: boolean | string[];
39
+ }
40
+
41
+ /** Input for {@link StoreProtocol.search}. */
42
+ export interface StoreSearchInput {
43
+ /** Only return items whose namespace starts with this path. */
44
+ namespacePrefix?: string[];
45
+ /** Deep partial-equality filter over `value`. */
46
+ filter?: Record<string, unknown>;
47
+ /** Case-insensitive substring over the serialized value. */
48
+ query?: string;
49
+ /** Maximum items to return. */
50
+ limit?: number;
51
+ /** Pagination offset. */
52
+ offset?: number;
53
+ }
54
+
55
+ /**
56
+ * Tenant-scoped namespaced key-value store with TTL and simple search.
57
+ *
58
+ * @remarks
59
+ * - Unique identity is `(tenantId, namespace, key)`.
60
+ * - Expired items are invisible to {@link get} and {@link search}.
61
+ * - `filter` is deep partial equality over `value`; `query` is a
62
+ * case-insensitive substring over the serialized value. Semantic/embedding
63
+ * search is deferred.
64
+ */
65
+ export interface StoreProtocol {
66
+ /** Create or replace an item. */
67
+ put(tenantId: string, input: StorePutInput): Promise<StoreItem>;
68
+ /** Read an unexpired item. */
69
+ get(tenantId: string, namespace: string[], key: string): Promise<StoreItem | null>;
70
+ /** Delete an item regardless of expiry. */
71
+ delete(tenantId: string, namespace: string[], key: string): Promise<boolean>;
72
+ /** Search unexpired items. */
73
+ search(tenantId: string, input: StoreSearchInput): Promise<StoreItem[]>;
74
+ /** List distinct namespace paths under a prefix. */
75
+ listNamespaces(tenantId: string, prefix?: string[], limit?: number): Promise<string[][]>;
76
+ /** Hard-delete expired items across all tenants. */
77
+ deleteExpired(now: Date, limit: number): Promise<number>;
78
+ }
79
+
80
+
81
+ /** Separator reserved for encoded namespace paths. */
82
+ export const STORE_IDENTITY_SEPARATOR = "\u001f";
83
+
84
+ /** Raised when a store namespace/key identity is malformed. */
85
+ export class StoreValidationError extends Error {
86
+ readonly code = "STORE_IDENTITY_INVALID" as const;
87
+
88
+ constructor(message: string) {
89
+ super(message);
90
+ this.name = "StoreValidationError";
91
+ }
92
+ }
93
+
94
+ function validStoreSegment(value: unknown): value is string {
95
+ return typeof value === "string"
96
+ && value.length > 0
97
+ && !value.includes(STORE_IDENTITY_SEPARATOR);
98
+ }
99
+
100
+ /**
101
+ * Assert a non-empty namespace array of non-empty segments without the
102
+ * reserved separator.
103
+ *
104
+ * @param namespace - Candidate namespace path.
105
+ * @throws {StoreValidationError} When the namespace is malformed.
106
+ */
107
+ export function assertStoreIdentity(namespace: unknown, key: unknown): asserts namespace is string[] {
108
+ if (!Array.isArray(namespace) || namespace.length === 0) {
109
+ throw new StoreValidationError("namespace must be a non-empty string array");
110
+ }
111
+ for (const segment of namespace) {
112
+ if (!validStoreSegment(segment)) {
113
+ throw new StoreValidationError(
114
+ "namespace segments must be non-empty strings without the reserved separator",
115
+ );
116
+ }
117
+ }
118
+ if (!validStoreSegment(key)) {
119
+ throw new StoreValidationError(
120
+ "key must be a non-empty string without the reserved separator",
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Assert a namespace prefix (which may be empty for "all namespaces").
127
+ *
128
+ * @param prefix - Candidate prefix path.
129
+ * @throws {StoreValidationError} When any segment is malformed.
130
+ */
131
+ export function assertStoreNamespacePrefix(prefix: unknown): asserts prefix is string[] {
132
+ if (!Array.isArray(prefix)) {
133
+ throw new StoreValidationError("namespace prefix must be a string array");
134
+ }
135
+ for (const segment of prefix) {
136
+ if (!validStoreSegment(segment)) {
137
+ throw new StoreValidationError(
138
+ "namespace segments must be non-empty strings without the reserved separator",
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Validate and compute an expiry date from `ttlMinutes`.
146
+ *
147
+ * @param ttlMinutes - Optional positive finite TTL in minutes.
148
+ * @param now - Base time (default current time).
149
+ * @returns The expiry date, or `undefined` when no TTL was supplied.
150
+ * @throws {StoreValidationError} When the TTL is invalid or out of range.
151
+ */
152
+ export function storeExpiresAt(
153
+ ttlMinutes: number | undefined,
154
+ now: Date = new Date(),
155
+ ): Date | undefined {
156
+ if (ttlMinutes === undefined) return undefined;
157
+ if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) {
158
+ throw new StoreValidationError("ttlMinutes must be a positive number");
159
+ }
160
+ const expiresAt = new Date(now.getTime() + ttlMinutes * 60_000);
161
+ if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getUTCFullYear() > 9999) {
162
+ throw new StoreValidationError("ttlMinutes is out of range");
163
+ }
164
+ return expiresAt;
165
+ }
166
+
167
+ /**
168
+ * Validate a non-negative safe-integer pagination value.
169
+ *
170
+ * @param value - Candidate limit/offset.
171
+ * @param fallback - Value used when omitted.
172
+ * @returns The validated number.
173
+ * @throws {StoreValidationError} When the value is not a non-negative safe integer.
174
+ */
175
+ export function storePaginationValue(value: number | undefined, fallback: number): number {
176
+ if (value === undefined) return fallback;
177
+ if (!Number.isSafeInteger(value) || value < 0) {
178
+ throw new StoreValidationError("limit/offset must be a non-negative safe integer");
179
+ }
180
+ return value;
181
+ }