@jarenjs/play 0.34.2 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  **A JSON-engine playground — understand an engine before you compose it.**
4
4
 
5
5
  Pick an engine (JSONPath, JSON Pointer, JSON Patch, `$query`, JSLT,
6
- markdown, mermaid, …), feed it a source input and one or more datasets from
7
- a curated example library, and watch it run. Where `@jarenjs/studio` is for
6
+ markdown, mermaid, a `$contract` document, …), feed it a source input and
7
+ one or more datasets from a curated example library, and watch it run. Where `@jarenjs/studio` is for
8
8
  building an application out of many files, `@jarenjs/play` is for
9
9
  learning one engine standalone — a reference bench you experiment on first.
10
10
 
@@ -72,7 +72,7 @@ export declare function createPlayComponent(options?: {
72
72
  engines: Readonly<Record<string, import("../index.js").EngineDescriptor>>;
73
73
  examples: readonly import("../index.js").PlayExample[];
74
74
  engineIds: typeof engineIds;
75
- runExample: (engineId: any, source: any, data: any, perCall?: {}) => import("../index.js").PlayResult;
75
+ runExample: (engineId: any, source: any, data: any, perCall?: {}) => import("../index.js").PlayResult | Promise<import("../index.js").PlayResult>;
76
76
  operators: {
77
77
  toOptions: () => any;
78
78
  } | undefined;
@@ -154,7 +154,13 @@ export type EngineDescriptor = {
154
154
  * - live mode selects (may be absent)
155
155
  */
156
156
  optionPanes?: OptionPane[];
157
- run: (source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult;
157
+ /**
158
+ * most engines answer synchronously; an engine whose run resolves real
159
+ * promises (the contract engine's dispatch panel) answers a thenable
160
+ * Result, which `runExample` passes through with the same never-throw
161
+ * contract (a rejection settles into an error Result)
162
+ */
163
+ run: (source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>;
158
164
  };
159
165
  export type RunOptions = {
160
166
  /**
@@ -289,7 +295,11 @@ export type PlayExample = {
289
295
  * @property {EnginePane[]} sourcePanes - the engine INPUT pane(s)
290
296
  * @property {EnginePane[]} dataPanes - the JSON it runs against (may be [])
291
297
  * @property {OptionPane[]} [optionPanes] - live mode selects (may be absent)
292
- * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult} run
298
+ * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>} run
299
+ * most engines answer synchronously; an engine whose run resolves real
300
+ * promises (the contract engine's dispatch panel) answers a thenable
301
+ * Result, which `runExample` passes through with the same never-throw
302
+ * contract (a rejection settles into an error Result)
293
303
  */
294
304
  /**
295
305
  * @typedef {Object} RunOptions
@@ -328,12 +338,15 @@ export declare function engineIds(): string[];
328
338
  export declare const EXAMPLES: readonly PlayExample[];
329
339
  /**
330
340
  * Run one engine over a source + data. An unknown engine (or a throwing
331
- * runner) yields an error Result — this never throws.
341
+ * runner) yields an error Result — this never throws. A synchronous
342
+ * engine answers a Result; an async engine (the contract dispatch)
343
+ * answers a Promise of one that never rejects — a host that must know
344
+ * which awaits `Promise.resolve(runExample(…))`.
332
345
  * @param {string} engineId
333
346
  * @param {Record<string, string>} source
334
347
  * @param {Record<string, string>} data
335
348
  * @param {RunOptions} [options] - operators (query/jslt), the option-pane
336
349
  * config, and host renderers (markdown/mermaid/charts)
337
- * @returns {PlayResult}
350
+ * @returns {PlayResult | Promise<PlayResult>}
338
351
  */
339
- export declare function runExample(engineId: string, source: Record<string, string>, data: Record<string, string>, options?: RunOptions): PlayResult;
352
+ export declare function runExample(engineId: string, source: Record<string, string>, data: Record<string, string>, options?: RunOptions): PlayResult | Promise<PlayResult>;
@@ -13,7 +13,7 @@ EngineDescriptor = {
13
13
  lead?: string, // one line describing the engine
14
14
  sourcePanes: EnginePane[], // the engine INPUT (usually one)
15
15
  dataPanes: EnginePane[], // the JSON it runs against (may be empty)
16
- run: (source, data) => PlayResult,
16
+ run: (source, data) => PlayResult | Promise<PlayResult>,
17
17
  }
18
18
  EnginePane = { key: string, label: string, control?: 'code' | 'text' }
19
19
  ```
@@ -27,6 +27,13 @@ EnginePane = { key: string, label: string, control?: 'code' | 'text' }
27
27
  - `run(source, data)` is PURE and NEVER throws: it wraps the real shipped
28
28
  compiler and returns a `PlayResult`. `source` / `data` are maps keyed
29
29
  by the pane `key`s, holding the raw text.
30
+ - Most engines answer synchronously. An engine whose run resolves real
31
+ promises (the contract engine's dispatch panel invokes a local client)
32
+ answers a **thenable** `PlayResult`; `runExample` passes it through
33
+ under the same never-throw contract — a rejection settles into an
34
+ error Result, so `await Promise.resolve(runExample(…))` is total for
35
+ every engine. A host that renders live must drop a settled result an
36
+ even newer run superseded (the website keys runs by a sequence).
30
37
 
31
38
  ## §2 The result
32
39
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/play",
3
3
  "private": false,
4
- "version": "0.34.2",
4
+ "version": "0.43.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -56,9 +56,10 @@
56
56
  "prepack": "npm run build:types"
57
57
  },
58
58
  "dependencies": {
59
- "@jarenjs/core": "^0.34.2",
60
- "@jarenjs/josl": "^0.34.2",
61
- "@jarenjs/json": "^0.34.2",
62
- "@jarenjs/validate": "^0.34.2"
59
+ "@jarenjs/contract": "^0.43.1",
60
+ "@jarenjs/core": "^0.43.1",
61
+ "@jarenjs/josl": "^0.43.1",
62
+ "@jarenjs/json": "^0.43.1",
63
+ "@jarenjs/validate": "^0.43.1"
63
64
  }
64
65
  }
package/src/engines.js CHANGED
@@ -18,6 +18,9 @@ import { parseXQuery } from '@jarenjs/json/xquery';
18
18
  import { parseJosl, stringifyJosl, stringifyJsonx } from '@jarenjs/josl';
19
19
  import { parseCsvDocument, stringifyCsv, sniffCsvDialect } from '@jarenjs/josl/csv';
20
20
  import { createTypeTestCompiler } from '@jarenjs/validate/query';
21
+ import { compileContract } from '@jarenjs/contract';
22
+ import { openLocalClient } from '@jarenjs/contract/local';
23
+ import { toOpenApi, toTypeScript } from '@jarenjs/contract/project';
21
24
  import { formatMs } from './format.js';
22
25
 
23
26
  const compileTypeTest = createTypeTestCompiler();
@@ -555,6 +558,74 @@ export const ENGINE_LIST = [
555
558
  return okPanels(renderedPanels(view), ...renderTiming(view, t1 - t0));
556
559
  },
557
560
  },
561
+ {
562
+ // the one ASYNC engine: the dispatch panel resolves a real client
563
+ // outcome, so run() returns a Promise — runExample and the hosts
564
+ // pass a thenable Result through (PLAY-FORMAT §2)
565
+ id: 'contract', label: 'Contract', lead: 'A $contract document — operations two ends may exchange, compiled, projected and dispatched in-process.',
566
+ sourcePanes: [{ key: 'document', label: '$contract document', control: 'code' }],
567
+ dataPanes: [{ key: 'call', label: 'Dispatch — { "op", "input" }' }],
568
+ async run(source, data) {
569
+ const d = parseJson(source.document, 'document'); if (d.error) return failParse(d);
570
+ let contract; const t0 = now();
571
+ // a compile refusal is the engine's lesson: a stable JC00xx code
572
+ // with the JSON Pointer of the member at fault
573
+ try { contract = compileContract(d.value); }
574
+ catch (err) { return fail(msg(err), code(err), locate(err, 'document')); }
575
+ const t1 = now();
576
+ let openapi, types;
577
+ try {
578
+ openapi = toOpenApi(contract, {
579
+ info: {
580
+ title: typeof d.value?.id === 'string' ? d.value.id : 'contract',
581
+ version: typeof d.value?.version === 'string' ? d.value.version : '0',
582
+ },
583
+ });
584
+ types = toTypeScript(contract);
585
+ }
586
+ catch (err) { return fail(msg(err), code(err), locate(err, 'document')); }
587
+ // the dispatch pane: an op + input run against trivial echo
588
+ // handlers over the local binding — the whole pipeline with no
589
+ // network. Output validation is DECLARED off (a capability the
590
+ // binding states), so any echo crosses; input validation still
591
+ // refuses a bad input with its outcome, which is the lesson.
592
+ /** @type {import('./index.js').Panel} */
593
+ let dispatch = {
594
+ id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'info',
595
+ text: 'Type { "op": "<operation id>", "input": { … } } in the Dispatch pane to run an operation against echo handlers (validateOutput declared \'never\').',
596
+ };
597
+ if (String(data.call ?? '').trim() !== '') {
598
+ const call = parseJson(data.call, 'call'); if (call.error) return failParse(call);
599
+ const op = call.value !== null && typeof call.value === 'object' ? call.value.op : undefined;
600
+ if (typeof op !== 'string') {
601
+ dispatch = { id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'warn', text: 'the call must be an object naming an "op" (and optionally an "input")' };
602
+ }
603
+ else {
604
+ /** @type {Record<string, (input: any) => any>} */
605
+ const handlers = {};
606
+ for (const id of Object.keys(contract.operations)) handlers[id] = (input) => input;
607
+ const client = openLocalClient(contract, handlers, { validateOutput: 'never' });
608
+ try {
609
+ const outcome = await client.invoke(op, call.value.input ?? {});
610
+ dispatch = { id: 'dispatch', label: 'Dispatch (echo)', kind: 'code', text: fmt(outcome) };
611
+ }
612
+ catch (err) {
613
+ // a host mistake (JC1005: unknown, opaque or subscribe
614
+ // operation) is the panel's answer, not an engine failure
615
+ dispatch = { id: 'dispatch', label: 'Dispatch', kind: 'note', tone: 'warn', text: msg(err) };
616
+ }
617
+ finally { client.close(); }
618
+ }
619
+ }
620
+ const t2 = now();
621
+ return okPanels([
622
+ { id: 'describe', label: 'describe()', kind: 'code', text: fmt(contract.describe()) },
623
+ { id: 'openapi', label: 'OpenAPI', kind: 'code', text: fmt(openapi.document) },
624
+ { id: 'types', label: 'TypeScript', kind: 'code', text: types },
625
+ dispatch,
626
+ ], t1 - t0, t2 - t1);
627
+ },
628
+ },
558
629
  // ——— the visual engines: descriptor + examples here, rendering delegated ———
559
630
  visual('markdown', 'Markdown', 'CommonMark + GFM + frontmatter → a JSON AST, rendered live.', { sourceLabel: 'Markdown' }),
560
631
  visual('mermaid', 'Mermaid', 'Diagrams-as-code → a geometry-free AST → pure-vnode SVG.', { sourceLabel: 'Mermaid' }),
package/src/examples.js CHANGED
@@ -543,4 +543,46 @@ is a fixed point.
543
543
  name: { type: 'string', minLength: 2 }, email: { type: 'string', format: 'email' }, age: { type: 'integer', minimum: 13 },
544
544
  }, required: ['name', 'email'] }) },
545
545
  datasets: [{ label: 'invalid', data: { data: j({ name: 'A', email: 'not-an-email', age: 7 }) } }] },
546
+
547
+ // ——— Contract ($contract document → describe / OpenAPI / TypeScript / dispatch) ———
548
+ { id: 'contract-shop', label: 'A shop contract', engine: 'contract',
549
+ source: { document: j({ $contract: '0.1', id: 'shop', version: '1',
550
+ $defs: { Product: { type: 'object', required: ['id', 'name', 'price'], properties: {
551
+ id: { type: 'integer' }, name: { type: 'string', minLength: 1 }, price: { type: 'number', minimum: 0 } } } },
552
+ operations: {
553
+ 'catalog.load': { kind: 'read',
554
+ input: { type: 'object', properties: { since: { type: 'string', format: 'date-time' } } },
555
+ output: { type: 'array', items: { $ref: '#/$defs/Product' } },
556
+ http: { method: 'GET', path: '/api/catalog' },
557
+ doc: 'The whole catalog.' },
558
+ 'product.save': { kind: 'command',
559
+ input: { type: 'object', required: ['id', 'product'], properties: {
560
+ id: { type: 'integer' }, product: { $ref: '#/$defs/Product' } } },
561
+ output: { $ref: '#/$defs/Product' },
562
+ errors: { conflict: { status: 409 } },
563
+ policy: { idempotency: 'optional' },
564
+ http: { method: 'PUT', path: '/api/products/{id}' } },
565
+ } }) },
566
+ datasets: [
567
+ { label: 'a valid save', data: { call: j({ op: 'product.save', input: { id: 7, product: { id: 7, name: 'Duck', price: 9.99 } } }) } },
568
+ { label: 'an invalid input', data: { call: j({ op: 'product.save', input: { id: 'seven' } }) } },
569
+ { label: 'no dispatch', data: { call: '' } },
570
+ ] },
571
+ { id: 'contract-minimal', label: 'One operation, no http', engine: 'contract',
572
+ source: { document: j({ $contract: '0.1', id: 'echo', operations: {
573
+ 'echo.say': { kind: 'command',
574
+ input: { type: 'object', required: ['text'], properties: { text: { type: 'string' } } },
575
+ output: true,
576
+ doc: 'No http member: the binding defaults to POST /echo.say.' },
577
+ } }) },
578
+ datasets: [{ label: 'say something', data: { call: j({ op: 'echo.say', input: { text: 'hello' } }) } }] },
579
+ { id: 'contract-broken', label: 'A refusal, with its docPath', engine: 'contract',
580
+ source: { document: j({ $contract: '0.1', id: 'broken', operations: {
581
+ 'catalog.load': { kind: 'read',
582
+ input: { type: 'object', properties: { since: { type: 'string' } } },
583
+ // no output member: JC00xx at compile, never at request time —
584
+ // the error names /operations/catalog.load with its code
585
+ http: { method: 'GET', path: '/api/catalog' } },
586
+ } }) },
587
+ datasets: [{ label: 'nothing to dispatch', data: { call: '' } }] },
546
588
  ];
package/src/index.js CHANGED
@@ -98,7 +98,11 @@ import { EXAMPLE_LIST } from './examples.js';
98
98
  * @property {EnginePane[]} sourcePanes - the engine INPUT pane(s)
99
99
  * @property {EnginePane[]} dataPanes - the JSON it runs against (may be [])
100
100
  * @property {OptionPane[]} [optionPanes] - live mode selects (may be absent)
101
- * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult} run
101
+ * @property {(source: Record<string, string>, data: Record<string, string>, options?: RunOptions) => PlayResult | Promise<PlayResult>} run
102
+ * most engines answer synchronously; an engine whose run resolves real
103
+ * promises (the contract engine's dispatch panel) answers a thenable
104
+ * Result, which `runExample` passes through with the same never-throw
105
+ * contract (a rejection settles into an error Result)
102
106
  */
103
107
 
104
108
  /**
@@ -162,23 +166,31 @@ function withConfig(engine, options) {
162
166
 
163
167
  /**
164
168
  * Run one engine over a source + data. An unknown engine (or a throwing
165
- * runner) yields an error Result — this never throws.
169
+ * runner) yields an error Result — this never throws. A synchronous
170
+ * engine answers a Result; an async engine (the contract dispatch)
171
+ * answers a Promise of one that never rejects — a host that must know
172
+ * which awaits `Promise.resolve(runExample(…))`.
166
173
  * @param {string} engineId
167
174
  * @param {Record<string, string>} source
168
175
  * @param {Record<string, string>} data
169
176
  * @param {RunOptions} [options] - operators (query/jslt), the option-pane
170
177
  * config, and host renderers (markdown/mermaid/charts)
171
- * @returns {PlayResult}
178
+ * @returns {PlayResult | Promise<PlayResult>}
172
179
  */
173
180
  export function runExample(engineId, source, data, options = {}) {
174
181
  const engine = ENGINES[engineId];
175
182
  if (engine === undefined) {
176
183
  return { ok: false, timing: null, error: { message: `unknown engine: ${engineId}` }, panels: [] };
177
184
  }
185
+ const failed = (/** @type {unknown} */ err) =>
186
+ /** @type {PlayResult} */ ({ ok: false, timing: null, error: { message: String(/** @type {any} */ (err)?.message ?? err) }, panels: [] });
178
187
  try {
179
- return engine.run(source ?? {}, data ?? {}, withConfig(engine, options));
188
+ const result = engine.run(source ?? {}, data ?? {}, withConfig(engine, options));
189
+ return result !== null && typeof result === 'object' && typeof (/** @type {any} */ (result).then) === 'function'
190
+ ? /** @type {Promise<PlayResult>} */ (result).then((r) => r, failed)
191
+ : result;
180
192
  }
181
193
  catch (err) {
182
- return { ok: false, timing: null, error: { message: String(/** @type {any} */ (err)?.message ?? err) }, panels: [] };
194
+ return failed(err);
183
195
  }
184
196
  }