@jarenjs/app 0.73.0 → 0.83.2

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 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).
@@ -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
+ };
@@ -69,15 +69,20 @@ export type FormViewOptions = {
69
69
  * - Remove-item button text (default `'×'`).
70
70
  */
71
71
  removeLabel?: string;
72
+ /**
73
+ * - Decorative required marker (default `'*'`).
74
+ */
75
+ requiredMarker?: string;
72
76
  /**
73
77
  * - Accessible
74
- * names for the two symbol buttons. `@jarenjs/forms`'
78
+ * names for the symbol buttons and the JSON editor's hint. `@jarenjs/forms`'
75
79
  * `formChromeLabels(catalog)` resolves them from a message catalog;
76
80
  * the English defaults apply when absent.
77
81
  */
78
82
  labels?: {
79
83
  addItem?: string;
80
84
  removeItem?: string;
85
+ jsonPlaceholder?: string;
81
86
  };
82
87
  /**
83
88
  * - (actions) JSON Pointer to the form
@@ -95,8 +100,9 @@ export type FormViewOptions = {
95
100
  * standard action names (`input`/`check`/`number`/`add`/`remove`).
96
101
  * @property {string} [addLabel] - Add-item button text (default `'+'`).
97
102
  * @property {string} [removeLabel] - Remove-item button text (default `'×'`).
98
- * @property {{addItem?: string, removeItem?: string}} [labels] - Accessible
99
- * names for the two symbol buttons. `@jarenjs/forms`'
103
+ * @property {string} [requiredMarker] - Decorative required marker (default `'*'`).
104
+ * @property {{addItem?: string, removeItem?: string, jsonPlaceholder?: string}} [labels] - Accessible
105
+ * names for the symbol buttons and the JSON editor's hint. `@jarenjs/forms`'
100
106
  * `formChromeLabels(catalog)` resolves them from a message catalog;
101
107
  * the English defaults apply when absent.
102
108
  * @property {string} [dataPointer] - (actions) JSON Pointer to the form
@@ -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
+ };
@@ -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
+ };
@@ -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.73.0",
4
+ "version": "0.83.2",
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.73.0",
54
- "@jarenjs/json": "^0.73.0",
55
- "@jarenjs/view": "^0.73.0"
61
+ "@jarenjs/core": "^0.83.2",
62
+ "@jarenjs/json": "^0.83.2",
63
+ "@jarenjs/view": "^0.83.2"
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 effectHandlers = options.effects ?? {};
215
- const subHandlers = options.subs ?? {};
216
- const eventExtractors = options.eventFields ?? {};
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: options.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/forms.js CHANGED
@@ -88,8 +88,9 @@ export function formEventFields() {
88
88
  * standard action names (`input`/`check`/`number`/`add`/`remove`).
89
89
  * @property {string} [addLabel] - Add-item button text (default `'+'`).
90
90
  * @property {string} [removeLabel] - Remove-item button text (default `'×'`).
91
- * @property {{addItem?: string, removeItem?: string}} [labels] - Accessible
92
- * names for the two symbol buttons. `@jarenjs/forms`'
91
+ * @property {string} [requiredMarker] - Decorative required marker (default `'*'`).
92
+ * @property {{addItem?: string, removeItem?: string, jsonPlaceholder?: string}} [labels] - Accessible
93
+ * names for the symbol buttons and the JSON editor's hint. `@jarenjs/forms`'
93
94
  * `formChromeLabels(catalog)` resolves them from a message catalog;
94
95
  * the English defaults apply when absent.
95
96
  * @property {string} [dataPointer] - (actions) JSON Pointer to the form
@@ -108,7 +109,7 @@ export function createFormView(options = {}) {
108
109
  const root = options.root ?? '$.form';
109
110
  const cls = options.classPrefix ?? 'jaren-form';
110
111
  const act = { ...DEFAULT_ACTIONS, ...options.actions };
111
- const labels = { addItem: 'Add item', removeItem: 'Remove item', ...options.labels };
112
+ const labels = { addItem: 'Add item', removeItem: 'Remove item', jsonPlaceholder: 'Enter a JSON value', ...options.labels };
112
113
  /** Match any view-model node under `root` with the given control. */
113
114
  const ctl = (control) => `${root}..[?@.control == '${control}']`;
114
115
 
@@ -123,17 +124,17 @@ export function createFormView(options = {}) {
123
124
  type: 'button',
124
125
  class: `${cls}-remove`,
125
126
  // the glyph is decoration; the accessible name is the label
126
- 'aria-label': labels.removeItem,
127
- title: labels.removeItem,
127
+ 'aria-label': { $const: labels.removeItem },
128
+ title: { $const: labels.removeItem },
128
129
  disabled: writeDisabled,
129
130
  on: { click: { action: act.remove, with: { pointer: '$.pointer' } } },
130
- }, options.removeLabel ?? '×']] };
131
+ }, { $const: options.removeLabel ?? '×' }]] };
131
132
 
