@cynodia/axiom-runtime 0.9.0-alpha.2 → 0.11.0-alpha.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/dist/dom.d.ts CHANGED
@@ -96,5 +96,12 @@ export interface HostEnvironment {
96
96
  * server-only.
97
97
  */
98
98
  readBlobMetadata?(storageId: string, key: string): Promise<IntegrationQueryOutcome>;
99
+ /**
100
+ * Runs a registered `QueryDef` by id and returns its result, for a `query` operation
101
+ * inside an action (spec 0.10 §40). Resolved before the transaction opens, exactly like
102
+ * `queryIntegration`. Only the authoritative runtime implements it — a `query` operation
103
+ * makes its action server-authority, so no client-compiled action ever contains one.
104
+ */
105
+ runQuery?(queryId: string, args: Record<string, unknown>): Promise<IntegrationQueryOutcome>;
99
106
  }
100
107
  //# sourceMappingURL=dom.d.ts.map
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './mutation/transaction.js';
6
6
  export * from './mutation/resolve-location.js';
7
7
  export * from './mutation/mutation-engine.js';
8
8
  export * from './remote.js';
9
+ export * from './query-state.js';
9
10
  export * from './format.js';
10
11
  export * from './presentation-classes.js';
11
12
  export * from './runtime.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './mutation/transaction.js';
6
6
  export * from './mutation/resolve-location.js';
7
7
  export * from './mutation/mutation-engine.js';
8
8
  export * from './remote.js';
9
+ export * from './query-state.js';
9
10
  export * from './format.js';
10
11
  export * from './presentation-classes.js';
11
12
  export * from './runtime.js';
@@ -49,6 +49,12 @@ export function createMutationEngine(options) {
49
49
  return { affectedStates: [resolved.rootStateId], affectedLocations: [resolved.path] };
50
50
  }
51
51
  case 'remove': {
52
+ if (operation.target.kind === 'provider-record') {
53
+ // The authority rewrites a provider-record target to a `collection-item` over a
54
+ // staging collection before this engine runs (spec 0.10 §38). Reaching here
55
+ // means that rewrite did not happen.
56
+ throw new Error('A provider-record remove target is authority-only');
57
+ }
52
58
  const collection = resolveLocation(operation.target.collection, scope, runtime);
53
59
  const current = collection.read();
54
60
  const items = Array.isArray(current) ? current : [];
@@ -42,6 +42,12 @@ function toPath(location, scope, runtime) {
42
42
  };
43
43
  return { rootStateId: parent.rootStateId, segments: [...parent.segments, segment] };
44
44
  }
45
+ case 'provider-record':
46
+ // A `provider-record` location addresses canonical data that no `StateDef` holds. The
47
+ // authority rewrites it to a `collection-item` over an in-transaction staging state
48
+ // before this engine ever sees it (spec 0.10 §38); reaching here means that rewrite
49
+ // did not happen — a client runtime, which holds no data provider.
50
+ throw new Error(`A provider-record location cannot be resolved without a data provider; it is authority-only`);
45
51
  default:
46
52
  throw new Error(`Unknown location kind "${location.kind}"`);
47
53
  }
