@lotics/app-sdk 0.100.1 → 0.101.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/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31309 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +77 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +135 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transport-layer helpers for the SDK upload pipeline.
|
|
3
|
-
*
|
|
4
|
-
* Two concerns this module owns:
|
|
5
|
-
*
|
|
6
|
-
* 1. **Per-request timeout.** A bare `fetch` hangs forever if the network is
|
|
7
|
-
* unreachable; we wrap it in an `AbortController` so a stuck request fails
|
|
8
|
-
* after `UPLOAD_TIMEOUT_MS` instead of leaving the user staring at a
|
|
9
|
-
* spinner.
|
|
10
|
-
*
|
|
11
|
-
* 2. **Retry with exponential backoff** for the presigned `PUT` to object
|
|
12
|
-
* storage — the single most failure-prone step on mobile networks. We
|
|
13
|
-
* retry on network errors / 5xx / timeouts up to `UPLOAD_PUT_MAX_ATTEMPTS`
|
|
14
|
-
* with 1s → 2s → 4s waits between attempts. We do NOT retry on 4xx
|
|
15
|
-
* (client error, retrying won't help) or on caller-initiated aborts.
|
|
16
|
-
*
|
|
17
|
-
* Mirrors the conventions in `frontend/lib/api_utils.ts` and
|
|
18
|
-
* `frontend/lib/file_upload.ts`, simplified for the SDK (no logger, no
|
|
19
|
-
* correlation headers).
|
|
20
|
-
*/
|
|
21
|
-
export const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
22
|
-
const UPLOAD_PUT_MAX_ATTEMPTS = 3;
|
|
23
|
-
const UPLOAD_PUT_BACKOFF_BASE_MS = 1000;
|
|
24
|
-
export class UploadTimeoutError extends Error {
|
|
25
|
-
url;
|
|
26
|
-
timeoutMs;
|
|
27
|
-
name = "UploadTimeoutError";
|
|
28
|
-
constructor(url, timeoutMs) {
|
|
29
|
-
super(`Upload request to ${url} timed out after ${timeoutMs}ms`);
|
|
30
|
-
this.url = url;
|
|
31
|
-
this.timeoutMs = timeoutMs;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
export class UploadAbortedError extends Error {
|
|
35
|
-
name = "UploadAbortedError";
|
|
36
|
-
constructor() {
|
|
37
|
-
super("Upload aborted");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** `fetch` with timeout + optional external `AbortSignal`. */
|
|
41
|
-
export async function fetchWithTimeout(url, init, timeoutMs) {
|
|
42
|
-
const controller = new AbortController();
|
|
43
|
-
let didTimeout = false;
|
|
44
|
-
const timeoutId = setTimeout(() => {
|
|
45
|
-
didTimeout = true;
|
|
46
|
-
controller.abort();
|
|
47
|
-
}, timeoutMs);
|
|
48
|
-
if (init.signal) {
|
|
49
|
-
if (init.signal.aborted) {
|
|
50
|
-
clearTimeout(timeoutId);
|
|
51
|
-
throw new UploadAbortedError();
|
|
52
|
-
}
|
|
53
|
-
init.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
if (didTimeout)
|
|
60
|
-
throw new UploadTimeoutError(url, timeoutMs);
|
|
61
|
-
if (init.signal?.aborted)
|
|
62
|
-
throw new UploadAbortedError();
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
65
|
-
finally {
|
|
66
|
-
clearTimeout(timeoutId);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Presigned-PUT to object storage with retry + backoff.
|
|
71
|
-
*
|
|
72
|
-
* Retries network errors, timeouts, and 5xx responses up to
|
|
73
|
-
* `UPLOAD_PUT_MAX_ATTEMPTS` times. A 4xx response is returned to the caller
|
|
74
|
-
* unchanged (retrying a 403/400 won't help; the caller decides). Aborts
|
|
75
|
-
* propagate immediately without retry.
|
|
76
|
-
*/
|
|
77
|
-
export async function putToStorageWithRetry(uploadUrl, file, signal) {
|
|
78
|
-
let lastError;
|
|
79
|
-
for (let attempt = 1; attempt <= UPLOAD_PUT_MAX_ATTEMPTS; attempt += 1) {
|
|
80
|
-
try {
|
|
81
|
-
const response = await fetchWithTimeout(uploadUrl, {
|
|
82
|
-
method: "PUT",
|
|
83
|
-
headers: { "Content-Type": file.type },
|
|
84
|
-
body: file,
|
|
85
|
-
signal,
|
|
86
|
-
}, UPLOAD_TIMEOUT_MS);
|
|
87
|
-
if (response.ok)
|
|
88
|
-
return response;
|
|
89
|
-
// 4xx is terminal — retrying a malformed/expired URL is pointless.
|
|
90
|
-
if (response.status >= 400 && response.status < 500)
|
|
91
|
-
return response;
|
|
92
|
-
// 5xx — fall through to retry.
|
|
93
|
-
lastError = new Error(`Storage PUT returned ${response.status}`);
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
// Caller-initiated abort: stop immediately.
|
|
97
|
-
if (err instanceof UploadAbortedError)
|
|
98
|
-
throw err;
|
|
99
|
-
lastError = err;
|
|
100
|
-
}
|
|
101
|
-
if (attempt < UPLOAD_PUT_MAX_ATTEMPTS) {
|
|
102
|
-
const delayMs = UPLOAD_PUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
103
|
-
await sleep(delayMs, signal);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
throw lastError instanceof Error ? lastError : new Error("Storage PUT failed");
|
|
107
|
-
}
|
|
108
|
-
function sleep(ms, signal) {
|
|
109
|
-
return new Promise((resolve, reject) => {
|
|
110
|
-
if (signal?.aborted) {
|
|
111
|
-
reject(new UploadAbortedError());
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const timeoutId = setTimeout(() => {
|
|
115
|
-
cleanup();
|
|
116
|
-
resolve();
|
|
117
|
-
}, ms);
|
|
118
|
-
const cleanup = () => {
|
|
119
|
-
clearTimeout(timeoutId);
|
|
120
|
-
signal?.removeEventListener("abort", onAbort);
|
|
121
|
-
};
|
|
122
|
-
const onAbort = () => {
|
|
123
|
-
cleanup();
|
|
124
|
-
reject(new UploadAbortedError());
|
|
125
|
-
};
|
|
126
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
127
|
-
});
|
|
128
|
-
}
|
package/dist/src/url_params.d.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Typed codecs that map URL query-string values (always strings, or string
|
|
3
|
-
* arrays for repeated keys) to and from the typed view-state an app keeps in
|
|
4
|
-
* the address bar — the engine behind `useUrlState`.
|
|
5
|
-
*
|
|
6
|
-
* Two properties make the URLs clean and the round-trip lossless:
|
|
7
|
-
*
|
|
8
|
-
* - **Default-omission.** A codec built with `.withDefault(d)` encodes the
|
|
9
|
-
* default value to `undefined` — i.e. the key is dropped from the URL. So a
|
|
10
|
-
* filter at its default (`page=1`, `q=""`) never appears, and the shared
|
|
11
|
-
* link carries only what the user actually changed.
|
|
12
|
-
* - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
|
|
13
|
-
* app declared. Every other query param (`lotics_host`, `__mock`, a param a
|
|
14
|
-
* second `useUrlState` owns, a future host param) is read past and preserved
|
|
15
|
-
* on write — the merge is the namespace boundary, so no reserved-prefix rule
|
|
16
|
-
* is needed.
|
|
17
|
-
*
|
|
18
|
-
* Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
|
|
19
|
-
* so it carries no workspace deps (no `@lotics/shared`). These are plain pure
|
|
20
|
-
* functions — a frontend that later wants the same value⇄query codec can import
|
|
21
|
-
* them from here rather than growing a second copy.
|
|
22
|
-
*/
|
|
23
|
-
/** A query value as it appears in the URL: a single string, or an array for a
|
|
24
|
-
* repeated key (`?tag=a&tag=b`). Absent keys are simply missing from the map. */
|
|
25
|
-
export type UrlParamValue = string | string[];
|
|
26
|
-
/** The current query string decoded to a map (present keys only). */
|
|
27
|
-
export type UrlParams = Record<string, UrlParamValue>;
|
|
28
|
-
/** A write: each key set to its encoded value, or `undefined` to clear it. Only
|
|
29
|
-
* the keys present are touched; others in the URL are preserved (a merge). */
|
|
30
|
-
export type UrlParamsPatch = Record<string, UrlParamValue | undefined>;
|
|
31
|
-
/**
|
|
32
|
-
* A reversible mapping between a typed value `T` and its URL representation.
|
|
33
|
-
* Methods (not function-typed properties) so a concrete `UrlParamCodec<string>`
|
|
34
|
-
* stays assignable to `UrlParamCodec<unknown>` for the `useUrlState` codec map.
|
|
35
|
-
*/
|
|
36
|
-
export interface UrlParamCodec<T> {
|
|
37
|
-
/** Raw query value (or `undefined` when the key is absent) → typed value. */
|
|
38
|
-
decode(raw: UrlParamValue | undefined): T;
|
|
39
|
-
/** Typed value → raw query value, or `undefined` to omit the key. */
|
|
40
|
-
encode(value: T): UrlParamValue | undefined;
|
|
41
|
-
}
|
|
42
|
-
/** A codec whose value is optional (absent key → `undefined`), refinable to a
|
|
43
|
-
* required codec with a fallback via `.withDefault`. */
|
|
44
|
-
export interface OptionalUrlParamCodec<T> extends UrlParamCodec<T | undefined> {
|
|
45
|
-
/** Make the value required: an absent key decodes to `fallback`, and a value
|
|
46
|
-
* equal to `fallback` encodes to nothing (kept out of the URL). */
|
|
47
|
-
withDefault(fallback: T): UrlParamCodec<T>;
|
|
48
|
-
}
|
|
49
|
-
declare function enumCodec<const V extends readonly string[]>(values: V): OptionalUrlParamCodec<V[number]>;
|
|
50
|
-
declare function arrayOf<T>(inner: UrlParamCodec<T | undefined>): OptionalUrlParamCodec<T[]>;
|
|
51
|
-
/**
|
|
52
|
-
* The codec builders an app composes into a `useUrlState` shape. Each base
|
|
53
|
-
* builder yields an optional codec (absent key → `undefined`); add
|
|
54
|
-
* `.withDefault(v)` to make it required and keep the default out of the URL.
|
|
55
|
-
*
|
|
56
|
-
* useUrlState({
|
|
57
|
-
* q: urlParam.string.withDefault(""),
|
|
58
|
-
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
59
|
-
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
60
|
-
* from: urlParam.isoDate, // optional Date
|
|
61
|
-
* page: urlParam.number.withDefault(1),
|
|
62
|
-
* })
|
|
63
|
-
*/
|
|
64
|
-
export declare const urlParam: {
|
|
65
|
-
readonly string: OptionalUrlParamCodec<string>;
|
|
66
|
-
readonly number: OptionalUrlParamCodec<number>;
|
|
67
|
-
readonly boolean: OptionalUrlParamCodec<boolean>;
|
|
68
|
-
readonly isoDate: OptionalUrlParamCodec<Date>;
|
|
69
|
-
readonly enum: typeof enumCodec;
|
|
70
|
-
readonly arrayOf: typeof arrayOf;
|
|
71
|
-
};
|
|
72
|
-
/** Decode the declared keys out of a params map into typed values. */
|
|
73
|
-
export declare function decodeAll<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, params: UrlParams): {
|
|
74
|
-
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
75
|
-
};
|
|
76
|
-
/** Encode a partial set of declared values into a patch (each key → value or
|
|
77
|
-
* `undefined` to clear). Only the keys present in `values` are emitted, so the
|
|
78
|
-
* write merges and leaves every other query param untouched. */
|
|
79
|
-
export declare function encodePatch<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, values: Partial<{
|
|
80
|
-
[K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
|
|
81
|
-
}>): UrlParamsPatch;
|
|
82
|
-
/** Parse a `location.search` string into a params map (repeated keys → array). */
|
|
83
|
-
export declare function parseSearch(search: string): UrlParams;
|
|
84
|
-
/** Apply a patch to a `location.search` string and return the new query string
|
|
85
|
-
* (no leading `?`). Each patch key is replaced; `undefined` clears it; every
|
|
86
|
-
* other existing param is preserved. */
|
|
87
|
-
export declare function serializeMerge(search: string, patch: UrlParamsPatch): string;
|
|
88
|
-
/** Apply a patch to an in-memory params map (the optimistic local mirror). */
|
|
89
|
-
export declare function applyPatch(params: UrlParams, patch: UrlParamsPatch): UrlParams;
|
|
90
|
-
/** Shallow value-equality over two params maps — used to drop echoed updates so
|
|
91
|
-
* an app's own write doesn't re-render it a second time. */
|
|
92
|
-
export declare function paramsEqual(a: UrlParams, b: UrlParams): boolean;
|
|
93
|
-
export {};
|
package/dist/src/url_params.js
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Typed codecs that map URL query-string values (always strings, or string
|
|
3
|
-
* arrays for repeated keys) to and from the typed view-state an app keeps in
|
|
4
|
-
* the address bar — the engine behind `useUrlState`.
|
|
5
|
-
*
|
|
6
|
-
* Two properties make the URLs clean and the round-trip lossless:
|
|
7
|
-
*
|
|
8
|
-
* - **Default-omission.** A codec built with `.withDefault(d)` encodes the
|
|
9
|
-
* default value to `undefined` — i.e. the key is dropped from the URL. So a
|
|
10
|
-
* filter at its default (`page=1`, `q=""`) never appears, and the shared
|
|
11
|
-
* link carries only what the user actually changed.
|
|
12
|
-
* - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
|
|
13
|
-
* app declared. Every other query param (`lotics_host`, `__mock`, a param a
|
|
14
|
-
* second `useUrlState` owns, a future host param) is read past and preserved
|
|
15
|
-
* on write — the merge is the namespace boundary, so no reserved-prefix rule
|
|
16
|
-
* is needed.
|
|
17
|
-
*
|
|
18
|
-
* Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
|
|
19
|
-
* so it carries no workspace deps (no `@lotics/shared`). These are plain pure
|
|
20
|
-
* functions — a frontend that later wants the same value⇄query codec can import
|
|
21
|
-
* them from here rather than growing a second copy.
|
|
22
|
-
*/
|
|
23
|
-
function first(raw) {
|
|
24
|
-
return Array.isArray(raw) ? raw[0] : raw;
|
|
25
|
-
}
|
|
26
|
-
function valueEqual(a, b) {
|
|
27
|
-
if (a === b)
|
|
28
|
-
return true;
|
|
29
|
-
if (a instanceof Date && b instanceof Date)
|
|
30
|
-
return a.getTime() === b.getTime();
|
|
31
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
32
|
-
return a.length === b.length && a.every((x, i) => valueEqual(x, b[i]));
|
|
33
|
-
}
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
/** Wrap an optional base codec with `.withDefault`. */
|
|
37
|
-
function optional(base) {
|
|
38
|
-
return {
|
|
39
|
-
decode: base.decode,
|
|
40
|
-
encode: base.encode,
|
|
41
|
-
withDefault(fallback) {
|
|
42
|
-
return {
|
|
43
|
-
decode: (raw) => {
|
|
44
|
-
const v = base.decode(raw);
|
|
45
|
-
return v === undefined ? fallback : v;
|
|
46
|
-
},
|
|
47
|
-
encode: (value) => (valueEqual(value, fallback) ? undefined : base.encode(value)),
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
const stringCodec = optional({
|
|
53
|
-
decode: (raw) => (raw === undefined ? undefined : first(raw)),
|
|
54
|
-
encode: (v) => v,
|
|
55
|
-
});
|
|
56
|
-
const numberCodec = optional({
|
|
57
|
-
decode: (raw) => {
|
|
58
|
-
const s = first(raw);
|
|
59
|
-
if (s === undefined || s === "")
|
|
60
|
-
return undefined;
|
|
61
|
-
const n = Number(s);
|
|
62
|
-
return Number.isFinite(n) ? n : undefined;
|
|
63
|
-
},
|
|
64
|
-
encode: (v) => (v === undefined ? undefined : String(v)),
|
|
65
|
-
});
|
|
66
|
-
const booleanCodec = optional({
|
|
67
|
-
decode: (raw) => {
|
|
68
|
-
const s = first(raw);
|
|
69
|
-
return s === "true" ? true : s === "false" ? false : undefined;
|
|
70
|
-
},
|
|
71
|
-
encode: (v) => (v === undefined ? undefined : v ? "true" : "false"),
|
|
72
|
-
});
|
|
73
|
-
const isoDateCodec = optional({
|
|
74
|
-
decode: (raw) => {
|
|
75
|
-
const s = first(raw);
|
|
76
|
-
if (s === undefined || !/^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
77
|
-
return undefined;
|
|
78
|
-
const d = new Date(`${s}T00:00:00.000Z`);
|
|
79
|
-
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
80
|
-
},
|
|
81
|
-
encode: (v) => (v === undefined ? undefined : v.toISOString().slice(0, 10)),
|
|
82
|
-
});
|
|
83
|
-
function enumCodec(values) {
|
|
84
|
-
return optional({
|
|
85
|
-
decode: (raw) => {
|
|
86
|
-
const s = first(raw);
|
|
87
|
-
return s === undefined ? undefined : values.find((v) => v === s);
|
|
88
|
-
},
|
|
89
|
-
encode: (v) => v,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
function arrayOf(inner) {
|
|
93
|
-
return optional({
|
|
94
|
-
decode: (raw) => {
|
|
95
|
-
if (raw === undefined)
|
|
96
|
-
return undefined;
|
|
97
|
-
const items = Array.isArray(raw) ? raw : [raw];
|
|
98
|
-
return items.map((x) => inner.decode(x)).filter((x) => x !== undefined);
|
|
99
|
-
},
|
|
100
|
-
encode: (value) => {
|
|
101
|
-
if (value === undefined || value.length === 0)
|
|
102
|
-
return undefined;
|
|
103
|
-
const out = value
|
|
104
|
-
.map((x) => inner.encode(x))
|
|
105
|
-
.filter((x) => typeof x === "string");
|
|
106
|
-
return out.length > 0 ? out : undefined;
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* The codec builders an app composes into a `useUrlState` shape. Each base
|
|
112
|
-
* builder yields an optional codec (absent key → `undefined`); add
|
|
113
|
-
* `.withDefault(v)` to make it required and keep the default out of the URL.
|
|
114
|
-
*
|
|
115
|
-
* useUrlState({
|
|
116
|
-
* q: urlParam.string.withDefault(""),
|
|
117
|
-
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
118
|
-
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
119
|
-
* from: urlParam.isoDate, // optional Date
|
|
120
|
-
* page: urlParam.number.withDefault(1),
|
|
121
|
-
* })
|
|
122
|
-
*/
|
|
123
|
-
export const urlParam = {
|
|
124
|
-
string: stringCodec,
|
|
125
|
-
number: numberCodec,
|
|
126
|
-
boolean: booleanCodec,
|
|
127
|
-
isoDate: isoDateCodec,
|
|
128
|
-
enum: enumCodec,
|
|
129
|
-
arrayOf,
|
|
130
|
-
};
|
|
131
|
-
/** Decode the declared keys out of a params map into typed values. */
|
|
132
|
-
export function decodeAll(defs, params) {
|
|
133
|
-
const out = {};
|
|
134
|
-
for (const key of Object.keys(defs)) {
|
|
135
|
-
out[key] = defs[key].decode(params[key]);
|
|
136
|
-
}
|
|
137
|
-
// Boundary: a per-key loop can't be expressed as the mapped result type.
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
/** Encode a partial set of declared values into a patch (each key → value or
|
|
141
|
-
* `undefined` to clear). Only the keys present in `values` are emitted, so the
|
|
142
|
-
* write merges and leaves every other query param untouched. */
|
|
143
|
-
export function encodePatch(defs, values) {
|
|
144
|
-
const out = {};
|
|
145
|
-
for (const key of Object.keys(values)) {
|
|
146
|
-
const codec = defs[key];
|
|
147
|
-
if (codec)
|
|
148
|
-
out[key] = codec.encode(values[key]);
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
151
|
-
}
|
|
152
|
-
/** Parse a `location.search` string into a params map (repeated keys → array). */
|
|
153
|
-
export function parseSearch(search) {
|
|
154
|
-
const sp = new URLSearchParams(search);
|
|
155
|
-
const out = {};
|
|
156
|
-
for (const key of sp.keys()) {
|
|
157
|
-
if (key in out)
|
|
158
|
-
continue;
|
|
159
|
-
const all = sp.getAll(key);
|
|
160
|
-
out[key] = all.length > 1 ? all : all[0];
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
/** Apply a patch to a `location.search` string and return the new query string
|
|
165
|
-
* (no leading `?`). Each patch key is replaced; `undefined` clears it; every
|
|
166
|
-
* other existing param is preserved. */
|
|
167
|
-
export function serializeMerge(search, patch) {
|
|
168
|
-
const sp = new URLSearchParams(search);
|
|
169
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
170
|
-
sp.delete(key);
|
|
171
|
-
if (value === undefined)
|
|
172
|
-
continue;
|
|
173
|
-
if (Array.isArray(value)) {
|
|
174
|
-
for (const item of value)
|
|
175
|
-
sp.append(key, item);
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
sp.append(key, value);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return sp.toString();
|
|
182
|
-
}
|
|
183
|
-
/** Apply a patch to an in-memory params map (the optimistic local mirror). */
|
|
184
|
-
export function applyPatch(params, patch) {
|
|
185
|
-
const next = { ...params };
|
|
186
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
187
|
-
if (value === undefined)
|
|
188
|
-
delete next[key];
|
|
189
|
-
else
|
|
190
|
-
next[key] = value;
|
|
191
|
-
}
|
|
192
|
-
return next;
|
|
193
|
-
}
|
|
194
|
-
/** Shallow value-equality over two params maps — used to drop echoed updates so
|
|
195
|
-
* an app's own write doesn't re-render it a second time. */
|
|
196
|
-
export function paramsEqual(a, b) {
|
|
197
|
-
const ak = Object.keys(a);
|
|
198
|
-
const bk = Object.keys(b);
|
|
199
|
-
if (ak.length !== bk.length)
|
|
200
|
-
return false;
|
|
201
|
-
for (const key of ak) {
|
|
202
|
-
const av = a[key];
|
|
203
|
-
const bv = b[key];
|
|
204
|
-
if (Array.isArray(av) || Array.isArray(bv)) {
|
|
205
|
-
if (!Array.isArray(av) || !Array.isArray(bv))
|
|
206
|
-
return false;
|
|
207
|
-
if (av.length !== bv.length || !av.every((x, i) => x === bv[i]))
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
else if (av !== bv) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
export interface OptimisticApi<T> {
|
|
2
|
-
/** `base` with any pending optimistic patches applied. */
|
|
3
|
-
items: T[];
|
|
4
|
-
/**
|
|
5
|
-
* Optimistically merge `next` into the item keyed `id`, then run `persist`.
|
|
6
|
-
* On resolve → `onSettled?.()`; the patch is kept (it should already match
|
|
7
|
-
* what the write persisted, so the re-read lands underneath it without a
|
|
8
|
-
* flicker). On reject → the patch is reverted.
|
|
9
|
-
*
|
|
10
|
-
* `onSettled` is NOT for refetching the query this patch came from — a
|
|
11
|
-
* successful `useWorkflow` inside `persist` re-reads every mounted query on
|
|
12
|
-
* its own. Use it for what the write cannot know about: a total the app
|
|
13
|
-
* computed itself, an indicator to clear.
|
|
14
|
-
*/
|
|
15
|
-
patch: (id: string, next: Partial<T>, persist: () => Promise<unknown>, opts?: {
|
|
16
|
-
onSettled?: () => void;
|
|
17
|
-
}) => void;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Optimistic list overrides for a `useQuery` result feeding an interactive view
|
|
21
|
-
* (calendar drag, gantt resize, kanban move). Pure React state: it takes the
|
|
22
|
-
* already-mapped items + a key function + a caller-supplied `persist` thunk, so
|
|
23
|
-
* it has no coupling to any specific mutation transport (the app threads its own
|
|
24
|
-
* `useWorkflow` call + `refetch`). It lives in app-sdk as the *reconcile* leg of
|
|
25
|
-
* the read (`useQuery`) → mutate (`useWorkflow`) → reconcile loop.
|
|
26
|
-
*/
|
|
27
|
-
export declare function useOptimistic<T>(base: T[], keyOf: (item: T) => string): OptimisticApi<T>;
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from "react";
|
|
2
|
-
/**
|
|
3
|
-
* Optimistic list overrides for a `useQuery` result feeding an interactive view
|
|
4
|
-
* (calendar drag, gantt resize, kanban move). Pure React state: it takes the
|
|
5
|
-
* already-mapped items + a key function + a caller-supplied `persist` thunk, so
|
|
6
|
-
* it has no coupling to any specific mutation transport (the app threads its own
|
|
7
|
-
* `useWorkflow` call + `refetch`). It lives in app-sdk as the *reconcile* leg of
|
|
8
|
-
* the read (`useQuery`) → mutate (`useWorkflow`) → reconcile loop.
|
|
9
|
-
*/
|
|
10
|
-
export function useOptimistic(base, keyOf) {
|
|
11
|
-
const [overrides, setOverrides] = useState({});
|
|
12
|
-
const items = useMemo(() => base.map((item) => {
|
|
13
|
-
const o = overrides[keyOf(item)];
|
|
14
|
-
return o ? { ...item, ...o } : item;
|
|
15
|
-
}), [base, overrides, keyOf]);
|
|
16
|
-
const patch = useCallback((id, next, persist, opts) => {
|
|
17
|
-
setOverrides((m) => ({ ...m, [id]: { ...m[id], ...next } }));
|
|
18
|
-
persist().then(() => opts?.onSettled?.(), () => setOverrides((m) => {
|
|
19
|
-
if (!(id in m))
|
|
20
|
-
return m;
|
|
21
|
-
const cleared = { ...m };
|
|
22
|
-
delete cleared[id];
|
|
23
|
-
return cleared;
|
|
24
|
-
}));
|
|
25
|
-
}, []);
|
|
26
|
-
return { items, patch };
|
|
27
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export interface RecentsApi<T> {
|
|
2
|
-
/** Remembered items, most-recent first (deduped by `keyOf`, capped at `max`). */
|
|
3
|
-
recents: T[];
|
|
4
|
-
/** Record `item` as most-recent: moves an existing match to the front, caps,
|
|
5
|
-
* and persists. Call this on select. */
|
|
6
|
-
remember: (item: T) => void;
|
|
7
|
-
/** Drop one remembered item (matched by `keyOf`). */
|
|
8
|
-
forget: (item: T) => void;
|
|
9
|
-
/** Clear the whole list. */
|
|
10
|
-
clear: () => void;
|
|
11
|
-
}
|
|
12
|
-
export interface RecentsOptions<T> {
|
|
13
|
-
/** Stable identity per item — used to dedup and to match `forget`. Default:
|
|
14
|
-
* `JSON.stringify`. Pass the item's id for objects you'll re-create. */
|
|
15
|
-
keyOf?: (item: T) => string;
|
|
16
|
-
/** Maximum items kept. Default 5. */
|
|
17
|
-
max?: number;
|
|
18
|
-
}
|
|
19
|
-
export declare function useRecents<T>(key: string, options?: RecentsOptions<T>): RecentsApi<T>;
|
package/dist/src/use_recents.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remember a small, most-recent-first list of items across sessions — the
|
|
3
|
-
* "recently used" affordance a search box shows when focused but empty.
|
|
4
|
-
*
|
|
5
|
-
* Persistence is `localStorage`, namespaced per `key`, so two comboboxes on one
|
|
6
|
-
* page keep separate lists and a deployed app's recents survive reloads. The
|
|
7
|
-
* SDK is the right home (not `@lotics/ui`): `localStorage` is web-only, and
|
|
8
|
-
* `@lotics/ui` also builds for native, where it doesn't exist. The list is
|
|
9
|
-
* always state-backed, so the hook keeps working in-memory if storage is
|
|
10
|
-
* unavailable (private mode / quota) — recents is an enhancement, never a
|
|
11
|
-
* reason to crash the app.
|
|
12
|
-
*/
|
|
13
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
14
|
-
const PREFIX = "lotics.recents.";
|
|
15
|
-
function read(storageKey) {
|
|
16
|
-
try {
|
|
17
|
-
const raw = localStorage.getItem(storageKey);
|
|
18
|
-
if (raw == null)
|
|
19
|
-
return [];
|
|
20
|
-
const parsed = JSON.parse(raw);
|
|
21
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Storage unavailable or corrupt JSON: start empty, keep working in-memory.
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function write(storageKey, items) {
|
|
29
|
-
try {
|
|
30
|
-
localStorage.setItem(storageKey, JSON.stringify(items));
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
// Storage unavailable (private mode / quota): the in-memory list still
|
|
34
|
-
// updates; only cross-reload persistence is lost.
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
export function useRecents(key, options = {}) {
|
|
38
|
-
const storageKey = PREFIX + key;
|
|
39
|
-
// Config is captured in refs so the returned callbacks stay referentially
|
|
40
|
-
// stable across renders even when the caller passes an inline `keyOf`.
|
|
41
|
-
const keyOfRef = useRef(options.keyOf ?? ((item) => JSON.stringify(item)));
|
|
42
|
-
keyOfRef.current = options.keyOf ?? ((item) => JSON.stringify(item));
|
|
43
|
-
const maxRef = useRef(options.max ?? 5);
|
|
44
|
-
maxRef.current = options.max ?? 5;
|
|
45
|
-
const [recents, setRecents] = useState(() => read(storageKey).slice(0, maxRef.current));
|
|
46
|
-
// Re-hydrate when the namespace changes (a different list on the same screen).
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
setRecents(read(storageKey).slice(0, maxRef.current));
|
|
49
|
-
}, [storageKey]);
|
|
50
|
-
const remember = useCallback((item) => {
|
|
51
|
-
setRecents((prev) => {
|
|
52
|
-
const k = keyOfRef.current(item);
|
|
53
|
-
const next = [item, ...prev.filter((p) => keyOfRef.current(p) !== k)].slice(0, maxRef.current);
|
|
54
|
-
write(storageKey, next);
|
|
55
|
-
return next;
|
|
56
|
-
});
|
|
57
|
-
}, [storageKey]);
|
|
58
|
-
const forget = useCallback((item) => {
|
|
59
|
-
setRecents((prev) => {
|
|
60
|
-
const k = keyOfRef.current(item);
|
|
61
|
-
const next = prev.filter((p) => keyOfRef.current(p) !== k);
|
|
62
|
-
write(storageKey, next);
|
|
63
|
-
return next;
|
|
64
|
-
});
|
|
65
|
-
}, [storageKey]);
|
|
66
|
-
const clear = useCallback(() => {
|
|
67
|
-
write(storageKey, []);
|
|
68
|
-
setRecents([]);
|
|
69
|
-
}, [storageKey]);
|
|
70
|
-
return { recents, remember, forget, clear };
|
|
71
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Save a declared slice of an app's view-state — filters, search, sort, the
|
|
3
|
-
* active tab — into the host's address bar, so a filtered view survives refresh
|
|
4
|
-
* and is shareable/bookmarkable as a link. Writes always *replace* in place: the
|
|
5
|
-
* host owns no in-app history, and in-app navigation (and the browser back
|
|
6
|
-
* button) belong to the app's own router, not to this hook.
|
|
7
|
-
*
|
|
8
|
-
* const [filters, setFilters] = useUrlState({
|
|
9
|
-
* q: urlParam.string.withDefault(""),
|
|
10
|
-
* status: urlParam.enum(["open", "won", "lost"]), // optional
|
|
11
|
-
* tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
12
|
-
* page: urlParam.number.withDefault(1),
|
|
13
|
-
* });
|
|
14
|
-
* // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
15
|
-
* setFilters({ q: "acme" }); // merge into the address bar (replace)
|
|
16
|
-
*
|
|
17
|
-
* The app declares only the keys it owns; every other query param (a param a
|
|
18
|
-
* second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
|
|
19
|
-
* host param) is read past and preserved on write. For a search box, keep the
|
|
20
|
-
* live input in local state and commit to `setFilters` on a debounce — each
|
|
21
|
-
* call is a cross-frame write in an embedded app.
|
|
22
|
-
*
|
|
23
|
-
* The address bar is the only store: nothing is persisted server-side. Values
|
|
24
|
-
* are decoded fresh from the current params each render; standalone reads the
|
|
25
|
-
* URL directly, while an embedded app keeps a local mirror seeded from the host
|
|
26
|
-
* (`urlState.get` on mount) and updated optimistically on its own writes — so
|
|
27
|
-
* there's no independently-mutable second copy to drift.
|
|
28
|
-
*/
|
|
29
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
30
|
-
import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
|
|
31
|
-
import { applyPatch, decodeAll, encodePatch, paramsEqual, } from "./url_params.js";
|
|
32
|
-
export function useUrlState(defs) {
|
|
33
|
-
// `defs` is expected stable (declared inline once); read through a ref so the
|
|
34
|
-
// decoded values and `setValues` stay referentially stable across renders.
|
|
35
|
-
const defsRef = useRef(defs);
|
|
36
|
-
defsRef.current = defs;
|
|
37
|
-
const [params, setParams] = useState(peekUrlParams);
|
|
38
|
-
// Once the user edits, a late initial hydration (bridged `getUrlParams`
|
|
39
|
-
// resolves a tick after mount) must not clobber their write.
|
|
40
|
-
const editedRef = useRef(false);
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
let active = true;
|
|
43
|
-
const apply = (next) => {
|
|
44
|
-
if (active)
|
|
45
|
-
setParams((prev) => (paramsEqual(prev, next) ? prev : next));
|
|
46
|
-
};
|
|
47
|
-
void getUrlParams().then((p) => {
|
|
48
|
-
if (active && !editedRef.current)
|
|
49
|
-
apply(p);
|
|
50
|
-
});
|
|
51
|
-
const unsubscribe = subscribeUrlParams(apply);
|
|
52
|
-
return () => {
|
|
53
|
-
active = false;
|
|
54
|
-
unsubscribe();
|
|
55
|
-
};
|
|
56
|
-
}, []);
|
|
57
|
-
const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
|
|
58
|
-
const setValues = useCallback((patch) => {
|
|
59
|
-
editedRef.current = true;
|
|
60
|
-
const encoded = encodePatch(defsRef.current, patch);
|
|
61
|
-
// Optimistic local mirror so the UI is responsive even before the write
|
|
62
|
-
// round-trips (and the only update path in standalone, where the history
|
|
63
|
-
// write fires no event).
|
|
64
|
-
setParams((prev) => applyPatch(prev, encoded));
|
|
65
|
-
// The optimistic mirror already reflects the change, so a failed write
|
|
66
|
-
// only loses cross-refresh persistence, never the session — keep the
|
|
67
|
-
// optimistic state, but surface the failure instead of swallowing it.
|
|
68
|
-
void setUrlParams(encoded).catch((err) => {
|
|
69
|
-
console.error("useUrlState: failed to write state to the address bar", err);
|
|
70
|
-
});
|
|
71
|
-
}, []);
|
|
72
|
-
return [values, setValues];
|
|
73
|
-
}
|