@lynx-go/ui-kit 0.0.0-stage → 0.1.1

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,102 @@
1
+ import type { CommandsRecord, SubsRecord } from "../kernel";
2
+ import {
3
+ batchKey,
4
+ type CollectionQuery,
5
+ collectionKey,
6
+ type Resource,
7
+ type ResourceScope,
8
+ recordKey,
9
+ type Segment,
10
+ scopeOf,
11
+ } from "../kernel";
12
+
13
+ /**
14
+ * /react —— 绑定(把契约接到 TanStack 引擎与 URL)。
15
+ * 目录级禁 import effect(layer-check 强制);公开签名零 effect 泄漏。
16
+ */
17
+
18
+ export interface QueryEngineOptions {
19
+ staleTime?: number;
20
+ refetchInterval?: number | false;
21
+ enabled?: boolean;
22
+ }
23
+
24
+ /** queryOptions 工厂:queryKey 单源;hooks 与 route loader(ensureQueryData/Link preload)共用。 */
25
+ export function collectionQueryOptions<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
26
+ resource: Resource<T, ID, C, S>,
27
+ query: CollectionQuery,
28
+ options: QueryEngineOptions = {},
29
+ ): {
30
+ queryKey: readonly unknown[];
31
+ queryFn: (ctx: { signal: AbortSignal }) => Promise<Segment<T>>;
32
+ staleTime?: number;
33
+ refetchInterval?: number | false;
34
+ enabled?: boolean;
35
+ } {
36
+ const collection = resource.collection;
37
+ if (!collection) {
38
+ throw new Error(
39
+ `fleet-ui: resource "${String(resource.name)}" has no collection query — did you mean to wrap it with clientCollection()?`,
40
+ );
41
+ }
42
+ return {
43
+ queryKey: collectionKey(resource, query),
44
+ queryFn: ({ signal }) => collection(query, scopeOf(resource), signal),
45
+ ...options,
46
+ };
47
+ }
48
+
49
+ export function recordQueryOptions<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
50
+ resource: Resource<T, ID, C, S>,
51
+ id: ID | undefined,
52
+ options: QueryEngineOptions = {},
53
+ ): {
54
+ queryKey: readonly unknown[];
55
+ queryFn: (ctx: { signal: AbortSignal }) => Promise<T>;
56
+ staleTime?: number;
57
+ refetchInterval?: number | false;
58
+ enabled?: boolean;
59
+ } {
60
+ const record = resource.record;
61
+ if (!record) {
62
+ throw new Error(`fleet-ui: resource "${String(resource.name)}" has no record query`);
63
+ }
64
+ if (id === undefined)
65
+ return {
66
+ ...options,
67
+ queryKey: ["record", resource.name, "none"],
68
+ queryFn: () => Promise.reject(new Error("unreachable")),
69
+ enabled: false,
70
+ };
71
+ const scope = scopeOf(resource);
72
+ return {
73
+ queryKey: recordKey(resource, id),
74
+ queryFn: ({ signal }) => record(id, scope, signal),
75
+ ...options,
76
+ };
77
+ }
78
+
79
+ export function batchQueryOptions<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
80
+ resource: Resource<T, ID, C, S>,
81
+ ids: ReadonlyArray<ID>,
82
+ options: QueryEngineOptions = {},
83
+ ): {
84
+ queryKey: readonly unknown[];
85
+ queryFn: (ctx: { signal: AbortSignal }) => Promise<ReadonlyArray<T>>;
86
+ staleTime?: number;
87
+ refetchInterval?: number | false;
88
+ enabled?: boolean;
89
+ } {
90
+ const batch = resource.batch;
91
+ if (!batch) {
92
+ throw new Error(`fleet-ui: resource "${String(resource.name)}" has no batch query`);
93
+ }
94
+ const scope = scopeOf(resource);
95
+ return {
96
+ queryKey: batchKey(resource, ids),
97
+ queryFn: ({ signal }) => batch(ids, scope, signal),
98
+ ...options,
99
+ };
100
+ }
101
+
102
+ export type { ResourceScope };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useCan —— 权限门控(design.md §3 派生表:identity record Query + 纯谓词)。
3
+ *
4
+ * - fail-closed:identity 未加载(loading→pending)或加载失败(error→denied)都不放行;
5
+ * - "未就绪"(pending)与"拒绝"(denied)是两个可渲染态——pending 渲染占位而非敏感内容;
6
+ * - 页级门控走 beforeLoad(identity 经 loader 预取 + redirect),元素级用本绑定;
7
+ * - 纯推导拆出 permissionState:beforeLoad 侧没有 hook,loader 预取后调同一函数。
8
+ */
9
+
10
+ export type PermissionState = "pending" | "granted" | "denied";
11
+
12
+ /** identity 侧的最小结构面(useRecord 的 RecordHandle 天然满足)。 */
13
+ export interface IdentityLike<T> {
14
+ data: T | undefined;
15
+ status: "loading" | "error" | "success";
16
+ }
17
+
18
+ export function permissionState<T>(
19
+ identity: IdentityLike<T>,
20
+ predicate: (identity: T) => boolean,
21
+ ): PermissionState {
22
+ if (identity.status === "loading") return "pending";
23
+ if (identity.status !== "success" || identity.data === undefined) return "denied";
24
+ return predicate(identity.data) ? "granted" : "denied";
25
+ }
26
+
27
+ export interface CanHandle {
28
+ state: PermissionState;
29
+ /** fail-closed:仅 granted 为 true(pending 不是"暂时允许") */
30
+ allowed: boolean;
31
+ }
32
+
33
+ export function useCan<T>(
34
+ identity: IdentityLike<T>,
35
+ predicate: (identity: T) => boolean,
36
+ ): CanHandle {
37
+ const state = permissionState(identity, predicate);
38
+ return { state, allowed: state === "granted" };
39
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * usePreference —— localStorage + 编解码的通用受控状态(design.md §3 派生表)。
5
+ * useState 形 API;读坏(旧版本残留/手改)回退缺省值,不抛错。
6
+ * SavedQueriesList 等配方后排;双通道作用域同步(URL↔localStorage)是配方层四件套,不在此。
7
+ */
8
+
9
+ export interface PreferenceCodec<T> {
10
+ parse(raw: string): T;
11
+ stringify(value: T): string;
12
+ }
13
+
14
+ const jsonCodec: PreferenceCodec<unknown> = {
15
+ parse: (raw) => JSON.parse(raw) as unknown,
16
+ stringify: (value) => JSON.stringify(value),
17
+ };
18
+
19
+ /** 纯读:raw 缺席或解析失败 → fallback(fail-safe,不进错误通道)。 */
20
+ export function decodePreference<T>(
21
+ raw: string | null | undefined,
22
+ fallback: T,
23
+ codec?: PreferenceCodec<T>,
24
+ ): T {
25
+ if (raw === null || raw === undefined) return fallback;
26
+ try {
27
+ return codec ? codec.parse(raw) : (jsonCodec.parse(raw) as T);
28
+ } catch {
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ function encodePreference<T>(value: T, codec?: PreferenceCodec<T>): string {
34
+ return codec ? codec.stringify(value) : jsonCodec.stringify(value);
35
+ }
36
+
37
+ export function usePreference<T>(
38
+ key: string,
39
+ fallback: T,
40
+ codec?: PreferenceCodec<T>,
41
+ ): readonly [T, (next: T | ((prev: T) => T)) => void] {
42
+ const [value, setValue] = useState<T>(() =>
43
+ typeof window === "undefined"
44
+ ? fallback
45
+ : decodePreference(window.localStorage.getItem(key), fallback, codec),
46
+ );
47
+
48
+ const set = useCallback(
49
+ (next: T | ((prev: T) => T)) => {
50
+ setValue((prev) => {
51
+ const resolved = typeof next === "function" ? (next as (p: T) => T)(prev) : next;
52
+ if (typeof window !== "undefined") {
53
+ try {
54
+ window.localStorage.setItem(key, encodePreference(resolved, codec));
55
+ } catch {
56
+ // 私有模式/配额:偏好是锦上添花,写失败静默
57
+ }
58
+ }
59
+ return resolved;
60
+ });
61
+ },
62
+ [key, codec],
63
+ );
64
+
65
+ return [value, set] as const;
66
+ }
@@ -0,0 +1,123 @@
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { createSubscriptionEngine, type SubscriptionEngine } from "../adapters/subscription";
3
+ import type {
4
+ Cursor,
5
+ ResourceScope,
6
+ SubscriptionDescriptor,
7
+ SubscriptionPolicy,
8
+ Transport,
9
+ } from "../kernel";
10
+
11
+ /**
12
+ * useSubscription —— Subscription 原语的绑定(问/令/听的"听")。
13
+ *
14
+ * /adapters 引擎句柄 + useSyncExternalStore 接线(design.md §5.3 渲染契约):
15
+ * - 本 hook 只订阅**状态存储**(status/error/resyncs)——帧到达不触发页面级重渲染;
16
+ * 帧消费块(LogViewer/EventFeed)自行经 sub.subscribeFrames/getFrames 订阅帧存储;
17
+ * - StrictMode 双挂载幂等:引擎在 effect 内创建/销毁,退避状态与 transport 实例
18
+ * 每代独立;
19
+ * - 锚点语义:cursor 传 undefined = 待命不开(backlog→follow 等定锚);
20
+ * 显式 null = 活尾立即开(日志流)。cursor 值变化 = 重建订阅(清缓冲);
21
+ * gapped 后的原地重锚用 sub.resync(newAnchor)(resyncs++ 供黄条),不要换 prop。
22
+ *
23
+ * descriptor | transport 双形态(§2.4):descriptor(Resource.subscriptions 条目)须声明
24
+ * policy——流属性默认在资源侧声明,兼作运行时判别;裸 Transport 直用(全局流/日志流)。
25
+ */
26
+
27
+ export interface UseSubscriptionOptions<Params, F> {
28
+ /** descriptor 形态的参数(params 变化 = 重建订阅) */
29
+ params?: Params;
30
+ scope?: ResourceScope;
31
+ /** 覆盖 descriptor.policy(policy 锚定在创建时;中途变更请重建订阅) */
32
+ policy?: SubscriptionPolicy;
33
+ /** 初始锚点;undefined = 待命(backlog 等定锚);null = 活尾 */
34
+ cursor?: Cursor | null;
35
+ /** 帧游标投影(重连/续订恢复点);日志流无游标语义可不传 */
36
+ getCursor?: (frame: F) => Cursor | undefined;
37
+ onFrame?: (frame: F) => void;
38
+ /** 缺省:cursor 已定(含显式 null)即开;false = 完全手动(命令式消费) */
39
+ autoStart?: boolean;
40
+ }
41
+
42
+ const IDLE_STATE = { status: "reconnecting", error: null, resyncs: 0 } as const;
43
+ const NO_FRAMES: ReadonlyArray<never> = [];
44
+ const noop = () => undefined;
45
+
46
+ function idleEngine<F>(): SubscriptionEngine<F> {
47
+ return {
48
+ frames: NO_FRAMES,
49
+ status: "reconnecting",
50
+ error: null,
51
+ resyncs: 0,
52
+ start: noop,
53
+ stop: noop,
54
+ pause: noop,
55
+ resume: noop,
56
+ restart: noop,
57
+ resync: noop,
58
+ clear: noop,
59
+ onFrame: () => noop,
60
+ subscribeStatus: () => noop,
61
+ getState: () => IDLE_STATE,
62
+ subscribeFrames: () => noop,
63
+ getFrames: () => NO_FRAMES as ReadonlyArray<F>,
64
+ };
65
+ }
66
+
67
+ export function useSubscription<Params, F>(
68
+ source: Transport<F> | SubscriptionDescriptor<Params, F>,
69
+ options: UseSubscriptionOptions<Params, F> = {},
70
+ ): SubscriptionEngine<F> {
71
+ // 运行时判别:descriptor 声明 policy(资源侧流属性约定,§2.3);裸 Transport 无 policy
72
+ const descriptor = source as SubscriptionDescriptor<Params, F>;
73
+ const isDescriptor =
74
+ typeof (source as { policy?: unknown }).policy === "object" &&
75
+ typeof source.open === "function";
76
+ const paramsKey = options.params === undefined ? "" : JSON.stringify(options.params);
77
+ const scopeKey = JSON.stringify(options.scope ?? {});
78
+ const policy = options.policy ?? descriptor.policy;
79
+ const policyKey = JSON.stringify(policy ?? null);
80
+ const anchorKey = options.cursor === undefined ? "\u0000waiting" : `\u0000${options.cursor}`;
81
+
82
+ // biome-ignore lint/correctness/useExhaustiveDependencies: descriptor.open/options.* 由 paramsKey/scopeKey 记忆——params 对象标识每渲染变化不应重开流
83
+ const transport = useMemo<Transport<F>>(() => {
84
+ if (!isDescriptor) return source as Transport<F>;
85
+ return descriptor.open(options.params as Params, options.scope ?? {});
86
+ }, [source, isDescriptor, paramsKey, scopeKey]);
87
+
88
+ const [engine, setEngine] = useState<SubscriptionEngine<F> | null>(null);
89
+
90
+ // onFrame/getCursor 经 ref 透传(回调标识每渲染变化不应重建订阅;getCursor 是纯投影)
91
+ const onFrameRef = useRef(options.onFrame);
92
+ onFrameRef.current = options.onFrame;
93
+ const getCursorRef = useRef(options.getCursor);
94
+ getCursorRef.current = options.getCursor;
95
+
96
+ // biome-ignore lint/correctness/useExhaustiveDependencies: cursor/policy/autoStart 经 anchorKey/policyKey 锚定——引擎重建键 = transport × 锚点 × 策略(创建时值)
97
+ useEffect(() => {
98
+ const created = createSubscriptionEngine<F>({
99
+ transport,
100
+ cursor: options.cursor ?? null,
101
+ getCursor: (frame) => getCursorRef.current?.(frame),
102
+ ...(policy ? { policy } : {}),
103
+ onFrame: (frame) => onFrameRef.current?.(frame),
104
+ // cursor 未定 = 待命;已定(含显式 null 活尾)即开
105
+ autoStart: options.autoStart ?? options.cursor !== undefined,
106
+ });
107
+ setEngine(created);
108
+ return () => {
109
+ created.stop();
110
+ };
111
+ }, [transport, anchorKey, policyKey]);
112
+
113
+ const idle = useMemo(() => idleEngine<F>(), []);
114
+
115
+ // 页面级只订状态;帧订阅在块内(渲染契约)
116
+ useSyncExternalStore(
117
+ engine ? engine.subscribeStatus : idle.subscribeStatus,
118
+ engine ? engine.getState : idle.getState,
119
+ idle.getState,
120
+ );
121
+
122
+ return engine ?? idle;
123
+ }
@@ -0,0 +1,290 @@
1
+ import {
2
+ type CollectionQuery,
3
+ type Operator,
4
+ type Order,
5
+ type Predicate,
6
+ type QueryWindow,
7
+ textPredicate,
8
+ } from "../kernel";
9
+
10
+ /**
11
+ * /schema —— CollectionQuery 的 URL 编解码(Effect Schema 单源的公开面)。
12
+ *
13
+ * 对外只暴露纯函数 + Standard Schema 包装:消费方可自选 schema 库,
14
+ * 与自有 search 参数在纯对象层合并(validateSearch 接受纯函数)。
15
+ * URL 键名定案:q / filter=field:op:value(重复键)/ sort=field:dir / cursor / page / size。
16
+ */
17
+
18
+ export interface FilterFieldDef {
19
+ field: string;
20
+ /** 缺省 "eq" */
21
+ op?: Operator;
22
+ label?: string;
23
+ options?: ReadonlyArray<{ value: string; label: string }>;
24
+ }
25
+
26
+ export interface QuerySchemaSpec {
27
+ /** q 参与文本搜索的字段白名单 */
28
+ text?: ReadonlyArray<string>;
29
+ filters?: ReadonlyArray<FilterFieldDef>;
30
+ /** 默认 20 */
31
+ defaultSize?: number;
32
+ defaultOrder?: ReadonlyArray<Order>;
33
+ }
34
+
35
+ /** URL 中的扁平参数形状(human-readable)。 */
36
+ export interface FlatSearchParams {
37
+ q?: string;
38
+ filter?: ReadonlyArray<string>;
39
+ sort?: ReadonlyArray<string>;
40
+ cursor?: string;
41
+ page?: number;
42
+ size?: number;
43
+ }
44
+
45
+ export type FlatPatch = Partial<
46
+ Record<string, string | number | ReadonlyArray<string> | undefined>
47
+ >;
48
+
49
+ /** Standard Schema v1 的最小结构形状(TanStack Router validateSearch 直收)。 */
50
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
51
+ "~standard": {
52
+ version: 1;
53
+ vendor: "fleet-ui";
54
+ validate(value: Input): { value: Output };
55
+ };
56
+ }
57
+
58
+ const OPERATORS = new Set<string>([
59
+ "eq",
60
+ "ne",
61
+ "contains",
62
+ "in",
63
+ "gt",
64
+ "lt",
65
+ "gte",
66
+ "lte",
67
+ "exists",
68
+ ]);
69
+
70
+ export interface QuerySearchSchema {
71
+ toSearchSchema(): StandardSchemaV1<unknown, FlatSearchParams>;
72
+ encode(query: CollectionQuery): FlatSearchParams;
73
+ decode(params: unknown): CollectionQuery;
74
+ patch(part: {
75
+ predicate?: Predicate | null;
76
+ order?: ReadonlyArray<Order> | null;
77
+ window?: QueryWindow;
78
+ }): FlatPatch;
79
+ readonly defaults: CollectionQuery;
80
+ readonly spec: QuerySchemaSpec;
81
+ }
82
+
83
+ export function collectionQuerySchema(spec: QuerySchemaSpec = {}): QuerySearchSchema {
84
+ const defaultSize = spec.defaultSize ?? 20;
85
+ const defaults: CollectionQuery = {
86
+ window: { size: defaultSize },
87
+ ...(spec.defaultOrder ? { order: spec.defaultOrder } : {}),
88
+ };
89
+
90
+ // ---- 谓词 <-> 扁平键 ----
91
+
92
+ function encodePredicate(predicate: Predicate | undefined): {
93
+ q?: string;
94
+ filter?: ReadonlyArray<string>;
95
+ } {
96
+ if (predicate === undefined) return {};
97
+ const textSet = new Set(spec.text ?? []);
98
+ const filters: string[] = [];
99
+ let q: string | undefined;
100
+ for (const leaf of flattenLeaves(predicate)) {
101
+ if (
102
+ leaf.op === "contains" &&
103
+ textSet.has(leaf.field as string) &&
104
+ typeof leaf.value === "string"
105
+ ) {
106
+ q = q === undefined ? leaf.value : q; // 同值多字段(textPredicate 的 any 形)
107
+ continue;
108
+ }
109
+ const value = Array.isArray(leaf.value) ? leaf.value.join(",") : String(leaf.value);
110
+ filters.push(`${leaf.field}:${leaf.op}:${value}`);
111
+ }
112
+ return { ...(q !== undefined ? { q } : {}), ...(filters.length ? { filter: filters } : {}) };
113
+ }
114
+
115
+ function flattenLeaves(p: Predicate): ReadonlyArray<Extract<Predicate, { field: string }>> {
116
+ if ("field" in p) return [p];
117
+ if ("all" in p) return p.all.flatMap(flattenLeaves);
118
+ // any 只允许 textPredicate 产生的纯文本形;一般 OR 树不进 URL(消费方自定义参数)
119
+ const leaves = p.any.flatMap(flattenLeaves);
120
+ const textSet = new Set(spec.text ?? []);
121
+ const isText = leaves.every(
122
+ (l) => l.op === "contains" && textSet.has(l.field as string) && typeof l.value === "string",
123
+ );
124
+ if (!isText) {
125
+ throw new Error(
126
+ "fleet-ui: predicate with OR is not URL-serializable — keep it in local state or a custom search param",
127
+ );
128
+ }
129
+ return leaves;
130
+ }
131
+
132
+ function parseFilterEntry(entry: string): Extract<Predicate, { field: string }> | null {
133
+ const i1 = entry.indexOf(":");
134
+ const i2 = entry.indexOf(":", i1 + 1);
135
+ if (i1 < 0 || i2 < 0) return null;
136
+ const field = entry.slice(0, i1);
137
+ const op = entry.slice(i1 + 1, i2);
138
+ if (!OPERATORS.has(op)) return null;
139
+ const rawValue = entry.slice(i2 + 1);
140
+ const value = op === "in" ? rawValue.split(",").filter(Boolean) : rawValue;
141
+ return { field, op: op as Operator, value };
142
+ }
143
+
144
+ function decodePredicate(params: FlatSearchParams): Predicate | undefined {
145
+ const parts: Predicate[] = [];
146
+ if (params.q !== undefined) {
147
+ const text = textPredicate(spec.text ?? [], params.q);
148
+ if (text) parts.push(text);
149
+ }
150
+ for (const entry of params.filter ?? []) {
151
+ const leaf = parseFilterEntry(entry);
152
+ if (leaf) parts.push(leaf);
153
+ }
154
+ if (parts.length === 0) return undefined;
155
+ if (parts.length === 1) return parts[0];
156
+ return { all: parts };
157
+ }
158
+
159
+ // ---- 窗口 <-> 键 ----
160
+
161
+ function decodeWindow(params: FlatSearchParams): QueryWindow {
162
+ const size = positiveInt(params.size) ?? defaultSize;
163
+ if (params.cursor !== undefined && params.cursor !== "") {
164
+ return { size, cursor: params.cursor };
165
+ }
166
+ const page = positiveInt(params.page);
167
+ if (page !== undefined && page > 1) {
168
+ return { size, range: { offset: (page - 1) * size } };
169
+ }
170
+ return { size };
171
+ }
172
+
173
+ // ---- 归一化(validateSearch 用;容忍脏输入,绝不 throw) ----
174
+
175
+ function normalize(input: unknown): FlatSearchParams {
176
+ const raw = (input ?? {}) as Record<string, unknown>;
177
+ const out: FlatSearchParams = {};
178
+ if (typeof raw.q === "string" && raw.q.trim() !== "") out.q = raw.q;
179
+ const filter = toArray(raw.filter).filter((e) => parseFilterEntry(e) !== null);
180
+ if (filter.length > 0) out.filter = filter;
181
+ const sort = toArray(raw.sort).filter((e) => {
182
+ const i = e.indexOf(":");
183
+ if (i < 0) return false;
184
+ return e.slice(i + 1) === "asc" || e.slice(i + 1) === "desc";
185
+ });
186
+ if (sort.length > 0) out.sort = sort;
187
+ if (typeof raw.cursor === "string" && raw.cursor !== "") out.cursor = raw.cursor;
188
+ const page = positiveInt(raw.page);
189
+ if (page !== undefined && page > 1) out.page = page;
190
+ const size = positiveInt(raw.size);
191
+ if (size !== undefined && size !== defaultSize) out.size = size;
192
+ return out;
193
+ }
194
+
195
+ function decodeOrders(params: FlatSearchParams): ReadonlyArray<Order> | undefined {
196
+ const orders = (params.sort ?? [])
197
+ .map((e) => {
198
+ const i = e.indexOf(":");
199
+ const field = e.slice(0, i);
200
+ const dir = e.slice(i + 1);
201
+ return dir === "asc" || dir === "desc" ? ({ field, dir } as Order) : null;
202
+ })
203
+ .filter((o): o is Order => o !== null);
204
+ return orders.length > 0 ? orders : undefined;
205
+ }
206
+
207
+ return {
208
+ spec,
209
+ defaults,
210
+ toSearchSchema() {
211
+ return {
212
+ "~standard": {
213
+ version: 1,
214
+ vendor: "fleet-ui",
215
+ validate(value: unknown) {
216
+ return { value: normalize(value) };
217
+ },
218
+ },
219
+ };
220
+ },
221
+ encode(query: CollectionQuery): FlatSearchParams {
222
+ const flat: FlatSearchParams = { ...encodePredicate(query.predicate) };
223
+ if (query.order?.length) flat.sort = query.order.map((o) => `${o.field}:${o.dir}`);
224
+ const w = query.window;
225
+ flat.size = w.size;
226
+ if (w.cursor !== undefined) flat.cursor = w.cursor;
227
+ else if (w.range && w.range.offset > 0) flat.page = Math.floor(w.range.offset / w.size) + 1;
228
+ return flat;
229
+ },
230
+ decode(params: unknown): CollectionQuery {
231
+ const flat = normalize(params);
232
+ // "URL + schema 默认值即真相":URL 无 sort 时落 defaultOrder(不落会静默丢默认排序)
233
+ const order = decodeOrders(flat) ?? spec.defaultOrder;
234
+ return {
235
+ ...(decodePredicate(flat) !== undefined ? { predicate: decodePredicate(flat) } : {}),
236
+ ...(order ? { order } : {}),
237
+ window: decodeWindow(flat),
238
+ };
239
+ },
240
+ patch(part): FlatPatch {
241
+ const out: FlatPatch = {};
242
+ if ("predicate" in part) {
243
+ // 清除时必须显式 q/filter = undefined:函数式 search 是 {...prev, ...patch}
244
+ // 展开,缺席的键会让旧值存活(Clear 后 URL 残留 q——fleetly 试点实咬)。
245
+ const flat = encodePredicate(part.predicate ?? undefined);
246
+ out.q = flat.q;
247
+ out.filter = flat.filter;
248
+ out.cursor = undefined; // 换谓词回第一页
249
+ out.page = undefined;
250
+ }
251
+ if ("order" in part) {
252
+ const order = part.order ?? [];
253
+ if (order.length > 0) out.sort = order.map((o) => `${o.field}:${o.dir}`);
254
+ else out.sort = undefined;
255
+ out.cursor = undefined; // 换向回第一页(keyset 游标与方向耦合)
256
+ out.page = undefined;
257
+ }
258
+ if (part.window) {
259
+ const w = part.window;
260
+ if ("size" in w) out.size = w.size;
261
+ if (w.cursor !== undefined) {
262
+ out.cursor = w.cursor;
263
+ out.page = undefined;
264
+ } else if (w.range) {
265
+ out.page = Math.floor(w.range.offset / w.size) + 1;
266
+ out.cursor = undefined;
267
+ }
268
+ }
269
+ return out;
270
+ },
271
+ };
272
+ }
273
+
274
+ // ---- 工具 ----
275
+
276
+ function toArray(value: unknown): ReadonlyArray<string> {
277
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
278
+ if (typeof value === "string" && value !== "") return [value];
279
+ return [];
280
+ }
281
+
282
+ function positiveInt(value: unknown): number | undefined {
283
+ const n =
284
+ typeof value === "number"
285
+ ? value
286
+ : typeof value === "string"
287
+ ? Number.parseInt(value, 10)
288
+ : NaN;
289
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
290
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ collectionQuerySchema,
3
+ type FilterFieldDef,
4
+ type FlatPatch,
5
+ type FlatSearchParams,
6
+ type QuerySchemaSpec,
7
+ type QuerySearchSchema,
8
+ type StandardSchemaV1,
9
+ } from "./codec";
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.