@jarenjs/app 0.75.0 → 0.83.3
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/README.md +19 -0
- package/dist/types/app.d.ts +20 -0
- package/dist/types/collection.d.ts +73 -0
- package/dist/types/formula.d.ts +30 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/runs.d.ts +26 -0
- package/dist/types/search.d.ts +60 -0
- package/docs/APP-FORMAT.md +27 -0
- package/docs/COLLECTION-PROVIDER.md +166 -0
- package/docs/SEARCH.md +41 -0
- package/docs/TASKS.md +25 -0
- package/package.json +13 -5
- package/src/app.js +26 -4
- package/src/collection.js +192 -0
- package/src/formula.js +70 -0
- package/src/index.js +2 -0
- package/src/runs.js +63 -0
- package/src/search.js +101 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @jarenjs/app
|
|
2
2
|
|
|
3
|
+
Collection, search, formula and run resources are injected and disposed by their host. Follow the [combined public composition](../../docs/ADOPTION-EVIDENCE.md) for bounded observations, page ownership and explicit platform/manual qualifications.
|
|
4
|
+
|
|
3
5
|
Applications as JSON documents. This package rebuilds [hyperapp](https://github.com/jorgebucaran/hyperapp)'s dispatch loop on the Jaren suite and pushes its philosophy — *everything is data* — the rest of the way: hyperapp made effects and subscriptions data but kept actions and views as JavaScript functions; here **the whole application is one JSON value**:
|
|
4
6
|
|
|
5
7
|
| Slot | Written as | Compiled by |
|
|
@@ -259,13 +261,30 @@ Every subpath a consumer can import, derived from the manifest by
|
|
|
259
261
|
<!--fact:exports.app-->
|
|
260
262
|
| Import | Kind | Declarations |
|
|
261
263
|
|---|---|---|
|
|
264
|
+
| `@jarenjs/app/search` | JavaScript | declared |
|
|
262
265
|
| `@jarenjs/app` | JavaScript | declared |
|
|
263
266
|
| `@jarenjs/app/schemas/jaren-app.authoring.schema.json` | schema | — |
|
|
264
267
|
| `@jarenjs/app/schemas/jaren-app.draft-07.schema.json` | schema | — |
|
|
265
268
|
| `@jarenjs/app/schemas/jaren-app.schema.json` | schema | — |
|
|
266
269
|
| `@jarenjs/app/package.json` | metadata | — |
|
|
270
|
+
| `@jarenjs/app/formula` | JavaScript | declared |
|
|
267
271
|
<!--/fact-->
|
|
268
272
|
|
|
269
273
|
## Development
|
|
270
274
|
|
|
271
275
|
Unit tests live in `test/app/` at the repository root (`npm run test:app`). See [ROADMAP](../../docs/ROADMAP.md) for what's next: dirty-path-pruned re-rendering and time-travel tooling over the action log.
|
|
276
|
+
|
|
277
|
+
`createApp` forwards the renderer's `safe`, `onUnsafe` and `hydrate` options.
|
|
278
|
+
Optional `capabilities: { effects, subs, widgets, eventFields }` arrays grant
|
|
279
|
+
specific host registry names; with safe mode or explicit grants, omitted lists
|
|
280
|
+
are empty. See [APP-FORMAT §8.2.1](docs/APP-FORMAT.md#821-dom-profiles-and-host-capability-grants)
|
|
281
|
+
for the disposal and trust contract. Safe mode is a display policy and removes
|
|
282
|
+
DOM event bindings and widgets.
|
|
283
|
+
|
|
284
|
+
`createArrayRangeProvider` and `createCollectionCoordinator` provide injected collection coordination and complete snapshot output; see [the provider contract](docs/COLLECTION-PROVIDER.md).
|
|
285
|
+
|
|
286
|
+
`@jarenjs/app/search` owns an injected worker and a private resident index; see [search ownership](docs/SEARCH.md) for cancellation, progress and drained disposal.
|
|
287
|
+
|
|
288
|
+
## Terminating formula workers
|
|
289
|
+
|
|
290
|
+
`@jarenjs/app/formula` exports `createFormulaResource`, an injected worker lifecycle with bounded admission, generation fencing, hard termination and draining disposal. Hosts supply actual terminating isolates; same-thread helpers have no hard deadline. See the [formula resource contract](../json/docs/FORMULA-FORMAT.md#batches-and-isolation).
|
package/dist/types/app.d.ts
CHANGED
|
@@ -41,6 +41,26 @@ export type AppOptions = {
|
|
|
41
41
|
* `node.ownerDocument`).
|
|
42
42
|
*/
|
|
43
43
|
document?: any;
|
|
44
|
+
/**
|
|
45
|
+
* - Adopt existing DOM on the first frame.
|
|
46
|
+
*/
|
|
47
|
+
hydrate?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* - Forward the inert view render profile. In
|
|
50
|
+
* this mode host capabilities default to empty allow-lists.
|
|
51
|
+
*/
|
|
52
|
+
safe?: boolean;
|
|
53
|
+
onUnsafe?: import('@jarenjs/view').DomRendererOptions['onUnsafe'];
|
|
54
|
+
/**
|
|
55
|
+
* Restrict the host registry names the document may access. Omitted
|
|
56
|
+
* lists grant nothing when this option or `safe` is enabled.
|
|
57
|
+
*/
|
|
58
|
+
capabilities?: {
|
|
59
|
+
effects?: string[];
|
|
60
|
+
subs?: string[];
|
|
61
|
+
widgets?: string[];
|
|
62
|
+
eventFields?: string[];
|
|
63
|
+
};
|
|
44
64
|
/**
|
|
45
65
|
* Effect handlers by name. A handler function may carry an optional
|
|
46
66
|
* `dispose()` member, called exactly once by `app.destroy()` (a
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export { createArrayRangeProvider } from '@jarenjs/core/range';
|
|
2
|
+
/**
|
|
3
|
+
* Coordinate an injected provider with finite pages, bytes, requests and prefetch. The host owns
|
|
4
|
+
* provider construction; by default coordinator disposal also drains the provider.
|
|
5
|
+
* @param {any} provider @param {any} [options]
|
|
6
|
+
*/
|
|
7
|
+
export declare function createCollectionCoordinator(provider: any, options?: any): {
|
|
8
|
+
provider: any;
|
|
9
|
+
requestRange: (range: any, signal: any) => Promise<{
|
|
10
|
+
state: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
} | {
|
|
13
|
+
state: string;
|
|
14
|
+
}>;
|
|
15
|
+
reset: (identity?: {}) => void;
|
|
16
|
+
stats: () => {
|
|
17
|
+
pages: number;
|
|
18
|
+
rows: any;
|
|
19
|
+
bytes: any;
|
|
20
|
+
inFlight: number;
|
|
21
|
+
outputs: number;
|
|
22
|
+
};
|
|
23
|
+
pinKeys(keys?: any[]): {
|
|
24
|
+
state: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
} | {
|
|
27
|
+
reason?: undefined;
|
|
28
|
+
state: string;
|
|
29
|
+
};
|
|
30
|
+
observation(): {
|
|
31
|
+
state: string;
|
|
32
|
+
query: any;
|
|
33
|
+
snapshot: any;
|
|
34
|
+
generation: number;
|
|
35
|
+
total: {
|
|
36
|
+
kind: string;
|
|
37
|
+
};
|
|
38
|
+
loadedRows: number;
|
|
39
|
+
loadedBytes: number;
|
|
40
|
+
};
|
|
41
|
+
subscribe(fn: any): () => boolean;
|
|
42
|
+
rowAt(index: any): any;
|
|
43
|
+
keyAt(index: any): any;
|
|
44
|
+
indexOf(key: any): any;
|
|
45
|
+
logicalCount(): any;
|
|
46
|
+
next(signal: any): Promise<{
|
|
47
|
+
state: string;
|
|
48
|
+
} | {
|
|
49
|
+
state: string;
|
|
50
|
+
start: number;
|
|
51
|
+
end: number;
|
|
52
|
+
continuation: null;
|
|
53
|
+
total: {
|
|
54
|
+
kind: string;
|
|
55
|
+
};
|
|
56
|
+
}>;
|
|
57
|
+
/** @param {any} sink @param {any} [options] */
|
|
58
|
+
output(sink: any, { selection, signal, pageRows }?: any): Promise<{
|
|
59
|
+
state: string;
|
|
60
|
+
reason: string;
|
|
61
|
+
} | {
|
|
62
|
+
reason?: undefined;
|
|
63
|
+
state: string;
|
|
64
|
+
rows: number;
|
|
65
|
+
error?: undefined;
|
|
66
|
+
} | {
|
|
67
|
+
rows?: undefined;
|
|
68
|
+
state: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
error: unknown;
|
|
71
|
+
}>;
|
|
72
|
+
dispose(): Promise<void>;
|
|
73
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host supplies actual worker/isolate termination. terminate() MUST stop execution
|
|
3
|
+
* and settle request(), including on cancellation. No same-thread deadline is offered.
|
|
4
|
+
* @param {{workerFactory:()=>{request:(message:any)=>Promise<any>,terminate:()=>Promise<any>},timeoutMs?:number,maxInFlight?:number}} options
|
|
5
|
+
*/
|
|
6
|
+
export declare function createFormulaResource(options: {
|
|
7
|
+
workerFactory: () => {
|
|
8
|
+
request: (message: any) => Promise<any>;
|
|
9
|
+
terminate: () => Promise<any>;
|
|
10
|
+
};
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
maxInFlight?: number;
|
|
13
|
+
}): {
|
|
14
|
+
/** Run one bounded batch in an isolate; replies must echo version/generation/revision. */
|
|
15
|
+
run(payload: any, { revision, signal }?: {
|
|
16
|
+
revision?: string | undefined;
|
|
17
|
+
signal?: undefined;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
state: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}> | null;
|
|
22
|
+
/** All admitted work, including terminating requests, counts until drained. */
|
|
23
|
+
stats(): {
|
|
24
|
+
pending: number;
|
|
25
|
+
generation: number;
|
|
26
|
+
disposed: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** Stop admission, terminate every worker and await settlement. Idempotent. */
|
|
29
|
+
dispose(): any;
|
|
30
|
+
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export { createTransactionLog } from './diagnostics.js';
|
|
|
12
12
|
export { createSplitterWidget } from './splitter.js';
|
|
13
13
|
export { createDocStore, encodeShare, decodeShare } from './docstore.js';
|
|
14
14
|
export { AppCompileError, AppRuntimeError, HostValueError, toError, APP_CODES } from './errors.js';
|
|
15
|
+
export { createArrayRangeProvider, createCollectionCoordinator } from './collection.js';
|
|
16
|
+
export { createRunObservation } from './runs.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The injected readPage may invoke a public contract operation. One page is in
|
|
3
|
+
* flight at a time; only current summary/cursor enters app state. No event log
|
|
4
|
+
* or worker resources accumulate here. `wake` can wait for a notification or a
|
|
5
|
+
* bounded poll; after a lost notification the next page resumes the durable cursor.
|
|
6
|
+
* @param {{ readPage: (input: any, context: { signal: AbortSignal }) => any,
|
|
7
|
+
* wake: (signal: AbortSignal) => any, pageSize?: number, maxBytes?: number }} options
|
|
8
|
+
*/
|
|
9
|
+
export declare function createRunObservation(options: {
|
|
10
|
+
readPage: (input: any, context: {
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
}) => any;
|
|
13
|
+
wake: (signal: AbortSignal) => any;
|
|
14
|
+
pageSize?: number;
|
|
15
|
+
maxBytes?: number;
|
|
16
|
+
}): {
|
|
17
|
+
(props: {
|
|
18
|
+
runId: string;
|
|
19
|
+
id: number;
|
|
20
|
+
cursor?: number;
|
|
21
|
+
update: string;
|
|
22
|
+
error: string;
|
|
23
|
+
}, dispatch: (name: string, payload: any) => void): () => void;
|
|
24
|
+
/** Detach every observer; this never calls a run's cancel operation. */
|
|
25
|
+
dispose: () => void;
|
|
26
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Own one worker and one published index. Worker request/reply envelopes carry
|
|
3
|
+
* version, requestId, generation and sourceRevision; build replies carry snapshots.
|
|
4
|
+
* dispose() on the injected worker must settle every request, including aborted ones.
|
|
5
|
+
* @param {import('@jarenjs/core/search').LexicalDefinition} definition
|
|
6
|
+
* @param {{workerFactory:()=>{request:(message:any, options:any)=>Promise<any>, dispose:()=>any},
|
|
7
|
+
* maxInFlight?:number, onProgress?:(progress:any)=>void}} options
|
|
8
|
+
*/
|
|
9
|
+
export declare function createSearchResource(definition: import('@jarenjs/core/search').LexicalDefinition, options: {
|
|
10
|
+
workerFactory: () => {
|
|
11
|
+
request: (message: any, options: any) => Promise<any>;
|
|
12
|
+
dispose: () => any;
|
|
13
|
+
};
|
|
14
|
+
maxInFlight?: number;
|
|
15
|
+
onProgress?: (progress: any) => void;
|
|
16
|
+
}): {
|
|
17
|
+
/** @param {any[]} documents @param {{generation:number, sourceRevision:string, requestId:string}} identity @param {AbortSignal} [signal] */
|
|
18
|
+
build(documents: any[], identity: {
|
|
19
|
+
generation: number;
|
|
20
|
+
sourceRevision: string;
|
|
21
|
+
requestId: string;
|
|
22
|
+
}, signal?: AbortSignal): Promise<{
|
|
23
|
+
generation: number;
|
|
24
|
+
sourceRevision: string;
|
|
25
|
+
requestId: string;
|
|
26
|
+
state: any;
|
|
27
|
+
reason: any;
|
|
28
|
+
} | {
|
|
29
|
+
generation: number;
|
|
30
|
+
sourceRevision: string;
|
|
31
|
+
requestId: string;
|
|
32
|
+
state: string;
|
|
33
|
+
documents: number;
|
|
34
|
+
}>;
|
|
35
|
+
/** @param {string} text @param {any} [options] */
|
|
36
|
+
search(text: string, options?: any): {
|
|
37
|
+
state: any;
|
|
38
|
+
reason?: any;
|
|
39
|
+
generation: number;
|
|
40
|
+
sourceRevision: string;
|
|
41
|
+
identity: any;
|
|
42
|
+
};
|
|
43
|
+
stats(): {
|
|
44
|
+
documents: number;
|
|
45
|
+
vocabulary: number;
|
|
46
|
+
postings: number;
|
|
47
|
+
tokens: number;
|
|
48
|
+
sourceBytes: number;
|
|
49
|
+
indexBytes: number;
|
|
50
|
+
tombstones: number;
|
|
51
|
+
fieldLengths: any[];
|
|
52
|
+
generation: number;
|
|
53
|
+
sourceRevision: string;
|
|
54
|
+
disposed: boolean;
|
|
55
|
+
workers: number;
|
|
56
|
+
pending: number;
|
|
57
|
+
};
|
|
58
|
+
/** Stop admission, fence callbacks, terminate the worker and drain its requests. */
|
|
59
|
+
dispose(): any;
|
|
60
|
+
};
|
package/docs/APP-FORMAT.md
CHANGED
|
@@ -510,6 +510,33 @@ once), and renderer destruction — widgets unmount exactly once, the
|
|
|
510
510
|
container is left empty, and scheduled render flushes become exact
|
|
511
511
|
no-ops.
|
|
512
512
|
|
|
513
|
+
### 8.2.1 DOM profiles and host capability grants
|
|
514
|
+
|
|
515
|
+
`createApp` forwards `safe`, `onUnsafe` and `hydrate` to its DOM renderer
|
|
516
|
+
(VIEW-FORMAT §6/§8). `capabilities` optionally grants names from the host's
|
|
517
|
+
`effects`, `subs`, `widgets` and `eventFields` registries:
|
|
518
|
+
|
|
519
|
+
```js
|
|
520
|
+
const app = createApp(doc, {
|
|
521
|
+
node, safe: true, onUnsafe: report,
|
|
522
|
+
effects: { load },
|
|
523
|
+
capabilities: { effects: ['load'] },
|
|
524
|
+
});
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
When `safe: true` or `capabilities` is supplied, omitted grant lists are empty.
|
|
528
|
+
Unknown or inherited registry names are refused at construction. Only granted
|
|
529
|
+
effects belong to this app's disposal lifecycle. Without either option, the
|
|
530
|
+
existing registered capabilities remain available. Safe mode still removes
|
|
531
|
+
DOM event bindings and widgets; a widget grant does not override that display
|
|
532
|
+
policy. A trusted interactive app can use explicit grants without `safe`.
|
|
533
|
+
|
|
534
|
+
The action vocabulary is local to the app document. Effect/subscription/widget
|
|
535
|
+
registries and custom event fields are host boundaries. These controls bound
|
|
536
|
+
named host access, not CPU, memory, native navigation or network loads; they
|
|
537
|
+
are not a browser security sandbox. Hosted documents still require a deliberate
|
|
538
|
+
trust policy.
|
|
539
|
+
|
|
513
540
|
### 8.3 Transaction observers and diagnostics
|
|
514
541
|
|
|
515
542
|
`app.observe(fn)` delivers one bounded JSON record per settled
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Structural collection providers
|
|
2
|
+
|
|
3
|
+
A provider is a host-injected service. Presentation code consumes its shape;
|
|
4
|
+
storage code never imports app or components. Rows, cursors and handles stay
|
|
5
|
+
private to the provider. App state carries JSON intent and bounded observations.
|
|
6
|
+
|
|
7
|
+
## Database adapter
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { createDbRangeProvider } from '@jarenjs/linq/db';
|
|
11
|
+
|
|
12
|
+
const provider = await createDbRangeProvider(store, 'Row', {
|
|
13
|
+
orderBy: '$it.rank'
|
|
14
|
+
}, { keys: ['id'], maxRows: 256, maxBytes: 262144 });
|
|
15
|
+
try {
|
|
16
|
+
const reply = await provider.request({
|
|
17
|
+
generation: 1, requestId: 'first', query: provider.query,
|
|
18
|
+
snapshot: provider.snapshot, range: { start: 0, end: 20 },
|
|
19
|
+
credits: { pages: 1, rows: 20, bytes: 65536, work: 20 }
|
|
20
|
+
}, signal);
|
|
21
|
+
// Publish only if all echoed identities still match current intent.
|
|
22
|
+
consume(reply);
|
|
23
|
+
} finally { await provider.dispose(); }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`handle.range(spec, options)` is the same adapter. Open the root store with
|
|
27
|
+
committed capture enabled; create the provider from that root, outside a scoped
|
|
28
|
+
transaction. The adapter currently supports captured hybrid entity roots.
|
|
29
|
+
Adopted physical capture and physical keyset paging remain refused.
|
|
30
|
+
|
|
31
|
+
`keys` names distinct stable logical key members. Compound keys are canonical
|
|
32
|
+
JSON tuples; single keys are strings. Keys must exist and be unique in returned
|
|
33
|
+
rows. A load spec supplies filter/order; the provider owns take/skip/after and
|
|
34
|
+
serves root rows without includes. The spec is cloned on construction.
|
|
35
|
+
|
|
36
|
+
## Identities and outcomes
|
|
37
|
+
|
|
38
|
+
Query identity includes source, entity, filter/sort, keys, schema version and
|
|
39
|
+
profile. `source` defaults to a host UUID; custom `query`/`source` strings must
|
|
40
|
+
uniquely identify that source configuration and provider lifetime. Snapshot
|
|
41
|
+
identity is the source epoch, incremented by committed capture or a changed
|
|
42
|
+
database data-version detected at request time. It is not a durable MVCC token.
|
|
43
|
+
External writers are detected on the next request, not polled continuously.
|
|
44
|
+
|
|
45
|
+
Request generation fences superseded client work; it is never a source snapshot.
|
|
46
|
+
Every reply echoes generation, requestId, query and snapshot. The latest admitted
|
|
47
|
+
request supersedes earlier replies, including requests within the same generation.
|
|
48
|
+
Callers must also fence publication against their current intent.
|
|
49
|
+
|
|
50
|
+
A request supplies a half-open `range: { start, end }` or opaque `continuation`,
|
|
51
|
+
and nonnegative safe-integer page/row/byte/work credits. Cancellation is the second
|
|
52
|
+
argument, an injected `AbortSignal`, never a JSON member. Outcomes are:
|
|
53
|
+
|
|
54
|
+
| State | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `ready` | Stable keys, frozen rows, echoed identities, continuation and total are available. |
|
|
57
|
+
| `invalidated` | Query, snapshot, continuation or request identity became stale; start from current source identity. |
|
|
58
|
+
| `budget-exhausted` | Admission or result exceeds finite credits; nothing may be published as complete. |
|
|
59
|
+
| `error` | Invalid input, unsupported seek, cancellation, disposal or source failure. |
|
|
60
|
+
| `loading` | Reserved structural state; this promise-based adapter resolves terminal outcomes. |
|
|
61
|
+
|
|
62
|
+
`total` is `{ kind: 'known', value }` or `{ kind: 'unknown' }`; loaded row count
|
|
63
|
+
never means logical total. Sequential mode offers continuation, live resets and
|
|
64
|
+
unknown total, with no index/key seek or complete export. A nonzero initial index
|
|
65
|
+
refuses `reason: 'unsupported-seek'` without scanning. Full pages use an unknown
|
|
66
|
+
continuation sentinel; a later empty page proves exhaustion, without a hidden
|
|
67
|
+
lookahead row. Continuations are private, bounded and may expire by eviction.
|
|
68
|
+
|
|
69
|
+
Opt-in `resident: true` first reads one complete source, bounded by `maxRows` and
|
|
70
|
+
`maxBytes`, then offers index seek and exact total (`seekIndex: false` and
|
|
71
|
+
`exactTotal: false` can withhold them). Oversize initialization fails and cleans up.
|
|
72
|
+
It never presents a truncated source as complete. A source reset discards the
|
|
73
|
+
resident rows and the next request must requalify the complete bounded source.
|
|
74
|
+
That request must reserve at least `maxRows + 1 + requestedLength` work credits
|
|
75
|
+
before the refill starts; otherwise it returns `budget-exhausted` without reading.
|
|
76
|
+
|
|
77
|
+
## Resources, events and lifecycle
|
|
78
|
+
|
|
79
|
+
Defaults are 256 source/page rows, 262144 bytes, 4 retained continuation handles,
|
|
80
|
+
2 in-flight requests and 8 subscriptions. All bounds are positive safe integers.
|
|
81
|
+
Per-request `used` reports pages, returned rows/bytes and consumed root work,
|
|
82
|
+
including a row consumed at a byte boundary and any resident refill plus slice.
|
|
83
|
+
The provider conservatively reserves array punctuation for the full requested
|
|
84
|
+
length before reading; a near-limit partial page can therefore refuse early.
|
|
85
|
+
`stats()` separately reports source reads/rows/bytes, resident storage, retained
|
|
86
|
+
handles, pending requests and subscriptions. SQL visited rows and internal database
|
|
87
|
+
allocations are unavailable; these credits measure admitted application work.
|
|
88
|
+
Source bytes count consumed root payloads, including an item refused at the byte
|
|
89
|
+
boundary. `page(..., { lookahead: false })` exposes these counts as `work: { rows,
|
|
90
|
+
bytes }`; default pages preserve their existing shape and one-row lookahead.
|
|
91
|
+
|
|
92
|
+
`subscribe(fn)` returns an unsubscribe function. Events carry monotone revision,
|
|
93
|
+
query/snapshot and explicit `type: 'reset'`, with source capture qualification.
|
|
94
|
+
These events do not imply incremental keyed maintenance. Keyed insert/update/
|
|
95
|
+
delete/move events are reserved for sources that independently prove them.
|
|
96
|
+
|
|
97
|
+
`dispose()` stops admission, unsubscribes, cancels and drains pending requests,
|
|
98
|
+
clears rows/handles and fences late callbacks. It is idempotent. A provider must
|
|
99
|
+
be disposed before closing its store.
|
|
100
|
+
|
|
101
|
+
One host coordinator owns finite prefetch, request and page/byte credits; one
|
|
102
|
+
component owns DOM/cell/pin and measurement credits. Selection is stable-key or
|
|
103
|
+
query/snapshot-scoped intent with exclusions. The authoritative mutation rechecks
|
|
104
|
+
membership and revision. Focus/scroll restoration carries key, offset and query
|
|
105
|
+
identity, plus a declared fallback when the key disappears. Complete print/export
|
|
106
|
+
requires a separate bounded stream over a declared complete snapshot; mounted
|
|
107
|
+
rows cannot prove completeness. Sequential mode declares `completeExport: false`; bounded resident mode supplies the complete snapshot stream described below.
|
|
108
|
+
|
|
109
|
+
## App coordinator and resident arrays
|
|
110
|
+
|
|
111
|
+
`createArrayRangeProvider(rows, options)` creates an immutable resident source copy
|
|
112
|
+
and key index. This full source remains resident, and `stats().rows/bytes` reports
|
|
113
|
+
it honestly. `keyOf` defaults to the string form of `row.id`. Query/source identities
|
|
114
|
+
default to a new host UUID; `runtime`, `source`, `query` and `snapshot` can be
|
|
115
|
+
injected. Explicit query identities must distinguish source, ordering/filter and
|
|
116
|
+
relevant profile/schema versions. `replace(rows,newSnapshot)` requires a new source
|
|
117
|
+
identity, increments event revision and resets cursors. Return values are copied
|
|
118
|
+
so consumers cannot mutate the source snapshot. `indexOf(key)` uses the resident
|
|
119
|
+
index. `seekIndex:false` and `exactTotal:false` exercise restricted capabilities.
|
|
120
|
+
|
|
121
|
+
`createCollectionCoordinator(provider, options)` privately owns cancellation,
|
|
122
|
+
generation/request fencing and caches. Defaults: 64 rows/page, 4 cached pages,
|
|
123
|
+
256 cached rows, 262144 cached bytes, 2 in-flight requests, 256 work credits/request,
|
|
124
|
+
one simultaneous output (`maxOutputs`), eight subscribers (`maxSubscriptions`),
|
|
125
|
+
and zero prefetch pages. `prefetchPages` is finite, below the page limit and clamped
|
|
126
|
+
to row credits. `requestRange({start,end},signal)` refuses oversized ranges before
|
|
127
|
+
source work. Every response must echo all four identities and fit row/byte/work
|
|
128
|
+
credits before it can publish. A provider that ignores cancellation still cannot
|
|
129
|
+
replace current rows. Source events need a monotone integer revision; this
|
|
130
|
+
coordinator treats keyed events conservatively as reset rather than claiming
|
|
131
|
+
incremental maintenance. `reset` fences old work and clears cached resources.
|
|
132
|
+
|
|
133
|
+
`observation()` and `subscribe` expose JSON query/snapshot/generation, status,
|
|
134
|
+
logical total and bounded loaded row/byte counts. `rowAt`, `keyAt` and `indexOf`
|
|
135
|
+
access private cache rows. `logicalCount()` returns the known total, or the loaded
|
|
136
|
+
frontier plus a continuation sentinel; it never manufactures a known total.
|
|
137
|
+
`next(signal)` requests the next sequential page. A sequential source refuses
|
|
138
|
+
arbitrary jumps as `unsupported-seek` without scanning. `pinKeys(keys)` protects
|
|
139
|
+
editor pages within existing page/row/byte limits; an impossible admission returns
|
|
140
|
+
`budget-exhausted / pinned-page-credits`. `stats()` separately reports cache pages,
|
|
141
|
+
rows/bytes, in-flight requests and outputs. By default async `dispose` drains both
|
|
142
|
+
the coordinator and provider; `disposeProvider:false` retains host ownership.
|
|
143
|
+
|
|
144
|
+
## Transactional export and print sinks
|
|
145
|
+
|
|
146
|
+
Complete sources expose `export({query,snapshot,pageRows,pageBytes},signal)` as an
|
|
147
|
+
async iterable. Each page is `{state:'ready',query,snapshot,rows,keys}`; the terminal
|
|
148
|
+
record is `{state:'complete',query,snapshot,total}`. The terminal count proves how
|
|
149
|
+
many source rows were read, independently of selection. A missing terminal record,
|
|
150
|
+
changed identity, byte/row overflow, cancellation or missing selected key/range
|
|
151
|
+
endpoint is an incomplete output and must not be reported as success.
|
|
152
|
+
|
|
153
|
+
The coordinator's `output(sink,{selection,signal,pageRows})` awaits `begin(identity)`,
|
|
154
|
+
serial `write(rows)` calls and finally `commit({query,snapshot,rows})`. Any failure
|
|
155
|
+
calls `abort(error)` and returns `error / incomplete-export`. The sink must stage
|
|
156
|
+
work privately and make it visible only at commit. It supplies its own finite
|
|
157
|
+
spool or streaming storage; building a whole output array is not implicitly
|
|
158
|
+
bounded. Backpressure is the awaited `write` promise. The same sink contract
|
|
159
|
+
supports printing a completed snapshot artifact. Disposal cancels and drains
|
|
160
|
+
outstanding output before clearing resources.
|
|
161
|
+
|
|
162
|
+
Arrays support complete output at their immutable snapshot. SQLite resident mode
|
|
163
|
+
also supports complete output over its already qualified bounded source; it checks
|
|
164
|
+
source data-version before and after export, and refuses a changed/unloaded epoch.
|
|
165
|
+
Sequential database mode continues to refuse complete export. The website download
|
|
166
|
+
sink has a finite spool, so an oversized output fails before creating a download.
|
package/docs/SEARCH.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Search resource ownership
|
|
2
|
+
|
|
3
|
+
`createSearchResource` from `@jarenjs/app/search` owns one private resident index
|
|
4
|
+
and an injected worker. JSON application state holds search intent, progress,
|
|
5
|
+
result IDs and observations. It never holds index handles, worker objects,
|
|
6
|
+
controllers or cached source rows.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
import { createSearchResource } from '@jarenjs/app/search';
|
|
10
|
+
// workerFactory is the host's injected service; dispose must settle its requests.
|
|
11
|
+
const resource = createSearchResource({ version: 1, fields: ['title', 'sku'] }, {
|
|
12
|
+
workerFactory, maxInFlight: 2, onProgress: progress => publishProgress(progress),
|
|
13
|
+
});
|
|
14
|
+
await resource.build(rows, { generation: 1, requestId: 'build-1', sourceRevision: 'catalog-1' });
|
|
15
|
+
const result = resource.search('gren tea', { limit: 20 });
|
|
16
|
+
await resource.dispose();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The factory supplies `{request(message, {signal, onProgress}), dispose()}`.
|
|
20
|
+
Build messages have `version: 1`, `operation: 'build'`, the lexical definition,
|
|
21
|
+
projected string documents, and all three identities. Transport admission checks
|
|
22
|
+
document/field/source and temporary credits before handing text to the worker.
|
|
23
|
+
The host worker runs the public core engine and responds with matching identities,
|
|
24
|
+
`state: 'complete'` and a serialized `snapshot`. Progress carries those same
|
|
25
|
+
identities plus nonnegative integer `work`. A host can use Web Workers or a server
|
|
26
|
+
worker; no worker constructor or runtime is imported by this capability.
|
|
27
|
+
|
|
28
|
+
An incomplete or corrupt snapshot never publishes. Switching builds aborts older
|
|
29
|
+
requests, fences late replies and retains finite in-flight admission credits.
|
|
30
|
+
Cancelled and superseded progress is ignored. Worker errors are explicit outcomes.
|
|
31
|
+
`dispose()` stops admission, aborts requests, closes the worker, drains settlements,
|
|
32
|
+
and releases all index/source references. The worker's disposal contract must
|
|
33
|
+
settle outstanding requests; a worker ignoring termination cannot prove drained
|
|
34
|
+
teardown. Enforce a host memory ceiling in addition to the core's logical credits.
|
|
35
|
+
|
|
36
|
+
For query/collection composition use the injected query provider and the existing
|
|
37
|
+
collection coordinator. Search resources do not create another application
|
|
38
|
+
scheduler, evaluator, catalog or range cache.
|
|
39
|
+
|
|
40
|
+
Build generations must increase strictly, including retries after a failed worker
|
|
41
|
+
request. Progress is fenced by identity and never regresses within a request.
|
package/docs/TASKS.md
CHANGED
|
@@ -254,3 +254,28 @@ above validates against the shipped
|
|
|
254
254
|
A pattern that needed a schema extension would be a format change; this
|
|
255
255
|
one is proof the existing vocabulary already carries request identity
|
|
256
256
|
and staleness rejection.
|
|
257
|
+
|
|
258
|
+
## Observe an existing durable run
|
|
259
|
+
|
|
260
|
+
`createRunObservation({ readPage, wake, pageSize?, maxBytes? })` is an app
|
|
261
|
+
subscription factory, used as `subs: { run: observation }`. The subscription
|
|
262
|
+
props name `runId`, attempt `id`, saved `cursor` and `update`/`error` actions.
|
|
263
|
+
`readPage({ id, after, limit }, { signal })` reads one bounded public event page;
|
|
264
|
+
`wake(signal)` waits for the next notification or the host's bounded poll.
|
|
265
|
+
Only one page is outstanding and only current status, summary, revision and
|
|
266
|
+
cursor enter app state. The update action must guard the attempt ID and reject
|
|
267
|
+
older revisions, as for task completion. No accumulated event log enters state.
|
|
268
|
+
|
|
269
|
+
The cleanup returned by a subscription and `observation.dispose()` detach
|
|
270
|
+
observers. They do not cancel the durable run. Lost notifications recover through
|
|
271
|
+
the saved revision cursor; missing history emits `reset: true` and a fresh
|
|
272
|
+
summary. Malformed/oversized pages and read failures emit only
|
|
273
|
+
`{ code: "run-observation-failed" }`, never exception text or provider credentials.
|
|
274
|
+
The host projection decides which domain summary fields are public.
|
|
275
|
+
|
|
276
|
+
`createRunPageHandler({ page, authorize, maxPage? })` from `@jarenjs/contract/app`
|
|
277
|
+
is an ordinary read handler that checks current authority before touching durable
|
|
278
|
+
history. Inject `createDbRunStore(...).page` into it, expose it through a compiled
|
|
279
|
+
read operation, and have `readPage` invoke that operation through any public
|
|
280
|
+
contract client. Explicit cancellation is a separate authorized command; navigation
|
|
281
|
+
only removes the subscription.
|
package/package.json
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/app",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.83.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
8
8
|
"sideEffects": false,
|
|
9
9
|
"exports": {
|
|
10
|
+
"./search": {
|
|
11
|
+
"types": "./dist/types/search.d.ts",
|
|
12
|
+
"default": "./src/search.js"
|
|
13
|
+
},
|
|
10
14
|
".": {
|
|
11
15
|
"types": "./dist/types/index.d.ts",
|
|
12
16
|
"default": "./src/index.js"
|
|
13
17
|
},
|
|
14
18
|
"./schemas/*": "./schemas/*",
|
|
15
|
-
"./package.json": "./package.json"
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./formula": {
|
|
21
|
+
"types": "./dist/types/formula.d.ts",
|
|
22
|
+
"default": "./src/formula.js"
|
|
23
|
+
}
|
|
16
24
|
},
|
|
17
25
|
"files": [
|
|
18
26
|
"dist/types/",
|
|
@@ -50,8 +58,8 @@
|
|
|
50
58
|
"prepack": "npm run build:types"
|
|
51
59
|
},
|
|
52
60
|
"dependencies": {
|
|
53
|
-
"@jarenjs/core": "^0.
|
|
54
|
-
"@jarenjs/json": "^0.
|
|
55
|
-
"@jarenjs/view": "^0.
|
|
61
|
+
"@jarenjs/core": "^0.83.3",
|
|
62
|
+
"@jarenjs/json": "^0.83.3",
|
|
63
|
+
"@jarenjs/view": "^0.83.3"
|
|
56
64
|
}
|
|
57
65
|
}
|
package/src/app.js
CHANGED
|
@@ -46,6 +46,13 @@ import { AppCompileError, AppRuntimeError, toError, safeErrorMessage } from './e
|
|
|
46
46
|
* app (drive it via `getVnode`/`subscribe`).
|
|
47
47
|
* @property {any} [document] - The DOM document (defaults to
|
|
48
48
|
* `node.ownerDocument`).
|
|
49
|
+
* @property {boolean} [hydrate] - Adopt existing DOM on the first frame.
|
|
50
|
+
* @property {boolean} [safe] - Forward the inert view render profile. In
|
|
51
|
+
* this mode host capabilities default to empty allow-lists.
|
|
52
|
+
* @property {import('@jarenjs/view').DomRendererOptions['onUnsafe']} [onUnsafe]
|
|
53
|
+
* @property {{ effects?: string[], subs?: string[], widgets?: string[], eventFields?: string[] }} [capabilities]
|
|
54
|
+
* Restrict the host registry names the document may access. Omitted
|
|
55
|
+
* lists grant nothing when this option or `safe` is enabled.
|
|
49
56
|
* @property {Record<string, (props: any, dispatch: Dispatch) => void>} [effects]
|
|
50
57
|
* Effect handlers by name. A handler function may carry an optional
|
|
51
58
|
* `dispose()` member, called exactly once by `app.destroy()` (a
|
|
@@ -211,9 +218,21 @@ export function createApp(appDoc, options = {}) {
|
|
|
211
218
|
const actions = compileActions(appDoc.actions, queryOptions);
|
|
212
219
|
const subs = compileSubs(appDoc.subs, queryOptions);
|
|
213
220
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
const grant = (kind) => {
|
|
222
|
+
const registry = options[kind] ?? {};
|
|
223
|
+
if (!options.safe && options.capabilities === undefined) return registry;
|
|
224
|
+
const names = options.capabilities?.[kind] ?? [];
|
|
225
|
+
if (!Array.isArray(names) || names.some((name) => typeof name !== 'string'))
|
|
226
|
+
throw new TypeError(`createApp: capabilities.${kind} must be a list of names`);
|
|
227
|
+
return Object.fromEntries(names.map((name) => {
|
|
228
|
+
if (!Object.hasOwn(registry, name)) throw new TypeError(`createApp: unknown ${kind} capability '${name}'`);
|
|
229
|
+
return [name, registry[name]];
|
|
230
|
+
}));
|
|
231
|
+
};
|
|
232
|
+
const effectHandlers = grant('effects');
|
|
233
|
+
const subHandlers = grant('subs');
|
|
234
|
+
const eventExtractors = grant('eventFields');
|
|
235
|
+
const widgets = grant('widgets');
|
|
217
236
|
const onError = options.onError ?? ((err) => { throw err; });
|
|
218
237
|
const schedule = options.schedule ?? ((flush) => queueMicrotask(flush));
|
|
219
238
|
const afterRender = options.afterRender ?? null;
|
|
@@ -1225,7 +1244,10 @@ export function createApp(appDoc, options = {}) {
|
|
|
1225
1244
|
renderer = createDomRenderer(options.node, {
|
|
1226
1245
|
document: options.document,
|
|
1227
1246
|
onEvent: handleBinding,
|
|
1228
|
-
widgets
|
|
1247
|
+
widgets,
|
|
1248
|
+
safe: options.safe,
|
|
1249
|
+
hydrate: options.hydrate,
|
|
1250
|
+
onUnsafe: options.onUnsafe,
|
|
1229
1251
|
// terminal-cleanup provenance: a widget unmount that throws
|
|
1230
1252
|
// during renderer teardown — deferred teardown after an
|
|
1231
1253
|
// app.destroy() from inside a hook included — is a CLEANUP
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Injected range coordination. Rows and handles remain private; observations contain JSON only. */
|
|
3
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
4
|
+
import { rangeBytes as bytesOf, rangeIdentity as identityOf, validRangeCredits as validCredits, validLogicalRange as validRange } from '@jarenjs/core/range';
|
|
5
|
+
export { createArrayRangeProvider } from '@jarenjs/core/range';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Coordinate an injected provider with finite pages, bytes, requests and prefetch. The host owns
|
|
9
|
+
* provider construction; by default coordinator disposal also drains the provider.
|
|
10
|
+
* @param {any} provider @param {any} [options]
|
|
11
|
+
*/
|
|
12
|
+
export function createCollectionCoordinator(provider, options = {}) {
|
|
13
|
+
const bounds = { pageRows: 64, maxPages: 4, maxRows: 256, maxBytes: 262144, maxInFlight: 2, maxOutputs: 1, maxSubscriptions: 8, prefetchPages: 0, work: 256, ...options };
|
|
14
|
+
for (const name of ['pageRows', 'maxPages', 'maxRows', 'maxBytes', 'maxInFlight', 'maxOutputs', 'maxSubscriptions', 'work'])
|
|
15
|
+
if (!Number.isSafeInteger(bounds[name]) || bounds[name] <= 0) throw new RangeError(`Invalid ${name}`);
|
|
16
|
+
if (!Number.isSafeInteger(bounds.prefetchPages) || bounds.prefetchPages < 0 || bounds.prefetchPages >= bounds.maxPages
|
|
17
|
+
|| bounds.pageRows > bounds.maxRows) throw new RangeError('Invalid prefetch credits');
|
|
18
|
+
let query = provider.query, snapshot = provider.snapshot, generation = 1, ticket = 0, disposed = false, revision = -1;
|
|
19
|
+
let observation = { state: 'loading', query, snapshot, generation, total: { kind: 'unknown' }, loadedRows: 0, loadedBytes: 0 };
|
|
20
|
+
let cursor = null, frontier = 0, exhausted = false;
|
|
21
|
+
const pages = new Map(), pending = new Map(), subscribers = new Set(), outputs = new Set();
|
|
22
|
+
let pinnedKeys = new Set();
|
|
23
|
+
function stats() { return { pages: pages.size, rows: [...pages.values()].reduce((n, p) => n + p.rows.length, 0),
|
|
24
|
+
bytes: [...pages.values()].reduce((n, p) => n + p.bytes, 0), inFlight: pending.size, outputs: outputs.size }; }
|
|
25
|
+
function publish(change) {
|
|
26
|
+
if (disposed) return;
|
|
27
|
+
const cost = stats(); observation = { ...observation, ...change, query, snapshot, generation, loadedRows: cost.rows, loadedBytes: cost.bytes };
|
|
28
|
+
for (const key of Object.keys(observation)) if (observation[key] === undefined) delete observation[key];
|
|
29
|
+
for (const fn of subscribers) { try { fn(structuredClone(observation)); } catch (error) { options.onError?.(error); } }
|
|
30
|
+
try { options.onChange?.(structuredClone(observation)); } catch (error) { options.onError?.(error); }
|
|
31
|
+
}
|
|
32
|
+
function cancel() { for (const controller of pending.keys()) controller.abort(); }
|
|
33
|
+
function reset(identity = {}) {
|
|
34
|
+
generation++; ticket++; cancel(); pages.clear(); cursor = null; frontier = 0; exhausted = false;
|
|
35
|
+
query = identity.query ?? provider.query; snapshot = identity.snapshot ?? provider.snapshot;
|
|
36
|
+
publish({ state: 'invalidated', total: { kind: 'unknown' } });
|
|
37
|
+
}
|
|
38
|
+
const unsubscribe = provider.subscribe?.((event) => {
|
|
39
|
+
if (disposed || event.query !== query || !Number.isSafeInteger(event.revision) || event.revision <= revision) return;
|
|
40
|
+
revision = event.revision;
|
|
41
|
+
// A patch shape alone does not prove incremental maintenance; reset safely at the source epoch.
|
|
42
|
+
reset(event);
|
|
43
|
+
});
|
|
44
|
+
async function load(start, continuation, current, signal) {
|
|
45
|
+
const request = { generation, requestId: String(current), query, snapshot,
|
|
46
|
+
...(continuation ? { continuation } : { range: { start, end: start + bounds.pageRows } }),
|
|
47
|
+
credits: { pages: 1, rows: bounds.pageRows, bytes: bounds.maxBytes, work: bounds.work } };
|
|
48
|
+
const identity = identityOf(request);
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const abort = () => controller.abort(signal?.reason);
|
|
51
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
52
|
+
if (signal?.aborted) abort();
|
|
53
|
+
const run = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const response = await provider.request(request, controller.signal);
|
|
56
|
+
if (disposed || current !== ticket || request.generation !== generation || controller.signal.aborted)
|
|
57
|
+
return { state: 'invalidated', reason: 'superseded' };
|
|
58
|
+
if (Object.keys(identity).some((key) => response?.[key] !== identity[key])) {
|
|
59
|
+
publish({ state: 'invalidated', reason: 'identity-mismatch' });
|
|
60
|
+
return { state: 'invalidated', reason: 'identity-mismatch' };
|
|
61
|
+
}
|
|
62
|
+
if (response.state !== 'ready') { publish({ state: response.state, reason: response.reason }); return response; }
|
|
63
|
+
const bytes = bytesOf(response.rows);
|
|
64
|
+
if (!Array.isArray(response.rows) || !Array.isArray(response.keys) || response.rows.length !== response.keys.length
|
|
65
|
+
|| response.rows.length > bounds.pageRows || bytes > bounds.maxBytes || new Set(response.keys).size !== response.keys.length
|
|
66
|
+
|| !response.keys.every((key) => typeof key === 'string') || !validCredits(response.used)
|
|
67
|
+
|| Object.keys(request.credits).some((key) => response.used[key] > request.credits[key])
|
|
68
|
+
|| response.used.bytes !== bytes || response.used.rows !== response.rows.length
|
|
69
|
+
|| !['known', 'unknown'].includes(response.total?.kind)
|
|
70
|
+
|| (response.total.kind === 'known' && (!Number.isSafeInteger(response.total.value) || response.total.value < start + response.rows.length)))
|
|
71
|
+
{ publish({ state: 'error', reason: 'invalid-response' }); return { state: 'error', reason: 'invalid-response' }; }
|
|
72
|
+
pages.delete(start);
|
|
73
|
+
while (pages.size && (pages.size >= bounds.maxPages || stats().rows + response.rows.length > bounds.maxRows || stats().bytes + bytes > bounds.maxBytes)) {
|
|
74
|
+
const evict = [...pages].find(([, page]) => !page.keys.some((key) => pinnedKeys.has(key)));
|
|
75
|
+
if (!evict) { publish({state:'budget-exhausted',reason:'pinned-page-credits'}); return {state:'budget-exhausted',reason:'pinned-page-credits'}; }
|
|
76
|
+
pages.delete(evict[0]);
|
|
77
|
+
}
|
|
78
|
+
pages.set(start, { rows: deepFreeze(structuredClone(response.rows)), keys: response.keys.slice(), bytes });
|
|
79
|
+
if (start >= frontier) { frontier = start + response.rows.length; cursor = response.continuation; exhausted = !cursor; }
|
|
80
|
+
publish({ state: 'ready', reason: undefined, total: response.total });
|
|
81
|
+
return { state: 'ready', start, end: start + response.rows.length, continuation: response.continuation, total: response.total };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (disposed || current !== ticket) return { state: 'invalidated', reason: 'superseded' };
|
|
85
|
+
publish({ state: 'error', reason: 'source-failure' }); return { state: 'error', reason: 'source-failure', error };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const promise = Promise.resolve().then(run).finally(() => { pending.delete(controller); signal?.removeEventListener('abort', abort); });
|
|
89
|
+
pending.set(controller, promise); return promise;
|
|
90
|
+
}
|
|
91
|
+
async function requestRange(range, signal) {
|
|
92
|
+
if (disposed) return { state: 'error', reason: 'disposed' };
|
|
93
|
+
if (!validRange(range)) return { state: 'error', reason: 'invalid-range' };
|
|
94
|
+
if (signal?.aborted) return { state: 'error', reason: 'cancelled' };
|
|
95
|
+
const first = provider.capabilities.seekIndex ? Math.floor(range.start / bounds.pageRows) * bounds.pageRows : range.start;
|
|
96
|
+
if (!provider.capabilities.seekIndex && first !== 0 && !(first === frontier && cursor))
|
|
97
|
+
return { state: 'error', reason: 'unsupported-seek' };
|
|
98
|
+
const count = Math.ceil((range.end - first) / bounds.pageRows);
|
|
99
|
+
if (count > bounds.maxPages || count * bounds.pageRows > bounds.maxRows) return { state: 'budget-exhausted', reason: 'page-credits' };
|
|
100
|
+
cancel(); const current = ++ticket;
|
|
101
|
+
if (pending.size >= bounds.maxInFlight) return { state: 'budget-exhausted', reason: 'in-flight' };
|
|
102
|
+
publish({ state: 'loading', reason: undefined });
|
|
103
|
+
let result = { state: 'ready' };
|
|
104
|
+
const admitted = Math.min(Math.floor(bounds.maxRows / bounds.pageRows), bounds.maxPages, count + (provider.capabilities.seekIndex ? bounds.prefetchPages : 0));
|
|
105
|
+
for (let page = 0; page < admitted; page++) {
|
|
106
|
+
const start = first + page * bounds.pageRows;
|
|
107
|
+
result = await load(start, provider.capabilities.seekIndex ? null : start === 0 ? null : cursor, current, signal);
|
|
108
|
+
if (result.state !== 'ready' || current !== ticket || disposed) return result;
|
|
109
|
+
if (result.end - result.start < bounds.pageRows || !result.continuation) break;
|
|
110
|
+
}
|
|
111
|
+
if (count === 0) publish({ state: 'ready' });
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const coordinator = {
|
|
115
|
+
provider, requestRange, reset, stats,
|
|
116
|
+
pinKeys(keys = []) {
|
|
117
|
+
if (!Array.isArray(keys) || keys.length > bounds.maxRows || !keys.every((key) => typeof key === 'string'))
|
|
118
|
+
return {state:'budget-exhausted',reason:'pin-credits'};
|
|
119
|
+
pinnedKeys = new Set(keys); return {state:'ready'};
|
|
120
|
+
},
|
|
121
|
+
observation() { return structuredClone(observation); },
|
|
122
|
+
subscribe(fn) { if (disposed) throw new Error('Coordinator disposed');
|
|
123
|
+
if (subscribers.size >= bounds.maxSubscriptions) throw new RangeError('Subscription credits');
|
|
124
|
+
subscribers.add(fn); return () => subscribers.delete(fn); },
|
|
125
|
+
rowAt(index) { for (const [start, page] of pages) if (index >= start && index < start + page.rows.length) return page.rows[index - start]; },
|
|
126
|
+
keyAt(index) { for (const [start, page] of pages) if (index >= start && index < start + page.rows.length) return page.keys[index - start]; return null; },
|
|
127
|
+
indexOf(key) {
|
|
128
|
+
for (const [start, page] of pages) { const i = page.keys.indexOf(key); if (i >= 0) return start + i; }
|
|
129
|
+
return provider.capabilities.seekKey ? provider.indexOf?.(key) ?? -1 : -1;
|
|
130
|
+
},
|
|
131
|
+
logicalCount() { return observation.total.kind === 'known' ? observation.total.value : frontier + (exhausted ? 0 : 1); },
|
|
132
|
+
async next(signal) { if (exhausted) return {state:'ready',start:frontier,end:frontier,continuation:null,total:observation.total}; return requestRange({ start: frontier, end: frontier + bounds.pageRows }, signal); },
|
|
133
|
+
/** @param {any} sink @param {any} [options] */
|
|
134
|
+
async output(sink, { selection, signal, pageRows = bounds.pageRows } = {}) {
|
|
135
|
+
if (disposed || !provider.capabilities.completeExport || typeof provider.export !== 'function')
|
|
136
|
+
return { state: 'error', reason: disposed ? 'disposed' : 'unsupported-export' };
|
|
137
|
+
if (outputs.size >= bounds.maxOutputs) return {state:'budget-exhausted',reason:'output-credits'};
|
|
138
|
+
if (selection && (selection.mode === 'all' || selection.ranges?.length) && (selection.query !== query || selection.snapshot !== snapshot))
|
|
139
|
+
return { state: 'invalidated', reason: 'selection-snapshot' };
|
|
140
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
141
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
142
|
+
const identity = { query, snapshot }, wanted = new Set(selection?.keys ?? []), excluded = new Set(selection?.exclusions ?? []);
|
|
143
|
+
const ranges = (selection?.ranges ?? []).map((range) => ({ ...range, open: false, found: 0 }));
|
|
144
|
+
let read = 0, written = 0, completed = false;
|
|
145
|
+
const operation = Promise.resolve().then(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await sink.begin?.(identity);
|
|
148
|
+
for await (const page of provider.export({ ...identity, pageRows, pageBytes: bounds.maxBytes }, controller.signal)) {
|
|
149
|
+
if (disposed || controller.signal.aborted || query !== identity.query || snapshot !== identity.snapshot
|
|
150
|
+
|| page.query !== identity.query || page.snapshot !== identity.snapshot || completed) throw new Error('Incomplete snapshot export');
|
|
151
|
+
if (page.state === 'complete') { if (page.total !== read) throw new Error('Incomplete snapshot total'); completed = true; continue; }
|
|
152
|
+
if (page.state !== 'ready' || page.rows.length !== page.keys.length || page.rows.length > pageRows || bytesOf(page.rows) > bounds.maxBytes)
|
|
153
|
+
throw new Error('Invalid export page');
|
|
154
|
+
const output = [];
|
|
155
|
+
for (let i = 0; i < page.rows.length; i++) {
|
|
156
|
+
const key = page.keys[i]; let inRange = false;
|
|
157
|
+
for (const range of ranges) {
|
|
158
|
+
const endpoint = key === range.fromKey || key === range.toKey;
|
|
159
|
+
inRange ||= range.open || endpoint;
|
|
160
|
+
if (endpoint) { range.found++; range.open = !range.open; if (range.fromKey === range.toKey) { range.open = false; range.found++; } }
|
|
161
|
+
}
|
|
162
|
+
const selected = !selection || (selection.mode === 'all' && !excluded.has(key)) || wanted.has(key) || inRange;
|
|
163
|
+
wanted.delete(key); read++;
|
|
164
|
+
if (selected) { output.push(page.rows[i]); written++; }
|
|
165
|
+
}
|
|
166
|
+
if (output.length) await sink.write(output);
|
|
167
|
+
}
|
|
168
|
+
if (!completed || wanted.size || ranges.some((range) => range.found !== 2)) throw new Error('Incomplete selection export');
|
|
169
|
+
if (disposed || controller.signal.aborted || query !== identity.query || snapshot !== identity.snapshot) throw new Error('Incomplete snapshot export');
|
|
170
|
+
await sink.commit({ ...identity, rows: written }); return { state: 'complete', rows: written };
|
|
171
|
+
}
|
|
172
|
+
catch (error) { await sink.abort?.(error); return { state: 'error', reason: 'incomplete-export', error }; }
|
|
173
|
+
});
|
|
174
|
+
outputs.add({ controller, operation });
|
|
175
|
+
try { return await operation; }
|
|
176
|
+
finally { signal?.removeEventListener('abort', abort); for (const item of outputs) if (item.operation === operation) outputs.delete(item); }
|
|
177
|
+
},
|
|
178
|
+
async dispose() {
|
|
179
|
+
let failure, failed = false;
|
|
180
|
+
if (!disposed) {
|
|
181
|
+
disposed = true; ticket++; cancel(); subscribers.clear();
|
|
182
|
+
try { unsubscribe?.(); } catch (error) { failure = error; failed = true; }
|
|
183
|
+
for (const item of outputs) item.controller.abort();
|
|
184
|
+
}
|
|
185
|
+
await Promise.allSettled([...pending.values(), ...[...outputs].map((item) => item.operation)]);
|
|
186
|
+
pages.clear(); cursor = null; pinnedKeys.clear();
|
|
187
|
+
if (options.disposeProvider !== false) { try { await provider.dispose(); } catch (error) { if (!failed) { failure = error; failed = true; } } }
|
|
188
|
+
if (failed) throw failure;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
return coordinator;
|
|
192
|
+
}
|
package/src/formula.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Own terminating formula workers; generation fences prevent stale publication. */
|
|
3
|
+
import { cloneJson, deepFreeze } from '@jarenjs/core/object';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A host supplies actual worker/isolate termination. terminate() MUST stop execution
|
|
8
|
+
* and settle request(), including on cancellation. No same-thread deadline is offered.
|
|
9
|
+
* @param {{workerFactory:()=>{request:(message:any)=>Promise<any>,terminate:()=>Promise<any>},timeoutMs?:number,maxInFlight?:number}} options
|
|
10
|
+
*/
|
|
11
|
+
export function createFormulaResource(options) {
|
|
12
|
+
const timeoutMs = options?.timeoutMs ?? 1000;
|
|
13
|
+
const maxInFlight = options?.maxInFlight ?? 2;
|
|
14
|
+
if (typeof options?.workerFactory !== 'function' || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647
|
|
15
|
+
|| !Number.isSafeInteger(maxInFlight) || maxInFlight < 1) throw new TypeError('Invalid terminating formula worker capability');
|
|
16
|
+
let generation = 0, disposed = false, teardown = null;
|
|
17
|
+
const pending = new Set();
|
|
18
|
+
return {
|
|
19
|
+
/** Run one bounded batch in an isolate; replies must echo version/generation/revision. */
|
|
20
|
+
run(payload, { revision = '', signal = undefined } = {}) {
|
|
21
|
+
if (disposed || signal?.aborted) return Promise.resolve({ state: 'error', reason: disposed ? 'disposed' : 'cancelled' });
|
|
22
|
+
if (pending.size >= maxInFlight) return Promise.resolve({ state: 'error', reason: 'in-flight' });
|
|
23
|
+
canonicalizeJson(payload);
|
|
24
|
+
if (typeof revision !== 'string') throw new TypeError('revision must be a string');
|
|
25
|
+
const message = deepFreeze(cloneJson({ version: 1, generation: ++generation, revision, payload }));
|
|
26
|
+
for (const active of pending) active.stop('superseded');
|
|
27
|
+
let worker, request, termination, reason = null, timer;
|
|
28
|
+
let stopped;
|
|
29
|
+
const stopPromise = new Promise((resolve) => { stopped = resolve; });
|
|
30
|
+
const terminate = () => termination ??= Promise.resolve().then(() => worker?.terminate());
|
|
31
|
+
const entry = { promise: null, stop(why) { reason ??= why; stopped(); } };
|
|
32
|
+
const abort = () => entry.stop('cancelled');
|
|
33
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
34
|
+
pending.add(entry);
|
|
35
|
+
entry.promise = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
worker = options.workerFactory();
|
|
38
|
+
if (typeof worker?.request !== 'function' || typeof worker?.terminate !== 'function') throw new TypeError('Invalid formula worker');
|
|
39
|
+
request = Promise.resolve().then(() => worker.request(message));
|
|
40
|
+
timer = setTimeout(() => entry.stop('deadline'), timeoutMs);
|
|
41
|
+
const reply = await Promise.race([request, stopPromise]);
|
|
42
|
+
if (reason !== null) return { state: 'error', reason, generation: message.generation, revision };
|
|
43
|
+
if (disposed || generation !== message.generation) return { state: 'invalidated', reason: 'superseded', generation: message.generation, revision };
|
|
44
|
+
if (reply?.version !== 1 || reply.generation !== message.generation || reply.revision !== revision)
|
|
45
|
+
return { state: 'invalidated', reason: 'worker-identity', generation: message.generation, revision };
|
|
46
|
+
canonicalizeJson(reply.result);
|
|
47
|
+
return deepFreeze(cloneJson({ state: 'complete', generation: message.generation, revision, result: reply.result }));
|
|
48
|
+
}
|
|
49
|
+
catch { return { state: 'error', reason: reason ?? 'worker-failed', generation: message.generation, revision }; }
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
signal?.removeEventListener('abort', abort);
|
|
53
|
+
try { await terminate(); }
|
|
54
|
+
finally { await Promise.allSettled([request]); pending.delete(entry); }
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return entry.promise;
|
|
58
|
+
},
|
|
59
|
+
/** All admitted work, including terminating requests, counts until drained. */
|
|
60
|
+
stats() { return { pending: pending.size, generation, disposed }; },
|
|
61
|
+
/** Stop admission, terminate every worker and await settlement. Idempotent. */
|
|
62
|
+
dispose() {
|
|
63
|
+
if (teardown) return teardown;
|
|
64
|
+
disposed = true; generation++;
|
|
65
|
+
for (const entry of pending) entry.stop('disposed');
|
|
66
|
+
teardown = Promise.allSettled([...pending].map((entry) => entry.promise)).then(() => undefined);
|
|
67
|
+
return teardown;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/index.js
CHANGED
|
@@ -14,3 +14,5 @@ export { createTransactionLog } from './diagnostics.js';
|
|
|
14
14
|
export { createSplitterWidget } from './splitter.js';
|
|
15
15
|
export { createDocStore, encodeShare, decodeShare } from './docstore.js';
|
|
16
16
|
export { AppCompileError, AppRuntimeError, HostValueError, toError, APP_CODES } from './errors.js';
|
|
17
|
+
export { createArrayRangeProvider, createCollectionCoordinator } from './collection.js';
|
|
18
|
+
export { createRunObservation } from './runs.js';
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Resumable bounded observation as an app subscription, independent of run cancellation. */
|
|
3
|
+
import { isJsonValue } from '@jarenjs/core/object';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The injected readPage may invoke a public contract operation. One page is in
|
|
7
|
+
* flight at a time; only current summary/cursor enters app state. No event log
|
|
8
|
+
* or worker resources accumulate here. `wake` can wait for a notification or a
|
|
9
|
+
* bounded poll; after a lost notification the next page resumes the durable cursor.
|
|
10
|
+
* @param {{ readPage: (input: any, context: { signal: AbortSignal }) => any,
|
|
11
|
+
* wake: (signal: AbortSignal) => any, pageSize?: number, maxBytes?: number }} options
|
|
12
|
+
*/
|
|
13
|
+
export function createRunObservation(options) {
|
|
14
|
+
if (!options || typeof options.readPage !== 'function' || typeof options.wake !== 'function') throw new TypeError('run observation needs readPage and wake capabilities');
|
|
15
|
+
const { readPage, wake, pageSize = 128, maxBytes = 262144 } = options;
|
|
16
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError('observation limits must be positive finite integers');
|
|
17
|
+
/** @type {Set<AbortController>} */
|
|
18
|
+
const observers = new Set();
|
|
19
|
+
let disposed = false;
|
|
20
|
+
/** @param {{ runId: string, id: number, cursor?: number, update: string, error: string }} props @param {(name: string, payload: any) => void} dispatch */
|
|
21
|
+
function observe(props, dispatch) {
|
|
22
|
+
if (!props || typeof props.runId !== 'string' || !props.runId || typeof props.update !== 'string' || typeof props.error !== 'string'
|
|
23
|
+
|| !Number.isSafeInteger(props.cursor ?? 0) || (props.cursor ?? 0) < 0) throw new TypeError('run observation needs runId, revision cursor and update/error actions');
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const { signal } = controller;
|
|
26
|
+
if (disposed) { controller.abort(); return () => {}; }
|
|
27
|
+
observers.add(controller);
|
|
28
|
+
let cursor = props.cursor ?? 0;
|
|
29
|
+
const stop = () => { controller.abort(); observers.delete(controller); };
|
|
30
|
+
const poll = async () => {
|
|
31
|
+
try {
|
|
32
|
+
while (!signal.aborted) {
|
|
33
|
+
const page = await readPage({ id: props.runId, after: cursor, limit: pageSize }, { signal });
|
|
34
|
+
if (signal.aborted) return;
|
|
35
|
+
if (!isJsonValue(page) || new TextEncoder().encode(JSON.stringify(page)).byteLength > maxBytes
|
|
36
|
+
|| typeof page.status !== 'string' || !Object.hasOwn(page, 'summary')
|
|
37
|
+
|| !['page', 'reset-required'].includes(page.state) || !Array.isArray(page.events) || page.events.length > pageSize
|
|
38
|
+
|| !Number.isSafeInteger(page.cursor) || page.cursor < cursor || !Number.isSafeInteger(page.revision) || page.revision < page.cursor
|
|
39
|
+
|| (page.more === true && page.cursor === cursor)) throw new TypeError('invalid run observation page');
|
|
40
|
+
let seen = cursor;
|
|
41
|
+
for (const event of page.events) {
|
|
42
|
+
if (event.runId !== props.runId || !Number.isSafeInteger(event.revision) || event.revision <= seen || event.revision > page.cursor)
|
|
43
|
+
throw new TypeError('invalid run event revision');
|
|
44
|
+
seen = event.revision;
|
|
45
|
+
}
|
|
46
|
+
cursor = page.cursor;
|
|
47
|
+
dispatch(props.update, { id: props.id, runId: props.runId, cursor, revision: page.revision,
|
|
48
|
+
status: page.status, summary: page.summary, reset: page.state === 'reset-required' });
|
|
49
|
+
if (page.more !== true) await wake(signal);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
if (!signal.aborted) dispatch(props.error, { id: props.id, runId: props.runId, cursor, error: { code: 'run-observation-failed' } });
|
|
54
|
+
}
|
|
55
|
+
finally { observers.delete(controller); }
|
|
56
|
+
};
|
|
57
|
+
void poll();
|
|
58
|
+
return stop;
|
|
59
|
+
}
|
|
60
|
+
/** Detach every observer; this never calls a run's cancel operation. */
|
|
61
|
+
observe.dispose = () => { disposed = true; for (const observer of observers) observer.abort(); observers.clear(); };
|
|
62
|
+
return observe;
|
|
63
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Private index ownership over an injected, draining worker capability. */
|
|
3
|
+
import { compileLexical } from '@jarenjs/core/search';
|
|
4
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Own one worker and one published index. Worker request/reply envelopes carry
|
|
8
|
+
* version, requestId, generation and sourceRevision; build replies carry snapshots.
|
|
9
|
+
* dispose() on the injected worker must settle every request, including aborted ones.
|
|
10
|
+
* @param {import('@jarenjs/core/search').LexicalDefinition} definition
|
|
11
|
+
* @param {{workerFactory:()=>{request:(message:any, options:any)=>Promise<any>, dispose:()=>any},
|
|
12
|
+
* maxInFlight?:number, onProgress?:(progress:any)=>void}} options
|
|
13
|
+
*/
|
|
14
|
+
export function createSearchResource(definition, options) {
|
|
15
|
+
const compiled = compileLexical(definition), maxInFlight = options?.maxInFlight ?? 2;
|
|
16
|
+
if (typeof options?.workerFactory !== 'function' || !Number.isSafeInteger(maxInFlight) || maxInFlight < 1)
|
|
17
|
+
throw new TypeError('Invalid search worker capability');
|
|
18
|
+
const fields = [...new Set(['id', ...compiled.config.fields])];
|
|
19
|
+
let worker = null, index = compiled.create(), disposed = false, ticket = 0, generation = -1, teardown = null;
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
return {
|
|
22
|
+
/** @param {any[]} documents @param {{generation:number, sourceRevision:string, requestId:string}} identity @param {AbortSignal} [signal] */
|
|
23
|
+
build(documents, identity, signal) {
|
|
24
|
+
identity = { ...identity };
|
|
25
|
+
const refuse = (state, reason) => ({ ...identity, state, reason });
|
|
26
|
+
if (disposed) return Promise.resolve(refuse('error', 'disposed'));
|
|
27
|
+
if (signal?.aborted) return Promise.resolve(refuse('error', 'cancelled'));
|
|
28
|
+
if (!identity || !Number.isSafeInteger(identity.generation) || identity.generation < 0
|
|
29
|
+
|| typeof identity.sourceRevision !== 'string' || typeof identity.requestId !== 'string')
|
|
30
|
+
return Promise.resolve(refuse('error', 'invalid-identity'));
|
|
31
|
+
if (identity.generation <= generation) return Promise.resolve(refuse('invalidated', 'stale-generation'));
|
|
32
|
+
if (pending.size >= maxInFlight) return Promise.resolve(refuse('budget-exhausted', 'in-flight'));
|
|
33
|
+
if (!Array.isArray(documents)) return Promise.resolve(refuse('error', 'invalid-documents'));
|
|
34
|
+
if (documents.length > compiled.config.limits.maxDocuments) return Promise.resolve(refuse('budget-exhausted', 'documents'));
|
|
35
|
+
const projected = []; let bytes = 0;
|
|
36
|
+
for (const document of documents) {
|
|
37
|
+
const row = {};
|
|
38
|
+
for (const field of fields) {
|
|
39
|
+
const value = document && Object.hasOwn(document, field) ? document[field] ?? '' : '';
|
|
40
|
+
if (typeof value !== 'string' || (field === 'id' && !value)) return Promise.resolve(refuse('error', 'invalid-documents'));
|
|
41
|
+
if (value.length > compiled.config.limits.maxFieldBytes) return Promise.resolve(refuse('budget-exhausted', 'field-bytes'));
|
|
42
|
+
const length = utf8ByteLength(value);
|
|
43
|
+
if (length > compiled.config.limits.maxFieldBytes) return Promise.resolve(refuse('budget-exhausted', 'field-bytes'));
|
|
44
|
+
bytes += length;
|
|
45
|
+
if (bytes > compiled.config.limits.maxSourceBytes || bytes * 4 + projected.length * 128 > compiled.config.limits.maxTemporaryBytes)
|
|
46
|
+
return Promise.resolve(refuse('budget-exhausted', 'source-bytes'));
|
|
47
|
+
Object.defineProperty(row, field, { value, enumerable: true });
|
|
48
|
+
}
|
|
49
|
+
projected.push(row);
|
|
50
|
+
}
|
|
51
|
+
for (const controller of pending.keys()) controller.abort();
|
|
52
|
+
generation = identity.generation; const mine = ++ticket, controller = new AbortController();
|
|
53
|
+
const abort = () => controller.abort(signal.reason);
|
|
54
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
55
|
+
let lastWork = -1;
|
|
56
|
+
const run = async () => {
|
|
57
|
+
try {
|
|
58
|
+
worker ??= options.workerFactory();
|
|
59
|
+
if (typeof worker?.request !== 'function' || typeof worker?.dispose !== 'function') throw new TypeError('Invalid search worker');
|
|
60
|
+
const reply = await worker.request({ version: 1, operation: 'build', definition: compiled.config, ...identity, documents: projected }, {
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
onProgress(progress) {
|
|
63
|
+
if (!disposed && !controller.signal.aborted && mine === ticket && progress?.version === 1
|
|
64
|
+
&& ['generation', 'requestId', 'sourceRevision'].every((key) => progress[key] === identity[key])
|
|
65
|
+
&& Number.isSafeInteger(progress.work) && progress.work >= 0 && progress.work >= lastWork) {
|
|
66
|
+
lastWork = progress.work; options.onProgress?.({ ...identity, work: progress.work });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
if (disposed) return refuse('error', 'disposed');
|
|
71
|
+
if (mine !== ticket) return refuse('invalidated', 'superseded');
|
|
72
|
+
if (controller.signal.aborted) return refuse('error', 'cancelled');
|
|
73
|
+
if (reply?.version !== 1 || !['generation', 'requestId', 'sourceRevision'].every((key) => reply[key] === identity[key]))
|
|
74
|
+
return refuse('invalidated', 'worker-identity');
|
|
75
|
+
if (reply.state !== 'complete') return refuse(['error', 'budget-exhausted'].includes(reply.state) ? reply.state : 'error', reply.reason ?? 'incomplete-worker');
|
|
76
|
+
const candidate = compiled.create();
|
|
77
|
+
const loaded = candidate.restore(reply.snapshot, identity);
|
|
78
|
+
if (loaded.state !== 'complete') { candidate.dispose(); return refuse(loaded.state, loaded.reason); }
|
|
79
|
+
const old = index; index = candidate; old.dispose();
|
|
80
|
+
return { ...identity, state: 'complete', documents: index.stats().documents };
|
|
81
|
+
}
|
|
82
|
+
catch (error) { return refuse('error', disposed ? 'disposed' : controller.signal.aborted ? 'cancelled' : String(error?.message ?? error)); }
|
|
83
|
+
};
|
|
84
|
+
const promise = run().finally(() => { pending.delete(controller); signal?.removeEventListener('abort', abort); });
|
|
85
|
+
pending.set(controller, promise); return promise;
|
|
86
|
+
},
|
|
87
|
+
/** @param {string} text @param {any} [options] */
|
|
88
|
+
search(text, options) { return index.search(text, options); },
|
|
89
|
+
stats() { return { ...index.stats(), workers: worker ? 1 : 0, pending: pending.size }; },
|
|
90
|
+
/** Stop admission, fence callbacks, terminate the worker and drain its requests. */
|
|
91
|
+
dispose() {
|
|
92
|
+
if (teardown) return teardown;
|
|
93
|
+
disposed = true; ticket++; for (const controller of pending.keys()) controller.abort(); index.dispose();
|
|
94
|
+
teardown = (async () => {
|
|
95
|
+
try { await worker?.dispose(); }
|
|
96
|
+
finally { await Promise.allSettled(pending.values()); worker = null; }
|
|
97
|
+
})();
|
|
98
|
+
return teardown;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|