@xoong/data 0.0.0-stage → 0.0.35
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/LICENSE +21 -0
- package/README.md +33 -2
- package/package.json +43 -4
- package/src/client.js +435 -0
- package/src/default.js +54 -0
- package/src/derived.js +28 -0
- package/src/format.js +55 -0
- package/src/index.d.ts +83 -0
- package/src/index.js +5 -0
- package/src/invalidate.js +29 -0
- package/src/options.js +62 -0
- package/src/pointer.js +28 -0
- package/src/store.js +285 -0
- package/src/stream.js +30 -0
- package/src/url-state.js +32 -0
- package/src/view.js +847 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Display formats for interpolation: `{publishedAt | datetime}`, `{total | number}`, `{createdAt | relative}`.
|
|
4
|
+
* They use Intl (the platform), the page language (`<html lang>`, else the browser's), and never throw on bad input: a value that
|
|
5
|
+
* cannot be formatted is shown as it is. Add your own with `setFormat('money', (v) => ...)`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @type {(() => string) | null} */
|
|
9
|
+
let localeSource = null;
|
|
10
|
+
/** The i18n package says which language is active; without it the page language (`<html lang>`) or the browser's is used. */
|
|
11
|
+
export function setLocaleSource(fn) { localeSource = fn; }
|
|
12
|
+
export function locale() {
|
|
13
|
+
return localeSource?.() || globalThis.document?.documentElement?.lang || globalThis.navigator?.language || 'en';
|
|
14
|
+
}
|
|
15
|
+
const asDate = (v) => { const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d; };
|
|
16
|
+
|
|
17
|
+
const UNITS = /** @type {[Intl.RelativeTimeFormatUnit, number][]} */ ([['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60], ['second', 1]]);
|
|
18
|
+
|
|
19
|
+
/** @type {Map<string, (value: any, locale?: string) => string>} */
|
|
20
|
+
const formats = new Map([
|
|
21
|
+
['date', (v, loc = locale()) => { const d = asDate(v); return d ? new Intl.DateTimeFormat(loc, { dateStyle: 'medium' }).format(d) : String(v); }],
|
|
22
|
+
['datetime', (v, loc = locale()) => { const d = asDate(v); return d ? new Intl.DateTimeFormat(loc, { dateStyle: 'medium', timeStyle: 'short' }).format(d) : String(v); }],
|
|
23
|
+
['time', (v, loc = locale()) => { const d = asDate(v); return d ? new Intl.DateTimeFormat(loc, { timeStyle: 'short' }).format(d) : String(v); }],
|
|
24
|
+
['relative', (v, loc = locale()) => {
|
|
25
|
+
const d = asDate(v);
|
|
26
|
+
if (!d) return String(v);
|
|
27
|
+
const seconds = Math.round((d.getTime() - Date.now()) / 1000);
|
|
28
|
+
const [unit, size] = UNITS.find(([, s]) => Math.abs(seconds) >= s) ?? ['second', 1];
|
|
29
|
+
return new Intl.RelativeTimeFormat(loc, { numeric: 'auto' }).format(Math.round(seconds / size), unit);
|
|
30
|
+
}],
|
|
31
|
+
['number', (v, loc = locale()) => (Number.isFinite(Number(v)) && v !== '' ? new Intl.NumberFormat(loc).format(Number(v)) : String(v))],
|
|
32
|
+
['json', (v) => JSON.stringify(v)],
|
|
33
|
+
// content in several languages: { en: 'Hello', vi: 'Xin chào' } -> the active language, then its base language, then English, then whatever is filled
|
|
34
|
+
['localized', (v) => {
|
|
35
|
+
if (v === null || typeof v !== 'object') return String(v);
|
|
36
|
+
const loc = locale();
|
|
37
|
+
const pick = v[loc] ?? v[loc.split('-')[0]] ?? v.en ?? Object.values(v).find((x) => x !== '' && x != null);
|
|
38
|
+
return pick == null ? '' : String(pick);
|
|
39
|
+
}],
|
|
40
|
+
]);
|
|
41
|
+
const warned = new Set();
|
|
42
|
+
|
|
43
|
+
/** Register or replace a format. @param {string} name @param {(value: any, locale?: string) => string} fn */
|
|
44
|
+
export function setFormat(name, fn) { formats.set(name, fn); }
|
|
45
|
+
|
|
46
|
+
/** Format a value; nothing (null, undefined) is an empty string; an unknown format name is reported once and shows the plain value. */
|
|
47
|
+
export function format(name, value, loc) {
|
|
48
|
+
if (value === undefined || value === null) return '';
|
|
49
|
+
const fn = formats.get(name);
|
|
50
|
+
if (!fn) {
|
|
51
|
+
if (!warned.has(name)) { warned.add(name); console.error(`xoong: unknown format "${name}" in {path | ${name}} (known: ${[...formats.keys()].join(', ')})`); }
|
|
52
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
53
|
+
}
|
|
54
|
+
try { return fn(value, loc); } catch { return String(value); }
|
|
55
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Types for @xoong/data. The runtime is plain JavaScript; this file adds the typed client.
|
|
2
|
+
// Use it with the file `xoong sync` generates:
|
|
3
|
+
//
|
|
4
|
+
// import type { Operations } from './.xoong/api.d.ts';
|
|
5
|
+
// const client = createClient<Operations>({ api, baseUrl: '/api' });
|
|
6
|
+
// const page = await client.load('listUsers', { limit: 10 }); // page: UserPage
|
|
7
|
+
// await client.mutate('createUser', {}, { name: 'Ada' }); // body is checked
|
|
8
|
+
|
|
9
|
+
/** Shape of one entry of the generated `Operations` interface. */
|
|
10
|
+
export interface OperationType { method: string; path: string; params: any; body: any; response: any }
|
|
11
|
+
|
|
12
|
+
/** Arguments after the operation name: params may be left out when the operation has none that are required. */
|
|
13
|
+
type ParamsArg<O extends OperationType> = {} extends O['params'] ? [params?: O['params']] : [params: O['params']];
|
|
14
|
+
type Id<Ops> = keyof Ops & string;
|
|
15
|
+
|
|
16
|
+
type Signal<T> = { readonly value: T; peek(): T };
|
|
17
|
+
|
|
18
|
+
export class XoongError extends Error {
|
|
19
|
+
status: number;
|
|
20
|
+
title?: string;
|
|
21
|
+
detail?: string;
|
|
22
|
+
code?: string;
|
|
23
|
+
retryAfterS?: number;
|
|
24
|
+
problem?: any;
|
|
25
|
+
operation?: string;
|
|
26
|
+
/** Per-field messages when the server reports them (`{ email: 'is not valid' }`). */
|
|
27
|
+
fields: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClientOptions {
|
|
31
|
+
/** Contents of .xoong/operations.json */
|
|
32
|
+
api: { operations: Record<string, any>; entities?: Record<string, any> };
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
36
|
+
credentials?: RequestCredentials;
|
|
37
|
+
staleTime?: number;
|
|
38
|
+
gcTime?: number;
|
|
39
|
+
evictEntities?: boolean;
|
|
40
|
+
retry?: number;
|
|
41
|
+
retryDelay?: number;
|
|
42
|
+
invalidate?: Record<string, string[]>;
|
|
43
|
+
onError?: (e: XoongError) => void;
|
|
44
|
+
onUnauthorized?: (e: XoongError) => void;
|
|
45
|
+
streamUrl?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SseEvent { event: string; data: string; json?: unknown; id?: string }
|
|
49
|
+
|
|
50
|
+
export interface TypedQuery<O extends OperationType> {
|
|
51
|
+
readonly data: Signal<O['response'] | undefined>;
|
|
52
|
+
readonly status: Signal<'idle' | 'loading' | 'success' | 'error'>;
|
|
53
|
+
readonly error: Signal<XoongError | null>;
|
|
54
|
+
retain(): void;
|
|
55
|
+
release(): void;
|
|
56
|
+
invalidate(): void;
|
|
57
|
+
fetch(): Promise<O['response']>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `Ops` is the generated `Operations` interface. Without it every operation name is a string and nothing is checked.
|
|
62
|
+
*/
|
|
63
|
+
export interface Client<Ops extends Record<keyof Ops, OperationType> = any> {
|
|
64
|
+
store: unknown;
|
|
65
|
+
op(ref: string): any;
|
|
66
|
+
query<K extends Id<Ops>>(op: K, ...params: ParamsArg<Ops[K]>): TypedQuery<Ops[K]>;
|
|
67
|
+
load<K extends Id<Ops>>(op: K, ...params: ParamsArg<Ops[K]>): Promise<Ops[K]['response']>;
|
|
68
|
+
/** Put data the server already has into the cache as if the GET had run (no request until it is stale). */
|
|
69
|
+
seed<K extends Id<Ops>>(op: K, params: Ops[K] extends { params: infer P } ? P : never, data: Ops[K]['response']): Ops[K]['response'];
|
|
70
|
+
mutate<K extends Id<Ops>>(op: K, params: Ops[K]['params'], ...body: [Ops[K]['body']] extends [never] ? [] : [body: Ops[K]['body']]): Promise<Ops[K]['response']>;
|
|
71
|
+
stream<K extends Id<Ops>>(op: K, params: Ops[K]['params'], body?: Ops[K]['body'], options?: { signal?: AbortSignal }): AsyncGenerator<SseEvent>;
|
|
72
|
+
invalidateAll(): void;
|
|
73
|
+
readonly pending: number;
|
|
74
|
+
readonly size: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createClient<Ops extends Record<keyof Ops, OperationType> = any>(options: ClientOptions): Client<Ops>;
|
|
78
|
+
export function configure<Ops extends Record<keyof Ops, OperationType> = any>(options: ClientOptions): Client<Ops>;
|
|
79
|
+
export function getClient<Ops extends Record<keyof Ops, OperationType> = any>(): Client<Ops>;
|
|
80
|
+
export function derived<T>(fn: (ctx: { all(name: string): any[]; get(name: string, id: string | number): any }) => T, client?: Client<any>): Signal<T>;
|
|
81
|
+
export function connectStream(options: { client: Client<any>; url: string; EventSource?: typeof EventSource; onStatus?: (s: 'open' | 'error' | 'closed') => void; onError?: (e: Error) => void }): { close(): void };
|
|
82
|
+
export function createStore(entityDefs?: Record<string, any>): unknown;
|
|
83
|
+
export function isRef(v: unknown): v is { $ref: string };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Default invalidation rule, derived from REST path structure.
|
|
4
|
+
*
|
|
5
|
+
* A mutation on `/users/42/disable` invalidates cached queries whose concrete
|
|
6
|
+
* path is a segment-prefix of it or that it is a segment-prefix of:
|
|
7
|
+
* GET /users (prefix of the mutation) -> invalidated
|
|
8
|
+
* GET /users/42 (prefix of the mutation) -> invalidated
|
|
9
|
+
* GET /users/42/posts (neither is a prefix) -> kept
|
|
10
|
+
* GET /users/43 (differs at a segment) -> kept
|
|
11
|
+
*/
|
|
12
|
+
const segments = (p) => p.split('/').filter(Boolean);
|
|
13
|
+
|
|
14
|
+
/** @param {string} a concrete path @param {string} b concrete path */
|
|
15
|
+
export function pathsOverlap(a, b) {
|
|
16
|
+
const x = segments(a), y = segments(b);
|
|
17
|
+
const n = Math.min(x.length, y.length);
|
|
18
|
+
for (let i = 0; i < n; i++) if (x[i] !== y[i]) return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fill `{param}` placeholders and encode values. Throws naming a missing param. */
|
|
23
|
+
export function fillPath(template, params = {}, opId = template) {
|
|
24
|
+
return template.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
25
|
+
const v = params[name];
|
|
26
|
+
if (v === undefined || v === null) throw new Error(`xoong: operation "${opId}" needs path parameter "${name}"`);
|
|
27
|
+
return encodeURIComponent(String(v));
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/options.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Options from the API for a combobox or a tag input, declared in markup:
|
|
4
|
+
*
|
|
5
|
+
* <xo-combobox name="categoryIds" multiple options-op="listCategories" option-value="id" option-label="name.en"></xo-combobox>
|
|
6
|
+
*
|
|
7
|
+
* `options-op` is a GET operation (an operationId or "/path"); `p-<name>` attributes are its parameters, as on <xo-view>. The list is the
|
|
8
|
+
* response itself, or its `items` (or `data`) member. `option-value` and `option-label` are paths in each item ("id", "name.en"); they
|
|
9
|
+
* default to `value` and `label`, then to `id` and `name`. The data comes from the shared cache, so a mutation that touches the list
|
|
10
|
+
* (a category added elsewhere on the page) refreshes the options by itself. Importing `@xoong/data/view` turns this on.
|
|
11
|
+
*/
|
|
12
|
+
import { effect } from '@xoong/core/signals';
|
|
13
|
+
import { getClient } from './default.js';
|
|
14
|
+
|
|
15
|
+
const camel = (/** @type {string} */ s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
16
|
+
const pick = (/** @type {any} */ item, /** @type {string[]} */ paths) => {
|
|
17
|
+
for (const path of paths) {
|
|
18
|
+
let v = item;
|
|
19
|
+
for (const step of path.split('.')) v = v?.[step];
|
|
20
|
+
if (v != null && typeof v !== 'object') return String(v);
|
|
21
|
+
}
|
|
22
|
+
return '';
|
|
23
|
+
};
|
|
24
|
+
const stops = new WeakMap();
|
|
25
|
+
|
|
26
|
+
/** @param {any} el */
|
|
27
|
+
function start(el) {
|
|
28
|
+
if (stops.has(el)) return;
|
|
29
|
+
const ref = el.getAttribute('options-op');
|
|
30
|
+
if (!ref) return;
|
|
31
|
+
/** @type {Record<string, string>} */ const params = {};
|
|
32
|
+
for (const a of el.attributes) if (a.name.startsWith('p-')) params[camel(a.name.slice(2))] = a.value;
|
|
33
|
+
const valueAt = el.getAttribute('option-value');
|
|
34
|
+
const labelAt = el.getAttribute('option-label');
|
|
35
|
+
let query;
|
|
36
|
+
try { query = getClient().query(ref.startsWith('/') ? `GET ${ref}` : ref, params); } catch (e) { console.error(e); return; }
|
|
37
|
+
query.retain();
|
|
38
|
+
const stop = effect(() => {
|
|
39
|
+
const data = query.data.value;
|
|
40
|
+
const list = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
|
|
41
|
+
el.options = (Array.isArray(list) ? list : []).map((item) => (typeof item === 'object' && item
|
|
42
|
+
? { value: pick(item, [valueAt ?? 'value', 'id']), label: pick(item, [labelAt ?? 'label', 'name', 'title', valueAt ?? 'id']) }
|
|
43
|
+
: String(item)));
|
|
44
|
+
});
|
|
45
|
+
stops.set(el, () => { stop(); query.release(); });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const scan = (/** @type {ParentNode} */ root) => { for (const el of root.querySelectorAll?.('[options-op]') ?? []) start(el); };
|
|
49
|
+
const clean = (/** @type {Node} */ node) => { for (const el of [node, ...((/** @type {any} */ (node)).querySelectorAll?.('[options-op]') ?? [])]) { stops.get(el)?.(); stops.delete(el); } };
|
|
50
|
+
|
|
51
|
+
if (typeof document !== 'undefined' && typeof MutationObserver !== 'undefined') {
|
|
52
|
+
const watch = () => {
|
|
53
|
+
scan(document);
|
|
54
|
+
new MutationObserver((records) => {
|
|
55
|
+
for (const r of records) {
|
|
56
|
+
for (const n of r.removedNodes) if (n.nodeType === 1 && !(/** @type {Element} */ (n)).isConnected) clean(n);
|
|
57
|
+
for (const n of r.addedNodes) if (n.nodeType === 1) { if (/** @type {Element} */ (n).hasAttribute('options-op')) start(n); scan(/** @type {Element} */ (n)); }
|
|
58
|
+
}
|
|
59
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
60
|
+
};
|
|
61
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch, { once: true }); else queueMicrotask(watch);
|
|
62
|
+
}
|
package/src/pointer.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** RFC 6901 JSON Pointer helpers (copy-on-write set). */
|
|
3
|
+
|
|
4
|
+
/** @param {string} p */
|
|
5
|
+
export function parsePointer(p) {
|
|
6
|
+
if (p === '' ) return [];
|
|
7
|
+
if (!p.startsWith('/')) throw new Error(`xoong: invalid JSON pointer "${p}" (must start with "/")`);
|
|
8
|
+
return p.slice(1).split('/').map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return a copy of `obj` with `value` set at `pointer`. Never mutates `obj`.
|
|
15
|
+
* @param {any} obj @param {string} pointer @param {any} value
|
|
16
|
+
*/
|
|
17
|
+
export function setPointer(obj, pointer, value) {
|
|
18
|
+
const parts = parsePointer(pointer);
|
|
19
|
+
if (parts.length === 0) return value;
|
|
20
|
+
for (const part of parts) if (FORBIDDEN.has(part)) throw new Error(`xoong: forbidden pointer segment "${part}"`);
|
|
21
|
+
const walk = (node, i) => {
|
|
22
|
+
const key = parts[i];
|
|
23
|
+
const base = Array.isArray(node) ? [...node] : { ...(node && typeof node === 'object' ? node : {}) };
|
|
24
|
+
base[key] = i === parts.length - 1 ? value : walk(base[key], i + 1);
|
|
25
|
+
return base;
|
|
26
|
+
};
|
|
27
|
+
return walk(obj, 0);
|
|
28
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { signal, batch } from '@xoong/core/signals';
|
|
3
|
+
import { setPointer } from './pointer.js';
|
|
4
|
+
|
|
5
|
+
/** A reference to a stored entity: { $ref: "User#42" } */
|
|
6
|
+
export const isRef = (v) => !!v && typeof v === 'object' && typeof v.$ref === 'string';
|
|
7
|
+
export const refKey = (name, id) => `${name}#${id}`;
|
|
8
|
+
const shallowEqual = (a, b) => {
|
|
9
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
10
|
+
return ka.length === kb.length && ka.every((k) => Object.is(a[k], b[k]));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalized entity store. Every entity is one signal, so a change made by a
|
|
15
|
+
* mutation, a refetch or a stream event is seen by every screen reading it.
|
|
16
|
+
*
|
|
17
|
+
* @param {Record<string, { id: string, props?: Record<string, any> }>} [entityDefs]
|
|
18
|
+
*/
|
|
19
|
+
export function createStore(entityDefs = {}) {
|
|
20
|
+
/**
|
|
21
|
+
* One signal per entity key, kept even after deletion (value undefined) so that
|
|
22
|
+
* readers stay subscribed and see an entity that is deleted and later re-created.
|
|
23
|
+
* @type {Map<string, import('@xoong/core/signals').Signal<any>>}
|
|
24
|
+
*/
|
|
25
|
+
const entities = new Map();
|
|
26
|
+
/** @type {Map<string, Set<string>>} entity name -> ids that currently exist */
|
|
27
|
+
const ids = new Map();
|
|
28
|
+
/** @type {Map<string, import('@xoong/core/signals').Signal<number>>} */
|
|
29
|
+
const indexTick = new Map();
|
|
30
|
+
/** Last applied stream version per entity; deletes keep theirs so late events cannot resurrect. */
|
|
31
|
+
const versions = new Map();
|
|
32
|
+
|
|
33
|
+
const tick = (name) => {
|
|
34
|
+
let s = indexTick.get(name);
|
|
35
|
+
if (!s) indexTick.set(name, (s = signal(0)));
|
|
36
|
+
return s;
|
|
37
|
+
};
|
|
38
|
+
const bump = (name) => { const s = tick(name); s.value = s.peek() + 1; };
|
|
39
|
+
const slot = (key) => {
|
|
40
|
+
let s = entities.get(key);
|
|
41
|
+
if (!s) entities.set(key, (s = signal(undefined)));
|
|
42
|
+
return s;
|
|
43
|
+
};
|
|
44
|
+
const exists = (key) => entities.get(key)?.peek() !== undefined;
|
|
45
|
+
|
|
46
|
+
const store = {
|
|
47
|
+
defs: entityDefs,
|
|
48
|
+
|
|
49
|
+
/** Insert or merge an entity record (already normalized: nested entities are refs). */
|
|
50
|
+
upsert(name, id, record, { replace = false } = {}) {
|
|
51
|
+
const key = refKey(name, id);
|
|
52
|
+
const sig = slot(key);
|
|
53
|
+
const current = sig.peek();
|
|
54
|
+
if (current !== undefined) {
|
|
55
|
+
const next = replace ? { ...record } : { ...current, ...record };
|
|
56
|
+
if (!shallowEqual(current, next)) sig.value = next;
|
|
57
|
+
} else {
|
|
58
|
+
batch(() => {
|
|
59
|
+
sig.value = { ...record };
|
|
60
|
+
if (!ids.has(name)) ids.set(name, new Set());
|
|
61
|
+
ids.get(name).add(String(id));
|
|
62
|
+
bump(name);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return { $ref: key };
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Remove an entity. References to it resolve to undefined afterwards. */
|
|
69
|
+
remove(name, id) {
|
|
70
|
+
const key = refKey(name, id);
|
|
71
|
+
if (!exists(key)) return;
|
|
72
|
+
batch(() => {
|
|
73
|
+
ids.get(name)?.delete(String(id));
|
|
74
|
+
entities.get(key).value = undefined; // notify readers; the slot stays for re-creation
|
|
75
|
+
bump(name);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** Tracked read of one entity record (nested entities remain refs). Tracks even when absent. */
|
|
80
|
+
read(key) {
|
|
81
|
+
return slot(key).value;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tracked existence check. Subscribes to adds/removes of that entity type
|
|
86
|
+
* only (not to field changes), so lists can drop deleted rows cheaply.
|
|
87
|
+
*/
|
|
88
|
+
has(ref) {
|
|
89
|
+
const name = ref.$ref.slice(0, ref.$ref.indexOf('#'));
|
|
90
|
+
tick(name).value;
|
|
91
|
+
return exists(ref.$ref);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** Resolve a ref to its record (tracked); other values pass through. */
|
|
95
|
+
deref(v) {
|
|
96
|
+
return isRef(v) ? store.read(v.$ref) : v;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** Tracked list of all loaded entity refs of a type. */
|
|
100
|
+
refs(name) {
|
|
101
|
+
tick(name).value; // subscribe to adds/removes
|
|
102
|
+
return [...(ids.get(name) ?? [])].map((id) => ({ $ref: refKey(name, id) }));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Deeply resolve refs into plain objects (tracked). Cycles are cut. */
|
|
106
|
+
denormalize(v, seen = new Set()) {
|
|
107
|
+
if (isRef(v)) {
|
|
108
|
+
if (seen.has(v.$ref)) return v;
|
|
109
|
+
const rec = store.read(v.$ref);
|
|
110
|
+
if (rec === undefined) return undefined;
|
|
111
|
+
const next = new Set(seen).add(v.$ref);
|
|
112
|
+
return store.denormalize(rec, next);
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(v)) {
|
|
115
|
+
// References to deleted entities drop out of lists instead of leaving holes.
|
|
116
|
+
return v.map((x) => store.denormalize(x, seen)).filter((x, i) => !(x === undefined && isRef(v[i])));
|
|
117
|
+
}
|
|
118
|
+
if (v && typeof v === 'object') {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const k of Object.keys(v)) {
|
|
121
|
+
Object.defineProperty(out, k, { value: store.denormalize(v[k], seen), enumerable: true, writable: true, configurable: true });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
return v;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply one stream event. Returns true if applied, false if dropped
|
|
130
|
+
* (stale version, unknown entity, malformed).
|
|
131
|
+
* Events: {type:'patch'|'upsert'|'delete', entity, id, path?, value?, version?}
|
|
132
|
+
*/
|
|
133
|
+
applyEvent(ev) {
|
|
134
|
+
if (!ev || typeof ev !== 'object' || typeof ev.entity !== 'string' || ev.id == null) return false;
|
|
135
|
+
const key = refKey(ev.entity, ev.id);
|
|
136
|
+
if (typeof ev.version === 'number') {
|
|
137
|
+
const seen = versions.get(key);
|
|
138
|
+
if (seen !== undefined && ev.version <= seen) return false;
|
|
139
|
+
}
|
|
140
|
+
let applied = false;
|
|
141
|
+
if (ev.type === 'delete') {
|
|
142
|
+
if (exists(key)) { store.remove(ev.entity, ev.id); applied = true; }
|
|
143
|
+
} else if (ev.type === 'upsert') {
|
|
144
|
+
if (ev.value && typeof ev.value === 'object') {
|
|
145
|
+
store.upsert(ev.entity, ev.id, normalizeRecord(store, ev.entity, ev.value));
|
|
146
|
+
applied = true;
|
|
147
|
+
}
|
|
148
|
+
} else if (ev.type === 'patch') {
|
|
149
|
+
const sig = entities.get(key);
|
|
150
|
+
if (sig && exists(key) && typeof ev.path === 'string') {
|
|
151
|
+
const next = setPointer(sig.peek(), ev.path, ev.value);
|
|
152
|
+
if (!shallowEqual(sig.peek(), next)) sig.value = next;
|
|
153
|
+
applied = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (applied && typeof ev.version === 'number') versions.set(key, ev.version);
|
|
157
|
+
return applied;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Apply a provisional change to an existing entity (an optimistic update). Returns an undo function that puts back only
|
|
162
|
+
* the fields that still hold the provisional value, so a newer change from the server or another screen is not erased.
|
|
163
|
+
* Does nothing (returns a no-op) when the entity is not loaded.
|
|
164
|
+
* @param {string} key "Name#id"
|
|
165
|
+
* @param {Record<string, any>} patch
|
|
166
|
+
*/
|
|
167
|
+
optimistic(key, patch) {
|
|
168
|
+
const sig = entities.get(key);
|
|
169
|
+
const prev = sig?.peek();
|
|
170
|
+
if (prev === undefined) return () => {};
|
|
171
|
+
const next = { ...prev, ...patch };
|
|
172
|
+
if (!shallowEqual(prev, next)) sig.value = next;
|
|
173
|
+
return () => {
|
|
174
|
+
const now = sig.peek();
|
|
175
|
+
if (now === undefined) return;
|
|
176
|
+
const back = { ...now };
|
|
177
|
+
for (const k of Object.keys(patch)) if (Object.is(now[k], patch[k])) { if (k in prev) back[k] = prev[k]; else delete back[k]; }
|
|
178
|
+
if (!shallowEqual(now, back)) sig.value = back;
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/** Forget stream versions (the server may have restarted its counters). Call after a resync or reconnect. */
|
|
183
|
+
resetVersions() { versions.clear(); },
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Drop every entity that cannot be reached from `roots` (normalized query results), following nested refs.
|
|
187
|
+
* Readers of a dropped entity see it as deleted. Returns how many were dropped.
|
|
188
|
+
* @param {any[]} roots
|
|
189
|
+
*/
|
|
190
|
+
sweep(roots) {
|
|
191
|
+
const keep = new Set();
|
|
192
|
+
const visit = (v) => {
|
|
193
|
+
if (isRef(v)) {
|
|
194
|
+
if (keep.has(v.$ref)) return;
|
|
195
|
+
keep.add(v.$ref);
|
|
196
|
+
visit(entities.get(v.$ref)?.peek());
|
|
197
|
+
} else if (Array.isArray(v)) v.forEach(visit);
|
|
198
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(visit);
|
|
199
|
+
};
|
|
200
|
+
roots.forEach(visit);
|
|
201
|
+
let dropped = 0;
|
|
202
|
+
batch(() => {
|
|
203
|
+
const names = new Set();
|
|
204
|
+
for (const [key, sig] of entities) {
|
|
205
|
+
if (keep.has(key) || sig.peek() === undefined) continue;
|
|
206
|
+
const name = key.slice(0, key.indexOf('#'));
|
|
207
|
+
ids.get(name)?.delete(key.slice(name.length + 1));
|
|
208
|
+
sig.value = undefined; versions.delete(key); names.add(name); dropped++;
|
|
209
|
+
}
|
|
210
|
+
for (const name of names) bump(name);
|
|
211
|
+
});
|
|
212
|
+
return dropped;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
clear() {
|
|
216
|
+
batch(() => {
|
|
217
|
+
for (const sig of entities.values()) sig.value = undefined;
|
|
218
|
+
ids.clear(); versions.clear();
|
|
219
|
+
for (const name of indexTick.keys()) bump(name);
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
return store;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Identity of a record: one property, or several for a composite id (each part percent-encoded, joined with "/", so
|
|
228
|
+
* ["a/b","c"] and ["a","b/c"] stay different). undefined when a part is missing. Stream events address it the same way.
|
|
229
|
+
*/
|
|
230
|
+
export function idOf(idDef, value) {
|
|
231
|
+
if (!Array.isArray(idDef)) return value[idDef] ?? undefined;
|
|
232
|
+
const parts = idDef.map((k) => value[k]);
|
|
233
|
+
return parts.some((p) => p === undefined || p === null) ? undefined : parts.map((p) => encodeURIComponent(String(p))).join('/');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Normalize a plain entity object into a record whose nested entities are refs. */
|
|
237
|
+
export function normalizeRecord(store, name, value, touched) {
|
|
238
|
+
const def = store.defs[name];
|
|
239
|
+
const record = { ...value };
|
|
240
|
+
if (def?.props) {
|
|
241
|
+
for (const [k, desc] of Object.entries(def.props)) {
|
|
242
|
+
if (k in record) record[k] = normalize(store, desc, record[k], touched);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return record;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Walk `value` using a schema descriptor, storing entities and returning the
|
|
250
|
+
* normalized tree. `touched` (optional Set) collects the keys of every entity written. Descriptors: {t:'entity',name} | {t:'array',items} | {t:'object',props} | {t:'union',key,map,default?}
|
|
251
|
+
*/
|
|
252
|
+
export function normalize(store, desc, value, touched) {
|
|
253
|
+
if (!desc || value == null) return value;
|
|
254
|
+
switch (desc.t) {
|
|
255
|
+
case 'entity': {
|
|
256
|
+
if (typeof value !== 'object') return value;
|
|
257
|
+
const def = store.defs[desc.name];
|
|
258
|
+
const id = def ? idOf(def.id, value) : undefined;
|
|
259
|
+
if (id === undefined) return value; // no identity: keep inline
|
|
260
|
+
const ref = store.upsert(desc.name, id, normalizeRecord(store, desc.name, value, touched), { replace: desc.replace });
|
|
261
|
+
touched?.add(ref.$ref);
|
|
262
|
+
return ref;
|
|
263
|
+
}
|
|
264
|
+
case 'array':
|
|
265
|
+
return Array.isArray(value) ? value.map((x) => normalize(store, desc.items, x, touched)) : value;
|
|
266
|
+
case 'object': {
|
|
267
|
+
if (typeof value !== 'object' || Array.isArray(value)) return value;
|
|
268
|
+
const out = { ...value };
|
|
269
|
+
for (const [k, d] of Object.entries(desc.props ?? {})) if (k in out) out[k] = normalize(store, d, out[k], touched);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
case 'union': {
|
|
273
|
+
// Pick the member by its tag property; anything else (a plain id string, an unknown tag) stays as received.
|
|
274
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
275
|
+
if (desc.has) { // no tag: the member is recognised by a property only it has
|
|
276
|
+
const hit = desc.has.find(([prop]) => prop in value);
|
|
277
|
+
return hit ? normalize(store, hit[1], value, touched) : value;
|
|
278
|
+
}
|
|
279
|
+
const member = (value[desc.key] !== undefined ? desc.map[value[desc.key]] : undefined) ?? desc.default;
|
|
280
|
+
return member ? normalize(store, member, value, touched) : value;
|
|
281
|
+
}
|
|
282
|
+
default:
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Connect to a stream of entity events (SSE) and apply them to the client's store.
|
|
4
|
+
* Event JSON: {type:'patch'|'upsert'|'delete', entity, id, path?, value?, version?}
|
|
5
|
+
* or {type:'resync'} to invalidate every cached query.
|
|
6
|
+
*
|
|
7
|
+
* Reconnection and Last-Event-ID replay are handled by EventSource itself.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ client: ReturnType<import('./client.js').createClient>, url: string,
|
|
10
|
+
* EventSource?: typeof EventSource, onStatus?: (s:'open'|'error'|'closed') => void,
|
|
11
|
+
* onError?: (e: Error) => void }} opts
|
|
12
|
+
*/
|
|
13
|
+
export function connectStream({ client, url, EventSource = globalThis.EventSource, onStatus, onError }) {
|
|
14
|
+
if (!EventSource) throw new Error('xoong: EventSource is not available in this environment');
|
|
15
|
+
const source = new EventSource(url, { withCredentials: true });
|
|
16
|
+
let reconnected = false;
|
|
17
|
+
source.onopen = () => {
|
|
18
|
+
onStatus?.('open');
|
|
19
|
+
// After an interruption we may have missed events: refetch what is on screen.
|
|
20
|
+
if (reconnected) { client.store.resetVersions(); client.invalidateAll(); }
|
|
21
|
+
};
|
|
22
|
+
source.onerror = () => { reconnected = true; onStatus?.('error'); };
|
|
23
|
+
source.onmessage = (msg) => {
|
|
24
|
+
let ev;
|
|
25
|
+
try { ev = JSON.parse(msg.data); } catch (e) { onError?.(/** @type {Error} */ (e)); return; }
|
|
26
|
+
if (ev?.type === 'resync') { client.store.resetVersions(); client.invalidateAll(); }
|
|
27
|
+
else client.store.applyEvent(ev);
|
|
28
|
+
};
|
|
29
|
+
return { close() { source.close(); onStatus?.('closed'); } };
|
|
30
|
+
}
|
package/src/url-state.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Where `<xo-view url-params="status,q,sort,offset">` keeps its parameters: the query string of the page. Without a router the
|
|
4
|
+
* History API is used directly; a router (in @xoong/ui) replaces this with its own, which also keeps its history positions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* read(): URLSearchParams,
|
|
10
|
+
* write(params: URLSearchParams, mode: 'push' | 'replace'): void,
|
|
11
|
+
* subscribe(fn: () => void): () => void,
|
|
12
|
+
* }} UrlSync
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {UrlSync} */
|
|
16
|
+
const history = {
|
|
17
|
+
read: () => new URLSearchParams(globalThis.location?.search),
|
|
18
|
+
write(params, mode) {
|
|
19
|
+
const loc = globalThis.location;
|
|
20
|
+
if (!loc) return;
|
|
21
|
+
const query = params.toString();
|
|
22
|
+
const url = loc.pathname + (query ? `?${query}` : '') + loc.hash;
|
|
23
|
+
if (url === loc.pathname + loc.search + loc.hash) return;
|
|
24
|
+
globalThis.history[mode === 'push' ? 'pushState' : 'replaceState'](globalThis.history.state, '', url);
|
|
25
|
+
},
|
|
26
|
+
subscribe(fn) { globalThis.addEventListener('popstate', fn); return () => globalThis.removeEventListener('popstate', fn); },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let current = history;
|
|
30
|
+
/** The router registers itself here. @param {UrlSync | null} sync */
|
|
31
|
+
export function setUrlSync(sync) { current = sync ?? history; }
|
|
32
|
+
export const urlSync = () => current;
|