@@ -0,0 +1,79 @@
1
+ import type { NodeId, RuntimeDiagnostic } from './runtime-types.js';
2
+ import type { RemoteQueryRequest, RemoteQueryResult } from './remote.js';
3
+ /**
4
+ * The canonical client-side lifecycle of a demand-driven read (spec 0.10 §57-60, §76).
5
+ *
6
+ * An application never maintains four booleans per list. It reads one `QueryView`:
7
+ *
8
+ * ```
9
+ * idle ──load──▶ loading ──ok──▶ ready ──refresh/loadMore──▶ refreshing ──ok──▶ ready
10
+ * │ ▲ │
11
+ * │ fail └──────────── ok ──────────────┘
12
+ * ▼ │ fail
13
+ * error ◀────────────── fail (data kept) ─────────┘
14
+ * ```
15
+ *
16
+ * The two failure edges differ, deliberately (spec §58, §60):
17
+ *
18
+ * - a **first load** that fails goes to `error` with **no data** — the caller can tell
19
+ * "loading" from "ready with zero rows";
20
+ * - a **refresh** that fails goes to `error` but **keeps the last successful data** visible,
21
+ * so a transient failure never flashes the UI empty.
22
+ */
23
+ export type QueryLifecycleState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error';
24
+ export declare const QUERY_LIFECYCLE_STATES: readonly QueryLifecycleState[];
25
+ export interface QueryPageData {
26
+ items: Array<Record<string, unknown>>;
27
+ nextCursor: string | null;
28
+ hasMore: boolean;
29
+ }
30
+ export interface QueryAggregateData {
31
+ rows: Array<{
32
+ key?: unknown[];
33
+ values: Record<string, unknown>;
34
+ }>;
35
+ }
36
+ export interface QueryView {
37
+ status: QueryLifecycleState;
38
+ /** The rows of a row query — the last **successful** result, kept across a failed refresh. */
39
+ page?: QueryPageData;
40
+ /** The rows of an aggregate query. */
41
+ aggregate?: QueryAggregateData;
42
+ /** The diagnostic from the most recent failure, cleared on the next success. */
43
+ diagnostic?: RuntimeDiagnostic;
44
+ /** Convenience: `status === 'refreshing'` — data is visible but a fetch is in flight. */
45
+ readonly refreshing: boolean;
46
+ /** Convenience: there is a successful result to show, whatever the status. */
47
+ readonly hasData: boolean;
48
+ }
49
+ /** The parameters that identify one active query — its id and its arguments. */
50
+ export interface QueryKey {
51
+ queryId: NodeId;
52
+ arguments?: Record<string, unknown>;
53
+ }
54
+ export type QueryFetcher = (request: RemoteQueryRequest) => Promise<RemoteQueryResult>;
55
+ /**
56
+ * Holds the `QueryView` for every active query and drives the transitions. It performs no
57
+ * I/O itself — a `QueryFetcher` (normally the remote gateway's `query`) does that — so it
58
+ * is testable without a transport and reusable by any client runtime.
59
+ */
60
+ export interface QueryStore {
61
+ /** The current view for a key. Returns the shared idle view for a key never loaded. */
62
+ get(key: QueryKey): QueryView;
63
+ /** First load (or a re-load of an errored key). No-op if already loading/ready/refreshing. */
64
+ load(key: QueryKey, options?: {
65
+ pageSize?: number;
66
+ }): Promise<QueryView>;
67
+ /** Re-fetch page one, keeping the current data visible while it runs (spec §59). */
68
+ refresh(key: QueryKey, options?: {
69
+ pageSize?: number;
70
+ }): Promise<QueryView>;
71
+ /** Fetch and append the next page. Only meaningful for a `ready` row query with `hasMore`. */
72
+ loadMore(key: QueryKey): Promise<QueryView>;
73
+ /** Drop a key's state entirely, back to `idle`. */
74
+ reset(key: QueryKey): void;
75
+ /** Notified after every transition, with the key that changed. */
76
+ subscribe(listener: (key: QueryKey) => void): () => void;
77
+ }
78
+ export declare function createQueryStore(fetcher: QueryFetcher): QueryStore;
79
+ //# sourceMappingURL=query-state.d.ts.map
@@ -0,0 +1,120 @@
1
+ export const QUERY_LIFECYCLE_STATES = [
2
+ 'idle',
3
+ 'loading',
4
+ 'ready',
5
+ 'refreshing',
6
+ 'error',
7
+ ];
8
+ function keyString(key) {
9
+ return `${String(key.queryId)}::${JSON.stringify(key.arguments ?? {})}`;
10
+ }
11
+ function decorate(base) {
12
+ return {
13
+ ...base,
14
+ get refreshing() {
15
+ return base.status === 'refreshing';
16
+ },
17
+ get hasData() {
18
+ return base.page !== undefined || base.aggregate !== undefined;
19
+ },
20
+ };
21
+ }
22
+ const IDLE = decorate({ status: 'idle' });
23
+ export function createQueryStore(fetcher) {
24
+ const views = new Map();
25
+ const listeners = new Set();
26
+ const set = (key, view) => {
27
+ views.set(keyString(key), view);
28
+ for (const listener of listeners) {
29
+ listener(key);
30
+ }
31
+ return view;
32
+ };
33
+ const current = (key) => views.get(keyString(key)) ?? IDLE;
34
+ async function fetchInto(key, starting, request, onSuccess) {
35
+ const previous = current(key);
36
+ set(key, decorate({
37
+ status: starting,
38
+ ...(previous.page ? { page: previous.page } : {}),
39
+ ...(previous.aggregate ? { aggregate: previous.aggregate } : {}),
40
+ }));
41
+ let result;
42
+ try {
43
+ result = await fetcher(request);
44
+ }
45
+ catch (error) {
46
+ result = {
47
+ ok: false,
48
+ diagnostics: [
49
+ {
50
+ code: 'AUTHORITY_UNREACHABLE',
51
+ message: error instanceof Error ? error.message : String(error),
52
+ severity: 'error',
53
+ },
54
+ ],
55
+ revision: 0,
56
+ };
57
+ }
58
+ if (!result.ok) {
59
+ // A failed first load keeps nothing; a failed refresh keeps the last good data.
60
+ return set(key, decorate({
61
+ status: 'error',
62
+ ...(starting === 'refreshing' && previous.page ? { page: previous.page } : {}),
63
+ ...(starting === 'refreshing' && previous.aggregate ? { aggregate: previous.aggregate } : {}),
64
+ ...(result.diagnostics[0] ? { diagnostic: result.diagnostics[0] } : {}),
65
+ }));
66
+ }
67
+ return set(key, onSuccess(previous, result));
68
+ }
69
+ return {
70
+ get: current,
71
+ load(key, options) {
72
+ const status = current(key).status;
73
+ if (status === 'loading' || status === 'ready' || status === 'refreshing') {
74
+ return Promise.resolve(current(key));
75
+ }
76
+ return fetchInto(key, 'loading', { queryId: key.queryId, arguments: key.arguments, ...(options?.pageSize ? { pageSize: options.pageSize } : {}) }, (_previous, result) => readyView(result));
77
+ },
78
+ refresh(key, options) {
79
+ const previous = current(key);
80
+ const starting = previous.hasData ? 'refreshing' : 'loading';
81
+ return fetchInto(key, starting, { queryId: key.queryId, arguments: key.arguments, ...(options?.pageSize ? { pageSize: options.pageSize } : {}) }, (_previous, result) => readyView(result));
82
+ },
83
+ loadMore(key) {
84
+ const previous = current(key);
85
+ if (previous.status !== 'ready' || !previous.page?.hasMore || !previous.page.nextCursor) {
86
+ return Promise.resolve(previous);
87
+ }
88
+ return fetchInto(key, 'refreshing', { queryId: key.queryId, arguments: key.arguments, cursor: previous.page.nextCursor }, (prev, result) => {
89
+ if (!result.page) {
90
+ return readyView(result);
91
+ }
92
+ return decorate({
93
+ status: 'ready',
94
+ page: {
95
+ items: [...(prev.page?.items ?? []), ...result.page.items],
96
+ nextCursor: result.page.nextCursor,
97
+ hasMore: result.page.hasMore,
98
+ },
99
+ });
100
+ });
101
+ },
102
+ reset(key) {
103
+ views.delete(keyString(key));
104
+ for (const listener of listeners) {
105
+ listener(key);
106
+ }
107
+ },
108
+ subscribe(listener) {
109
+ listeners.add(listener);
110
+ return () => listeners.delete(listener);
111
+ },
112
+ };
113
+ }
114
+ function readyView(result) {
115
+ return decorate({
116
+ status: 'ready',
117
+ ...(result.page ? { page: result.page } : {}),
118
+ ...(result.aggregate ? { aggregate: result.aggregate } : {}),
119
+ });
120
+ }
package/dist/remote.d.ts CHANGED
@@ -23,6 +23,30 @@ export interface HttpRemoteGatewayOptions {
23
23
  fetch?: typeof globalThis.fetch;
24
24
  timeoutMs?: number;
25
25
  }
