@lynx-go/ui-kit 0.0.0-stage → 0.1.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.
@@ -0,0 +1,262 @@
1
+ import type { ApiError, Cursor, FrameSource, Transport } from "../kernel";
2
+
3
+ /**
4
+ * 通用传输适配器(kernel Transport 契约 → fetch 流)。
5
+ *
6
+ * 终态分诊裁决落点(design.md §2.3):NDJSON-over-fetch 的"收流"与"中断"
7
+ * 在传输层不可严格区分——真终态判据是帧内容,由消费方经 isEnded 上报;
8
+ * body 结束而不满足 isEnded ⇒ 抛错(引擎按退避重连)。SSE 同理:具名 endEvent
9
+ * 是唯一"服务端收流"判据,连接断开一律按传输中断处理。
10
+ * 票据/base64/protojson 的帧解码归消费方(decode 参数)。
11
+ */
12
+
13
+ type UrlBuilder = string | ((cursor: Cursor | null) => string);
14
+
15
+ function buildUrl(url: UrlBuilder, cursor: Cursor | null): string {
16
+ if (typeof url === "function") return url(cursor);
17
+ if (cursor === null) return url;
18
+ const join = url.includes("?") ? "&" : "?";
19
+ return `${url}${join}cursor=${encodeURIComponent(cursor)}`;
20
+ }
21
+
22
+ async function fetchOrApiError(
23
+ url: string,
24
+ init: RequestInit,
25
+ fetchImpl: typeof fetch,
26
+ ): Promise<Response> {
27
+ let response: Response;
28
+ try {
29
+ response = await fetchImpl(url, init);
30
+ } catch (cause) {
31
+ throw { kind: "network", cause } satisfies ApiError;
32
+ }
33
+ if (!response.ok) {
34
+ if (response.status === 401) throw { kind: "auth" } satisfies ApiError;
35
+ if (response.status === 403) throw { kind: "forbidden" } satisfies ApiError;
36
+ if (response.status === 404) throw { kind: "not-found" } satisfies ApiError;
37
+ let message: string | undefined;
38
+ try {
39
+ const body = (await response.json()) as { message?: unknown; error?: { message?: unknown } };
40
+ if (typeof body.message === "string") message = body.message;
41
+ else if (typeof body.error?.message === "string") message = body.error.message;
42
+ } catch {
43
+ // 非 JSON 错误体
44
+ }
45
+ throw {
46
+ kind: "server",
47
+ status: response.status,
48
+ ...(message ? { message } : {}),
49
+ } satisfies ApiError;
50
+ }
51
+ return response;
52
+ }
53
+
54
+ class TransportInterrupt extends Error {
55
+ constructor(detail: string) {
56
+ super(`fleet-ui: transport interrupted (${detail})`);
57
+ }
58
+ }
59
+
60
+ function releaseReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
61
+ try {
62
+ reader.releaseLock();
63
+ } catch {
64
+ // 流已出错/关闭——无需解锁
65
+ }
66
+ }
67
+
68
+ /** 收流即取消(含释放锁);已结束/已中断的流上 cancel 可能拒绝,静默。 */
69
+ async function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
70
+ try {
71
+ await reader.cancel();
72
+ } catch {
73
+ releaseReader(reader);
74
+ }
75
+ }
76
+
77
+ // ============ ndjsonTransport(fleetly 日志流方言) ============
78
+
79
+ export interface NdjsonTransportOptions<F> {
80
+ url: UrlBuilder;
81
+ /** 缺省 JSON.parse;票据/base64/protojson 行解码在消费方 */
82
+ decode?: (line: string) => F;
83
+ /** fetch 注入(测试/demo);缺省 globalThis.fetch */
84
+ fetchImpl?: typeof fetch;
85
+ headers?: Record<string, string>;
86
+ /**
87
+ * 收流判据:body 正常结束且最后一帧满足 ⇒ closeReason "ended"(部署终态、日志关窗);
88
+ * 缺省恒 false——body 结束一律按传输中断(引擎重连)。
89
+ */
90
+ isEnded?: (lastFrame: F) => boolean;
91
+ }
92
+
93
+ export function ndjsonTransport<F>(options: NdjsonTransportOptions<F>): Transport<F> {
94
+ const decode = options.decode ?? ((line: string) => JSON.parse(line) as F);
95
+ const fetchImpl = options.fetchImpl ?? fetch;
96
+
97
+ return {
98
+ open(cursor: Cursor | null, signal: AbortSignal): FrameSource<F> {
99
+ let closeReason: FrameSource<F>["closeReason"] = "transport";
100
+
101
+ const self = {
102
+ get closeReason() {
103
+ return closeReason;
104
+ },
105
+ async *[Symbol.asyncIterator](): AsyncIterator<F> {
106
+ const response = await fetchOrApiError(
107
+ buildUrl(options.url, cursor),
108
+ { headers: options.headers, signal },
109
+ fetchImpl,
110
+ );
111
+ if (response.body === null) throw new TransportInterrupt("no body");
112
+ const reader = response.body.getReader();
113
+ const decoder = new TextDecoder();
114
+ let buffer = "";
115
+ let lastFrame: F | undefined;
116
+ try {
117
+ while (true) {
118
+ const { done, value } = await reader.read();
119
+ if (done) break;
120
+ buffer += decoder.decode(value, { stream: true });
121
+ let newline = buffer.indexOf("\n");
122
+ while (newline >= 0) {
123
+ const line = buffer.slice(0, newline).trim();
124
+ buffer = buffer.slice(newline + 1);
125
+ if (line.length > 0) {
126
+ lastFrame = decode(line);
127
+ yield lastFrame;
128
+ }
129
+ newline = buffer.indexOf("\n");
130
+ }
131
+ }
132
+ const tail = (buffer + decoder.decode()).trim();
133
+ if (tail.length > 0) {
134
+ lastFrame = decode(tail);
135
+ yield lastFrame;
136
+ }
137
+ } finally {
138
+ await cancelReader(reader);
139
+ }
140
+ // body 结束:真终态判据是帧内容(§2.3 裁决),否则按中断抛错交引擎重连
141
+ if (lastFrame !== undefined && options.isEnded?.(lastFrame) === true) {
142
+ closeReason = "ended";
143
+ return;
144
+ }
145
+ throw new TransportInterrupt("ndjson body ended without end frame");
146
+ },
147
+ };
148
+ return self;
149
+ },
150
+ };
151
+ }
152
+
153
+ // ============ sseTransport(具名事件方言) ============
154
+
155
+ export interface SseMessage {
156
+ event: string;
157
+ data: string;
158
+ /** 最后一次 id: 字段——SSE 的原生续订游标 */
159
+ id?: string;
160
+ }
161
+
162
+ export interface SseTransportOptions<F> {
163
+ url: UrlBuilder;
164
+ /** 缺省 JSON.parse(data);消费方可按 event 分派类型化帧 */
165
+ decode?: (message: SseMessage) => F;
166
+ fetchImpl?: typeof fetch;
167
+ headers?: Record<string, string>;
168
+ /** 收流判据:收到该具名事件 ⇒ closeReason "ended";缺省无(断开一律重连) */
169
+ endEvent?: string;
170
+ }
171
+
172
+ export function sseTransport<F>(options: SseTransportOptions<F>): Transport<F> {
173
+ const decode =
174
+ options.decode ?? ((message: SseMessage) => JSON.parse(message.data) as unknown as F);
175
+ const fetchImpl = options.fetchImpl ?? fetch;
176
+
177
+ return {
178
+ open(cursor: Cursor | null, signal: AbortSignal): FrameSource<F> {
179
+ let closeReason: FrameSource<F>["closeReason"] = "transport";
180
+
181
+ const self = {
182
+ get closeReason() {
183
+ return closeReason;
184
+ },
185
+ async *[Symbol.asyncIterator](): AsyncIterator<F> {
186
+ const response = await fetchOrApiError(
187
+ buildUrl(options.url, cursor),
188
+ { headers: { Accept: "text/event-stream", ...options.headers }, signal },
189
+ fetchImpl,
190
+ );
191
+ if (response.body === null) throw new TransportInterrupt("no body");
192
+ const reader = response.body.getReader();
193
+ const decoder = new TextDecoder();
194
+ let buffer = "";
195
+ let ended = false;
196
+
197
+ /** 解析一个 SSE 块(行集合);endEvent 命中时置 ended。 */
198
+ const dispatch = (block: string, out: F[]): void => {
199
+ const message: SseMessage = { event: "message", data: "" };
200
+ const dataLines: string[] = [];
201
+ for (const rawLine of block.split("\n")) {
202
+ const line = rawLine.replace(/\r$/, "");
203
+ if (line === "" || line.startsWith(":")) continue;
204
+ const colon = line.indexOf(":");
205
+ const field = colon < 0 ? line : line.slice(0, colon);
206
+ let value = colon < 0 ? "" : line.slice(colon + 1);
207
+ if (value.startsWith(" ")) value = value.slice(1);
208
+ if (field === "data") dataLines.push(value);
209
+ else if (field === "event") message.event = value;
210
+ else if (field === "id") message.id = value;
211
+ }
212
+ if (options.endEvent !== undefined && message.event === options.endEvent) {
213
+ ended = true; // 无 data 载荷的收流事件也成立(endEvent 判定先于 data 必有检查)
214
+ return;
215
+ }
216
+ if (dataLines.length === 0) return;
217
+ message.data = dataLines.join("\n");
218
+ out.push(decode(message));
219
+ };
220
+
221
+ /** 按 \n\n 切块;final=true 时处理无尾空行的末块;ended 后停止分派。 */
222
+ const emitBlocks = (chunk: string, final: boolean): F[] => {
223
+ buffer += chunk;
224
+ const frames: F[] = [];
225
+ let boundary = buffer.indexOf("\n\n");
226
+ while (boundary >= 0 && !ended) {
227
+ dispatch(buffer.slice(0, boundary), frames);
228
+ buffer = buffer.slice(boundary + 2);
229
+ boundary = buffer.indexOf("\n\n");
230
+ }
231
+ if (final && !ended && buffer.trim().length > 0) {
232
+ dispatch(buffer, frames);
233
+ buffer = "";
234
+ }
235
+ return frames;
236
+ };
237
+
238
+ try {
239
+ while (!ended) {
240
+ const { done, value } = await reader.read();
241
+ if (done) {
242
+ for (const frame of emitBlocks(decoder.decode(), true)) yield frame;
243
+ break;
244
+ }
245
+ for (const frame of emitBlocks(decoder.decode(value, { stream: true }), false)) {
246
+ yield frame;
247
+ }
248
+ }
249
+ } finally {
250
+ await cancelReader(reader);
251
+ }
252
+ if (ended) {
253
+ closeReason = "ended";
254
+ return;
255
+ }
256
+ throw new TransportInterrupt("sse stream closed without end event");
257
+ },
258
+ };
259
+ return self;
260
+ },
261
+ };
262
+ }
@@ -0,0 +1,153 @@
1
+ import type { CollectionQuery, FieldValue, Order, Predicate, QueryWindow, Segment } from "./types";
2
+
3
+ // ============ 谓词求值(纯函数) ============
4
+
5
+ function fieldOf(row: unknown, field: string): unknown {
6
+ return (row as Record<string, unknown>)[field];
7
+ }
8
+
9
+ function compare(a: unknown, b: unknown): number {
10
+ if (a === b) return 0;
11
+ if (a === undefined || a === null) return -1;
12
+ if (b === undefined || b === null) return 1;
13
+ if (typeof a === "number" && typeof b === "number") return a - b;
14
+ if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
15
+ return String(a).localeCompare(String(b));
16
+ }
17
+
18
+ function contains(value: unknown, needle: FieldValue): boolean {
19
+ return String(value ?? "")
20
+ .toLowerCase()
21
+ .includes(String(needle).toLowerCase());
22
+ }
23
+
24
+ export function evalPredicate(predicate: Predicate | undefined, row: unknown): boolean {
25
+ if (predicate === undefined) return true;
26
+ if ("all" in predicate) return predicate.all.every((p) => evalPredicate(p, row));
27
+ if ("any" in predicate) return predicate.any.some((p) => evalPredicate(p, row));
28
+ const value = fieldOf(row, predicate.field as string);
29
+ switch (predicate.op) {
30
+ case "eq":
31
+ return value === predicate.value;
32
+ case "ne":
33
+ return value !== predicate.value;
34
+ case "contains":
35
+ return contains(value, predicate.value);
36
+ case "in":
37
+ return Array.isArray(predicate.value) && predicate.value.includes(value as string);
38
+ case "gt":
39
+ return compare(value, predicate.value) > 0;
40
+ case "lt":
41
+ return compare(value, predicate.value) < 0;
42
+ case "gte":
43
+ return compare(value, predicate.value) >= 0;
44
+ case "lte":
45
+ return compare(value, predicate.value) <= 0;
46
+ case "exists":
47
+ return value !== undefined && value !== null;
48
+ }
49
+ }
50
+
51
+ /** 文本搜索谓词:q 在白名单字段上的 contains 之 OR(FilterBar/schema 工厂共用)。 */
52
+ export function textPredicate(fields: ReadonlyArray<string>, q: string): Predicate | undefined {
53
+ if (!q.trim() || fields.length === 0) return undefined;
54
+ return {
55
+ any: fields.map((field) => ({ field, op: "contains" as const, value: q.trim() })),
56
+ };
57
+ }
58
+
59
+ // ============ 客户端伪造游标(客户端求值方言) ============
60
+ // "游标进 URL、历史即分页栈"在整取方言上的兑现:offset 编进不透明 token。
61
+
62
+ export function encodeClientCursor(offset: number): string {
63
+ return `c${offset.toString(36)}`;
64
+ }
65
+
66
+ export function decodeClientCursor(cursor: string): number | null {
67
+ if (!cursor.startsWith("c")) return null;
68
+ const n = Number.parseInt(cursor.slice(1), 36);
69
+ return Number.isFinite(n) && n >= 0 ? n : null;
70
+ }
71
+
72
+ function offsetOf(window: QueryWindow): number {
73
+ if (window.cursor !== undefined) return decodeClientCursor(window.cursor) ?? 0;
74
+ return window.range?.offset ?? 0;
75
+ }
76
+
77
+ // ============ 客户端求值(派生概念的纯函数核心) ============
78
+
79
+ /**
80
+ * evaluate(CollectionQuery, rows) —— 求值策略,非概念。
81
+ * window.cursor 存在时保持游标模式(伪造 token);否则暴露 total(range/客户端翻页用)。
82
+ */
83
+ export function evaluateQuery<T>(query: CollectionQuery, rows: ReadonlyArray<T>): Segment<T> {
84
+ const filtered = query.predicate
85
+ ? rows.filter((row) => evalPredicate(query.predicate, row))
86
+ : [...rows];
87
+ const ordered = query.order?.length
88
+ ? [...filtered].sort((a, b) => {
89
+ for (const o of query.order ?? []) {
90
+ const c = compare(fieldOf(a, o.field as string), fieldOf(b, o.field as string));
91
+ if (c !== 0) return o.dir === "desc" ? -c : c;
92
+ }
93
+ return 0;
94
+ })
95
+ : filtered;
96
+
97
+ const w = query.window;
98
+ const offset = offsetOf(w);
99
+ const slice = ordered.slice(offset, offset + w.size);
100
+ const segment: Segment<T> = { rows: slice };
101
+
102
+ if (w.cursor !== undefined) {
103
+ if (offset + w.size < ordered.length) {
104
+ segment.nextCursor = encodeClientCursor(offset + w.size);
105
+ }
106
+ } else {
107
+ segment.total = ordered.length;
108
+ }
109
+ return segment;
110
+ }
111
+
112
+ // ============ 游标代数 ============
113
+
114
+ /**
115
+ * 游标推进。返回 null = 不可算:
116
+ * - keyset(服务端不透明游标 / leaderboards 型 token+total)的 prev 走浏览器历史;
117
+ * - 唯一能本地算 prev 的是 offset 方言与客户端求值方言(伪造游标含 offset)。
118
+ */
119
+ export function advance(
120
+ window: QueryWindow,
121
+ segment: Segment<unknown>,
122
+ dir: "next" | "prev",
123
+ ): QueryWindow | null {
124
+ if (dir === "next") {
125
+ // 服务端续读令牌优先(keyset 无 total / leaderboards 型 token+total);
126
+ // 客户端求值的 range 模式不产 nextCursor,自然落回 range 分支。
127
+ if (segment.nextCursor !== undefined) {
128
+ return { ...window, cursor: segment.nextCursor };
129
+ }
130
+ if (window.cursor !== undefined) {
131
+ return null; // 游标模式下无令牌 = 到底
132
+ }
133
+ const offset = offsetOf(window);
134
+ const next = offset + window.size;
135
+ if (segment.total !== undefined && next >= segment.total) return null;
136
+ return { ...window, range: { offset: next } };
137
+ }
138
+ const offset = offsetOf(window);
139
+ if (offset < window.size) return null;
140
+ const prev = { ...window };
141
+ if (window.cursor !== undefined) prev.cursor = encodeClientCursor(offset - window.size);
142
+ else prev.range = { offset: offset - window.size };
143
+ return prev;
144
+ }
145
+
146
+ export function orderEquals(a?: ReadonlyArray<Order>, b?: ReadonlyArray<Order>): boolean {
147
+ if (a === b) return true;
148
+ if (!a || !b || a.length !== b.length) return false;
149
+ return a.every((o, i) => {
150
+ const other = b[i];
151
+ return !!other && o.field === other.field && o.dir === other.dir;
152
+ });
153
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./algebra";
2
+ export * from "./keys";
3
+ export * from "./resource";
4
+ export * from "./types";
@@ -0,0 +1,99 @@
1
+ import type {
2
+ CommandsRecord,
3
+ QuerySelector,
4
+ Resource,
5
+ ResourceScope,
6
+ SubsRecord,
7
+ } from "./resource";
8
+ import { scopeOf } from "./resource";
9
+ import type { CollectionQuery } from "./types";
10
+
11
+ // ============ 查询键(queryKey 单源;失效选择器在此匹配) ============
12
+
13
+ /** 键形:[kind, resourceName, scope, ...detail]——前缀与 predicate 失效都依赖此结构。 */
14
+ export type QueryKind = "collection" | "record" | "batch";
15
+
16
+ function canonicalScope(scope: ResourceScope): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ for (const key of Object.keys(scope).sort()) {
19
+ const value = scope[key];
20
+ if (value !== undefined) out[key] = value;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function normPredicate(p: CollectionQuery["predicate"]): unknown {
26
+ return p ?? null;
27
+ }
28
+
29
+ function normWindow(w: CollectionQuery["window"]): unknown {
30
+ return { size: w.size, cursor: w.cursor ?? null, offset: w.range?.offset ?? null };
31
+ }
32
+
33
+ export function collectionKey<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
34
+ resource: Resource<T, ID, C, S>,
35
+ query: CollectionQuery,
36
+ ): readonly unknown[] {
37
+ return [
38
+ "collection",
39
+ resource.name,
40
+ canonicalScope(scopeOf(resource)),
41
+ normPredicate(query.predicate),
42
+ query.order ?? null,
43
+ normWindow(query.window),
44
+ ];
45
+ }
46
+
47
+ export function recordKey<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
48
+ resource: Resource<T, ID, C, S>,
49
+ id: ID,
50
+ ): readonly unknown[] {
51
+ return ["record", resource.name, canonicalScope(scopeOf(resource)), id];
52
+ }
53
+
54
+ export function batchKey<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
55
+ resource: Resource<T, ID, C, S>,
56
+ ids: ReadonlyArray<ID>,
57
+ ): readonly unknown[] {
58
+ const unique = [...new Set(ids)];
59
+ return ["batch", resource.name, canonicalScope(scopeOf(resource)), unique];
60
+ }
61
+
62
+ // ============ 失效匹配(scope 交运算在此) ============
63
+
64
+ const FLEET_KINDS = new Set(["collection", "record", "batch"]);
65
+
66
+ export function isFleetKey(key: readonly unknown[]): boolean {
67
+ return typeof key[0] === "string" && FLEET_KINDS.has(key[0]);
68
+ }
69
+
70
+ /**
71
+ * selector 匹配:默认与命令执行的 ctx scope 相交(project A 的命令不清 project B 的缓存);
72
+ * allScopes: true 显式逃逸。
73
+ */
74
+ export function selectorMatches(
75
+ selector: QuerySelector,
76
+ key: readonly unknown[],
77
+ commandScope: ResourceScope,
78
+ ): boolean {
79
+ const [kind, name, keyScope] = key;
80
+ if (selector.kind !== undefined && selector.kind !== kind) return false;
81
+ if (selector.resource !== name) return false;
82
+ if (!selector.allScopes) {
83
+ if (JSON.stringify(keyScope) !== JSON.stringify(canonicalScope(commandScope))) return false;
84
+ }
85
+ if (selector.kind === "record" && selector.id !== undefined) {
86
+ if (key[3] !== selector.id) return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ /** 绑定层把 invalidates 声明编译成 TanStack Query 的 predicate 过滤器。 */
92
+ export function invalidationPredicate(
93
+ selectors: ReadonlyArray<QuerySelector>,
94
+ commandScope: ResourceScope,
95
+ ): (query: { queryKey: readonly unknown[] }) => boolean {
96
+ return (query) =>
97
+ isFleetKey(query.queryKey) &&
98
+ selectors.some((s) => selectorMatches(s, query.queryKey, commandScope));
99
+ }
@@ -0,0 +1,181 @@
1
+ import type {
2
+ CollectionQuery,
3
+ CommandResult,
4
+ Segment,
5
+ SubscriptionPolicy,
6
+ Transport,
7
+ } from "./types";
8
+ import { isApiError } from "./types";
9
+
10
+ // ============ 交互面:Resource ============
11
+
12
+ /** branded 资源名——QuerySelector 收 branded 类型,拼错编译期爆炸。 */
13
+ declare const resourceBrand: unique symbol;
14
+ export type ResourceName = string & { readonly [resourceBrand]?: "ResourceName" };
15
+
16
+ export type ResourceScope = Readonly<Record<string, string | undefined>>;
17
+
18
+ export interface CommandDescriptor<Input, Payload, ID = never> {
19
+ run(input: Input, ctx: ResourceScope): Promise<CommandResult<Payload>>;
20
+ /** ID 类型线程锚:CommandsRecord 依资源 id 类型定位(optional 永不出现,
21
+ * 运行时无形状——发布 .ts 源码时消费方 noUnusedLocals 会穿透检查 kit,
22
+ * 未使用的类型参数必须锚定)。 */
23
+ readonly __id?: ID;
24
+ /** 跨资源失效声明(纯数据);默认与命令执行的 ctx scope 相交 */
25
+ invalidates?: ReadonlyArray<QuerySelector>;
26
+ /** 结果覆写钩子:命令作者裁决协议层状态的领域语义(delete 已删 → 幂等 ok 等) */
27
+ treatAs?: Partial<Record<"not-found" | "forbidden", CommandResult<Payload>>>;
28
+ /**
29
+ * @internal 归属资源名——defineResource 注入;缺省 invalidates 时的默认失效目标
30
+ * (内置动词同资源失效,跨资源声明走 invalidates)。
31
+ */
32
+ __resource?: ResourceName;
33
+ }
34
+
35
+ export interface SubscriptionDescriptor<Params, Frame> {
36
+ open(params: Params, ctx: ResourceScope): Transport<Frame>;
37
+ /** 流属性默认在资源侧声明;调用点可覆盖 */
38
+ policy?: SubscriptionPolicy;
39
+ }
40
+
41
+ export type CommandsRecord<ID> = Record<string, CommandDescriptor<any, any, ID>>;
42
+
43
+ /** defineResource 注入 __resource 后的命令映射(逐键保留具体 Input/Payload 类型) */
44
+ export type AttachedCommands<C> = {
45
+ [K in keyof C]: C[K] & { __resource?: ResourceName };
46
+ };
47
+ export type SubsRecord = Record<string, SubscriptionDescriptor<any, any>>;
48
+
49
+ export interface ResourceSpec<
50
+ T,
51
+ ID = string,
52
+ C extends CommandsRecord<ID> = Record<string, never>,
53
+ S extends SubsRecord = Record<string, never>,
54
+ > {
55
+ name: string;
56
+ /** 可选——command-only/流式资源无 id 语义;DataTable/绑定的 getRowId 显式传入 */
57
+ getId?: (row: T) => ID;
58
+ /** scope 键名(如 ["projectId"]),scoped() 约定用 */
59
+ scope?: ReadonlyArray<string>;
60
+
61
+ collection?: (
62
+ q: CollectionQuery,
63
+ ctx: ResourceScope,
64
+ signal?: AbortSignal,
65
+ ) => Promise<Segment<T>>;
66
+ record?: (id: ID, ctx: ResourceScope, signal?: AbortSignal) => Promise<T>;
67
+ batch?: (
68
+ ids: ReadonlyArray<ID>,
69
+ ctx: ResourceScope,
70
+ signal?: AbortSignal,
71
+ ) => Promise<ReadonlyArray<T>>;
72
+
73
+ commands?: C;
74
+ subscriptions?: S;
75
+ }
76
+
77
+ export interface Resource<
78
+ T,
79
+ ID = string,
80
+ C extends CommandsRecord<ID> = Record<string, never>,
81
+ S extends SubsRecord = Record<string, never>,
82
+ > {
83
+ readonly name: ResourceName;
84
+ readonly getId?: (row: T) => ID;
85
+ readonly scope?: ReadonlyArray<string>;
86
+
87
+ readonly collection?: (
88
+ q: CollectionQuery,
89
+ ctx: ResourceScope,
90
+ signal?: AbortSignal,
91
+ ) => Promise<Segment<T>>;
92
+ readonly record?: (id: ID, ctx: ResourceScope, signal?: AbortSignal) => Promise<T>;
93
+ readonly batch?: (
94
+ ids: ReadonlyArray<ID>,
95
+ ctx: ResourceScope,
96
+ signal?: AbortSignal,
97
+ ) => Promise<ReadonlyArray<T>>;
98
+
99
+ readonly commands?: AttachedCommands<C>;
100
+ readonly subscriptions?: S;
101
+ /** scoped() 注入的作用域值(内部约定,勿直接读写) */
102
+ readonly __scopeValues?: ResourceScope;
103
+ }
104
+
105
+ /**
106
+ * 定义一个实体/流族的交互面(三原语各自可选)。
107
+ * 泛型 C/S 逐键保留具体 CommandDescriptor/SubscriptionDescriptor——不被擦成 any。
108
+ */
109
+ export function defineResource<
110
+ T,
111
+ ID,
112
+ C extends CommandsRecord<ID> = Record<string, never>,
113
+ S extends SubsRecord = Record<string, never>,
114
+ >(spec: ResourceSpec<T, ID, C, S>): Resource<T, ID, C, S> {
115
+ if (!/^[a-z][a-z0-9-]*$/.test(spec.name)) {
116
+ throw new Error(
117
+ `fleet-ui: resource name "${spec.name}" must be kebab-case (it lives in query keys and URL diagnostics)`,
118
+ );
119
+ }
120
+ const commands = spec.commands
121
+ ? (Object.fromEntries(
122
+ Object.entries(spec.commands).map(([verb, descriptor]) => [
123
+ verb,
124
+ { ...descriptor, __resource: spec.name as ResourceName },
125
+ ]),
126
+ ) as AttachedCommands<C>)
127
+ : undefined;
128
+ return { ...spec, commands, name: spec.name as ResourceName };
129
+ }
130
+
131
+ /**
132
+ * runCommand —— 命令执行的单源入口(绑定层与命令式编排共用)。
133
+ * treatAs 在此裁决:协议层失败(not-found/forbidden)被命令作者覆写为结果分支;
134
+ * 其余失败(网络/401/5xx)原样出界走错误通道。
135
+ */
136
+ export async function runCommand<Input, Payload, ID>(
137
+ descriptor: CommandDescriptor<Input, Payload, ID>,
138
+ input: Input,
139
+ ctx: ResourceScope,
140
+ ): Promise<CommandResult<Payload>> {
141
+ try {
142
+ return await descriptor.run(input, ctx);
143
+ } catch (error) {
144
+ if (isApiError(error)) {
145
+ const remap = descriptor.treatAs?.[error.kind as "not-found" | "forbidden"];
146
+ if (remap) return remap;
147
+ }
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * scoped 约定(派生,非框架轴):queryKey 嵌入 scope 段 + 适配器 ctx 注入。
154
+ * 适配器可在 http 实例上把 ctx 注入 header(torchwood X-Torchwood-Project)。
155
+ */
156
+ export function scoped<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
157
+ resource: Resource<T, ID, C, S>,
158
+ values: ResourceScope,
159
+ ): Resource<T, ID, C, S> {
160
+ return {
161
+ ...resource,
162
+ __scopeValues: { ...(resource.__scopeValues ?? {}), ...values },
163
+ };
164
+ }
165
+
166
+ export function scopeOf<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
167
+ resource: Resource<T, ID, C, S>,
168
+ ): ResourceScope {
169
+ return resource.__scopeValues ?? {};
170
+ }
171
+
172
+ // ============ 失效选择器(kernel 纯数据;执行在绑定层) ============
173
+
174
+ export interface QuerySelector {
175
+ resource: ResourceName;
176
+ /** 缺省 = 该资源全部查询形态 */
177
+ kind?: "collection" | "record" | "batch";
178
+ id?: unknown;
179
+ /** 显式跨作用域失效(罕见;默认与命令 ctx scope 相交) */
180
+ allScopes?: boolean;
181
+ }