132
133
  /** The shared field chrome around one control vnode. */
133
134
  const field = (control) => ['div', { class: `${cls}-field`, 'data-pointer': '$.pointer' },
134
135
  ['label', {},
135
136
  '$.label',
136
- { $if: ['$.required', ['span', { class: `${cls}-required`, 'aria-hidden': 'true' }, ' *']] },
137
+ { $if: ['$.required', ['span', { class: `${cls}-required`, 'aria-hidden': 'true' }, { $const: ` ${options.requiredMarker ?? '*'}` }]] },
137
138
  control,
138
139
  ],
139
140
  { $if: ['$.description', ['p', { class: `${cls}-description` }, '$.description']] },
@@ -153,6 +154,7 @@ export function createFormView(options = {}) {
153
154
  placeholder: '$.placeholder',
154
155
  readonly: '$.readOnly',
155
156
  disabled,
157
+ 'aria-required': { $if: ['$.required', 'true'] },
156
158
  on: { input: { action, with: writeWith } },
157
159
  }];
158
160
 
@@ -170,6 +172,7 @@ export function createFormView(options = {}) {
170
172
  placeholder: '$.placeholder',
171
173
  readonly: '$.readOnly',
172
174
  disabled,
175
+ 'aria-required': { $if: ['$.required', 'true'] },
173
176
  min: '$.constraints.formatMinimum',
174
177
  max: '$.constraints.formatMaximum',
175
178
  on: { input: { action, with: writeWith } },
@@ -205,11 +208,11 @@ export function createFormView(options = {}) {
205
208
  ['button', {
206
209
  type: 'button',
207
210
  class: `${cls}-add`,
208
- 'aria-label': labels.addItem,
209
- title: labels.addItem,
211
+ 'aria-label': { $const: labels.addItem },
212
+ title: { $const: labels.addItem },
210
213
  disabled: writeDisabled,
211
214
  on: { click: { action: act.add, with: { pointer: '$.pointer', value: '$.addValue' } } },
212
- }, options.addLabel ?? '+']] },
215
+ }, { $const: options.addLabel ?? '+' }]] },
213
216
  [{ $apply: '$.errors[*]' }],
214
217
  remove,
215
218
  ],
@@ -229,6 +232,7 @@ export function createFormView(options = {}) {
229
232
  match: ctl('select'),
230
233
  body: field(['select', {
231
234
  disabled: writeDisabled,
235
+ 'aria-required': { $if: ['$.required', 'true'] },
232
236
  on: {
233
237
  change: { action: act.json, with: writeWith, event: [JSON_FIELD] },
234
238
  },
@@ -241,6 +245,7 @@ export function createFormView(options = {}) {
241
245
  type: 'checkbox',
242
246
  checked: '$.value',
243
247
  disabled: writeDisabled,
248
+ 'aria-required': { $if: ['$.required', 'true'] },
244
249
  on: { change: { action: act.check, with: writeWith } },
245
250
  }]),
246
251
  },
@@ -249,6 +254,7 @@ export function createFormView(options = {}) {
249
254
  match: ctl('textarea'),
250
255
  body: field(['textarea', {
251
256
  placeholder: '$.placeholder',
257
+ 'aria-required': { $if: ['$.required', 'true'] },
252
258
  readonly: '$.readOnly',
253
259
  disabled,
254
260
  on: { input: { action: act.input, with: writeWith } },
@@ -266,6 +272,9 @@ export function createFormView(options = {}) {
266
272
  class: `${cls}-json`,
267
273
  rows: 4,
268
274
  spellcheck: 'false',
275
+ placeholder: { $const: labels.jsonPlaceholder },
276
+ 'aria-description': { $const: labels.jsonPlaceholder },
277
+ 'aria-required': { $if: ['$.required', 'true'] },
269
278
  readonly: '$.readOnly',
270
279
  disabled,
271
280
  on: { change: { action: act.json, with: writeWith, event: [JSON_FIELD] } },
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
+ }