26
+ /** What a client hands the gateway to run a registered query (spec 0.10 §54). */
27
+ export interface RemoteQueryRequest {
28
+ queryId: NodeId;
29
+ arguments?: Record<string, unknown>;
30
+ cursor?: string;
31
+ pageSize?: number;
32
+ offset?: number;
33
+ }
34
+ export interface RemoteQueryResult {
35
+ ok: boolean;
36
+ diagnostics: RuntimeDiagnostic[];
37
+ page?: {
38
+ items: Array<Record<string, unknown>>;
39
+ nextCursor: string | null;
40
+ hasMore: boolean;
41
+ };
42
+ aggregate?: {
43
+ rows: Array<{
44
+ key?: unknown[];
45
+ values: Record<string, unknown>;
46
+ }>;
47
+ };
48
+ revision: number;
49
+ }
26
50
  /**
27
51
  * Builds the gateway a client runtime is configured with.
28
52
  *
@@ -44,6 +68,7 @@ export declare function createHttpRemoteGateway(options?: HttpRemoteGatewayOptio
44
68
  diagnostics: RuntimeDiagnostic[];
45
69
  changes: Record<NodeId, unknown>;
46
70
  }>;
71
+ query(request: RemoteQueryRequest): Promise<RemoteQueryResult>;
47
72
  snapshot(): Promise<{
48
73
  states: Record<NodeId, unknown>;
49
74
  }>;
package/dist/remote.js CHANGED
@@ -88,6 +88,35 @@ export function createHttpRemoteGateway(options = {}) {
88
88
  };
89
89
  }
90
90
  },
91
+ async query(request) {
92
+ try {
93
+ const answer = await send({
94
+ kind: 'query',
95
+ queryId: request.queryId,
96
+ ...(request.arguments ? { arguments: request.arguments } : {}),
97
+ ...(request.cursor ? { cursor: request.cursor } : {}),
98
+ ...(request.pageSize !== undefined ? { pageSize: request.pageSize } : {}),
99
+ ...(request.offset !== undefined ? { offset: request.offset } : {}),
100
+ });
101
+ if (answer.kind === 'query-result') {
102
+ return {
103
+ ok: answer.ok === true,
104
+ diagnostics: answer.diagnostics ?? [],
105
+ ...(answer.page ? { page: answer.page } : {}),
106
+ ...(answer.aggregate ? { aggregate: answer.aggregate } : {}),
107
+ revision: answer.revision ?? 0,
108
+ };
109
+ }
110
+ return { ok: false, diagnostics: answer.diagnostics ?? [], revision: 0 };
111
+ }
112
+ catch (error) {
113
+ return {
114
+ ok: false,
115
+ diagnostics: [unreachable(error instanceof Error ? error.message : String(error))],
116
+ revision: 0,
117
+ };
118
+ }
119
+ },
91
120
  async snapshot() {
92
121
  // A failed snapshot must reject: `start()` distinguishes "not loaded" from "empty",
93
122
  // and swallowing the failure here would erase that difference.
package/dist/runtime.d.ts CHANGED
@@ -48,6 +48,10 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
48
48
  readonly BLOB_STORAGE_UNAVAILABLE: "BLOB_STORAGE_UNAVAILABLE";
49
49
  /** A `blob-metadata` lookup failed: no such key, a staged object, or the store refused. */
50
50
  readonly BLOB_METADATA_FAILED: "BLOB_METADATA_FAILED";
51
+ /** A `query` operation ran where no data provider is reachable — a client runtime, or a server with none registered. */
52
+ readonly QUERY_RESOLVER_UNAVAILABLE: "QUERY_RESOLVER_UNAVAILABLE";
53
+ /** A `query` operation's registered query failed to execute. Never carries a provider secret. */
54
+ readonly QUERY_OPERATION_FAILED: "QUERY_OPERATION_FAILED";
51
55
  };
52
56
  export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
53
57
  /**
package/dist/runtime.js CHANGED
@@ -52,6 +52,10 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
52
52
  BLOB_STORAGE_UNAVAILABLE: 'BLOB_STORAGE_UNAVAILABLE',
53
53
  /** A `blob-metadata` lookup failed: no such key, a staged object, or the store refused. */
54
54
  BLOB_METADATA_FAILED: 'BLOB_METADATA_FAILED',
55
+ /** A `query` operation ran where no data provider is reachable — a client runtime, or a server with none registered. */
56
+ QUERY_RESOLVER_UNAVAILABLE: 'QUERY_RESOLVER_UNAVAILABLE',
57
+ /** A `query` operation's registered query failed to execute. Never carries a provider secret. */
58
+ QUERY_OPERATION_FAILED: 'QUERY_OPERATION_FAILED',
55
59
  };
56
60
  const MISSING = Symbol('missing');
57
61
  function unwrapType(type) {
@@ -737,6 +741,20 @@ export function createAxiomRuntime(options) {
737
741
  return toText(values[0]).toLowerCase();
738
742
  case 'to-string':
739
743
  return toText(values[0]);
744
+ case 'trim':
745
+ return toText(values[0]).trim();
746
+ case 'substring-before': {
747
+ const text = toText(values[0]);
748
+ const separator = toText(values[1]);
749
+ const index = separator === '' ? -1 : text.indexOf(separator);
750
+ return index < 0 ? text : text.slice(0, index);
751
+ }
752
+ case 'substring-after': {
753
+ const text = toText(values[0]);
754
+ const separator = toText(values[1]);
755
+ const index = separator === '' ? -1 : text.indexOf(separator);
756
+ return index < 0 ? '' : text.slice(index + separator.length);
757
+ }
740
758
  case 'now':
741
759
  return host.now();
742
760
  case 'uuid':
@@ -1088,6 +1106,7 @@ export function createAxiomRuntime(options) {
1088
1106
  }
1089
1107
  case 'integration-query':
1090
1108
  case 'blob-metadata':
1109
+ case 'query':
1091
1110
  // The result was already resolved and bound into the action's scope before this
1092
1111
  // transaction opened — see `runActionAsync`. Nothing to do here; this case exists
1093
1112
  // only so the kind is recognized rather than falling to `default`.
@@ -1294,7 +1313,9 @@ export function createAxiomRuntime(options) {
1294
1313
  });
1295
1314
  }
1296
1315
  function actionHasAsyncQuery(action) {
1297
- return (action.operations ?? []).some((operation) => operation.kind === 'integration-query' || operation.kind === 'blob-metadata');
1316
+ return (action.operations ?? []).some((operation) => operation.kind === 'integration-query' ||
1317
+ operation.kind === 'blob-metadata' ||
1318
+ operation.kind === 'query');
1298
1319
  }
1299
1320
  /**
1300
1321
  * Resolves every top-level `integration-query` operation before the transaction opens,
@@ -1365,6 +1386,49 @@ export function createAxiomRuntime(options) {
1365
1386
  presetBindings.set(operation.bindAs, blobOutcome.value);
1366
1387
  continue;
1367
1388
  }
1389
+ if (operation.kind === 'query') {
1390
+ // A registered query, resolved before the transaction — the same pre-transaction
1391
+ // phase `integration-query` occupies, and for the same reason: its result may inform
1392
+ // the mutations that follow, but running it is not a pure expression.
1393
+ const queryArgs = {};
1394
+ for (const [key, argument] of Object.entries(operation.arguments ?? {})) {
1395
+ queryArgs[key] = evaluate(argument, scope);
1396
+ }
1397
+ let queryOutcome;
1398
+ try {
1399
+ queryOutcome = await host.runQuery?.(String(operation.queryId), queryArgs);
1400
+ }
1401
+ catch (error) {
1402
+ queryOutcome = {
1403
+ ok: false,
1404
+ code: RUNTIME_DIAGNOSTIC_CODES.QUERY_OPERATION_FAILED,
1405
+ message: error instanceof Error ? error.message : String(error),
1406
+ };
1407
+ }
1408
+ if (!queryOutcome) {
1409
+ return failWith({
1410
+ code: RUNTIME_DIAGNOSTIC_CODES.QUERY_RESOLVER_UNAVAILABLE,
1411
+ message: `${action.name ?? action.id} runs a query, but no data provider is reachable here`,
1412
+ severity: 'error',
1413
+ nodeId: action.id,
1414
+ actionId: action.id,
1415
+ details: { queryId: String(operation.queryId) },
1416
+ });
1417
+ }
1418
+ if (!queryOutcome.ok) {
1419
+ return failWith({
1420
+ code: RUNTIME_DIAGNOSTIC_CODES.QUERY_OPERATION_FAILED,
1421
+ message: queryOutcome.message,
1422
+ severity: 'error',
1423
+ nodeId: action.id,
1424
+ actionId: action.id,
1425
+ details: { queryId: String(operation.queryId), code: queryOutcome.code },
1426
+ });
1427
+ }
1428
+ scope.values.set(operation.bindAs, queryOutcome.value);
1429
+ presetBindings.set(operation.bindAs, queryOutcome.value);
1430
+ continue;
1431
+ }
1368
1432
  if (operation.kind !== 'integration-query') {
1369
1433
  continue;
1370
1434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.9.0-alpha.2",
3
+ "version": "0.11.0-alpha.1",
4
4
  "description": "Domain-independent runtime that executes an Axiom application graph.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@cynodia/axiom-core": "0.9.0-alpha.2"
34
+ "@cynodia/axiom-core": "0.11.0-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",