@cynodia/axiom-core 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.
@@ -2,6 +2,8 @@ import type { Expression } from './expressions.js';
2
2
  import type { NodeId } from './ids.js';
3
3
  import type { ActionDef, ConstraintDef, ExpressionDef, StateDef, TransitionConstraintDef } from './nodes.js';
4
4
  import type { AnyNode } from './types.js';
5
+ import type { QueryDef } from './query.js';
6
+ import type { ReadPolicyDef } from './read-policy.js';
5
7
  /**
6
8
  * Who may commit a canonical mutation.
7
9
  *
@@ -38,6 +40,10 @@ export interface AuthorityContext {
38
40
  transitionConstraints: TransitionConstraintDef[];
39
41
  /** Named expressions, so a rule that reuses one still declares what it reads. */
40
42
  expressions: Map<NodeId, ExpressionDef>;
43
+ /** Registered queries — always authority-side, since they read authoritative data. */
44
+ queries: Map<NodeId, QueryDef>;
45
+ /** Row-level read policies, by the entity they govern. */
46
+ readPolicies: Map<NodeId, ReadPolicyDef>;
41
47
  principalEntityId?: NodeId;
42
48
  }
43
49
  export declare function authorityContext(nodes: readonly AnyNode[], principalEntityId?: NodeId): AuthorityContext;
@@ -47,6 +53,17 @@ export declare function statesReadBy(expressions: readonly Expression[], context
47
53
  export declare function actionUsesIntegration(action: ActionDef): boolean;
48
54
  /** Whether an action reaches an object store anywhere in its top-level operations. */
49
55
  export declare function actionUsesStorage(action: ActionDef): boolean;
56
+ /**
57
+ * Whether an action runs a registered query (spec 0.10 §40). Only the authority holds a
58
+ * data provider, so an action that reads authoritative data through a query executes there.
59
+ */
60
+ export declare function actionUsesQuery(action: ActionDef): boolean;
61
+ /**
62
+ * Whether an action writes a `provider-record` location anywhere in its operations
63
+ * (spec 0.10 §37-39). Mutating a canonical provider-backed row is authoritative work — the
64
+ * client holds no provider — so such an action executes on the server.
65
+ */
66
+ export declare function actionWritesProviderRecord(action: ActionDef): boolean;
50
67
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
51
68
  export declare function statesWrittenBy(action: ActionDef, context: AuthorityContext, visited?: Set<NodeId>): Set<NodeId>;
52
69
  /** The states an action reads: its guards, its values, its selectors, its authorization. */
package/dist/authority.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { referencedIds } from './derive-edges.js';
2
2
  import { actionGuards, isMutationOperation } from './nodes.js';
3
- import { locationExpressions, locationRootStateId } from './location.js';
3
+ import { locationExpressions, locationProviderEntityId, locationRootStateId } from './location.js';
4
+ import { queryExpressions } from './query.js';
4
5
  export const AUTHORITIES = ['client', 'server'];
5
6
  /**
6
7
  * The scope an authorization expression resolves the caller through.
@@ -32,6 +33,8 @@ export function authorityContext(nodes, principalEntityId) {
32
33
  const constraints = [];
33
34
  const transitionConstraints = [];
34
35
  const expressions = new Map();
36
+ const queries = new Map();
37
+ const readPolicies = new Map();
35
38
  for (const node of nodes) {
36
39
  switch (node.kind) {
37
40
  case 'state':
@@ -49,6 +52,12 @@ export function authorityContext(nodes, principalEntityId) {
49
52
  case 'expression':
50
53
  expressions.set(node.id, node);
51
54
  break;
55
+ case 'query':
56
+ queries.set(node.id, node);
57
+ break;
58
+ case 'read-policy':
59
+ readPolicies.set(node.entityId, node);
60
+ break;
52
61
  default:
53
62
  }
54
63
  }
@@ -58,6 +67,8 @@ export function authorityContext(nodes, principalEntityId) {
58
67
  constraints,
59
68
  transitionConstraints,
60
69
  expressions,
70
+ queries,
71
+ readPolicies,
61
72
  ...(principalEntityId ? { principalEntityId } : {}),
62
73
  };
63
74
  }
@@ -140,6 +151,30 @@ export function actionUsesStorage(action) {
140
151
  operation.kind === 'blob-commit' ||
141
152
  operation.kind === 'blob-delete');
142
153
  }
154
+ /**
155
+ * Whether an action runs a registered query (spec 0.10 §40). Only the authority holds a
156
+ * data provider, so an action that reads authoritative data through a query executes there.
157
+ */
158
+ export function actionUsesQuery(action) {
159
+ return (action.operations ?? []).some((operation) => operation.kind === 'query');
160
+ }
161
+ /**
162
+ * Whether an action writes a `provider-record` location anywhere in its operations
163
+ * (spec 0.10 §37-39). Mutating a canonical provider-backed row is authoritative work — the
164
+ * client holds no provider — so such an action executes on the server.
165
+ */
166
+ export function actionWritesProviderRecord(action) {
167
+ const targets = (operations) => operations.some((operation) => {
168
+ if (operation.kind === 'set' || operation.kind === 'insert' || operation.kind === 'remove') {
169
+ return locationProviderEntityId(operation.target) !== undefined;
170
+ }
171
+ if (operation.kind === 'for-each') {
172
+ return targets(operation.operations);
173
+ }
174
+ return false;
175
+ });
176
+ return targets(action.operations ?? []);
177
+ }
143
178
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
144
179
  export function statesWrittenBy(action, context, visited = new Set()) {
145
180
  const found = new Set();
@@ -230,7 +265,10 @@ export function actionAuthority(action, context) {
230
265
  // Integrations and object stores are both server-only by default (spec §65: secrets,
231
266
  // trust, CORS, auditability, deterministic authority), so an action that reaches either
232
267
  // is unconditionally server — independent of what it writes.
233
- if (actionUsesIntegration(action) || actionUsesStorage(action)) {
268
+ if (actionUsesIntegration(action) ||
269
+ actionUsesStorage(action) ||
270
+ actionUsesQuery(action) ||
271
+ actionWritesProviderRecord(action)) {
234
272
  return 'server';
235
273
  }
236
274
  for (const stateId of statesWrittenBy(action, context)) {
@@ -274,6 +312,10 @@ export function serverStateClosure(context) {
274
312
  const ruleExpressions = [
275
313
  ...context.constraints.map((constraint) => constraint.expression),
276
314
  ...context.transitionConstraints.map((constraint) => constraint.expression),
315
+ // A query's filter/sort/projection and a read policy's predicate are evaluated on the
316
+ // authority too, so any authoritative state they consult has to be present.
317
+ ...[...context.queries.values()].flatMap((query) => queryExpressions(query)),
318
+ ...[...context.readPolicies.values()].map((policy) => policy.predicate),
277
319
  ];
278
320
  for (const id of statesReadBy(ruleExpressions, context)) {
279
321
  needed.add(id);
@@ -2,6 +2,7 @@ import { constructedFieldIds, expressionDefsIn, expressionFieldIds, walkExpressi
2
2
  import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
3
3
  import { isGroupFieldId } from './group.js';
4
4
  import { isUINode } from './ui.js';
5
+ import { queryExpressions } from './query.js';
5
6
  /**
6
7
  * Ids a `ref` expression mentions anywhere in the tree.
7
8
  *
@@ -72,6 +73,9 @@ export function deriveEdges(nodes) {
72
73
  }
73
74
  }
74
75
  const entityScope = (entityId) => new Map([...rootScope, [entityId, statesByEntity.get(entityId) ?? []]]);
76
+ const relationshipsById = new Map(nodes
77
+ .filter((node) => node.kind === 'relationship')
78
+ .map((node) => [node.id, node]));
75
79
  const link = (from, to, kind, fieldIds = []) => {
76
80
  if (from === to || !known.has(from) || !known.has(to)) {
77
81
  return;
@@ -177,6 +181,35 @@ export function deriveEdges(nodes) {
177
181
  reads(node.id, node.uploadAuthorization, rootScope);
178
182
  }
179
183
  break;
184
+ case 'query': {
185
+ link(node.id, node.source, 'references');
186
+ if (node.readPolicyId) {
187
+ link(node.id, node.readPolicyId, 'depends-on');
188
+ }
189
+ const scope = new Map([
190
+ ...rootScope,
191
+ [node.rowScopeId, statesByEntity.get(node.source) ?? []],
192
+ ]);
193
+ for (const use of node.relationships ?? []) {
194
+ link(node.id, use.relationshipId, 'references');
195
+ const relationship = relationshipsById.get(use.relationshipId);
196
+ if (relationship) {
197
+ scope.set(use.bindAs, statesByEntity.get(relationship.to.entityId) ?? []);
198
+ }
199
+ }
200
+ for (const expression of queryExpressions(node)) {
201
+ reads(node.id, expression, scope);
202
+ }
203
+ break;
204
+ }
205
+ case 'relationship':
206
+ link(node.id, node.from.entityId, 'references', [node.from.fieldId]);
207
+ link(node.id, node.to.entityId, 'references', [node.to.fieldId]);
208
+ break;
209
+ case 'read-policy':
210
+ link(node.id, node.entityId, 'constrains');
211
+ reads(node.id, node.predicate, new Map([...rootScope, [node.rowScopeId, statesByEntity.get(node.entityId) ?? []]]));
212
+ break;
180
213
  case 'integration':
181
214
  case 'event':
182
215
  break;
@@ -488,6 +521,12 @@ function linkOperations(actionId, operations, linker, scope) {
488
521
  linker.link(actionId, operation.failedEventId, 'references');
489
522
  }
490
523
  break;
524
+ case 'query':
525
+ linker.link(actionId, operation.queryId, 'references');
526
+ for (const argument of Object.values(operation.arguments ?? {})) {
527
+ linker.reads(actionId, argument, scope);
528
+ }
529
+ break;
491
530
  default:
492
531
  }
493
532
  }
@@ -119,5 +119,53 @@ export declare const VALIDATION_CODES: {
119
119
  readonly invalidBlobEntity: "INVALID_BLOB_ENTITY";
120
120
  /** A blob operation that cannot execute as written — inside a `for-each`, or with no readable key. */
121
121
  readonly invalidBlobOperation: "INVALID_BLOB_OPERATION";
122
+ /** A `QueryDef.source`, a `RelationshipDef` endpoint entity, or a `ReadPolicyDef.entityId` that does not resolve to an entity. */
123
+ readonly unknownQueryEntity: "UNKNOWN_QUERY_ENTITY";
124
+ /** A `QueryRelationshipUse.relationshipId`, or a `query` operation's `queryId`, that does not resolve. */
125
+ readonly unknownRelationship: "UNKNOWN_RELATIONSHIP";
126
+ /** A `RelationshipDef` whose endpoints are inconsistent — a to-one whose `to.fieldId` is not the target's identity, a field not on its stated entity. */
127
+ readonly invalidRelationship: "INVALID_RELATIONSHIP";
128
+ /** A `QueryDef.readPolicyId` that does not resolve to a `read-policy` node. */
129
+ readonly unknownReadPolicy: "UNKNOWN_READ_POLICY";
130
+ /** A malformed `ReadPolicyDef` — non-boolean predicate, missing entity, or a scope that collides. */
131
+ readonly invalidReadPolicy: "INVALID_READ_POLICY";
132
+ /** More than one `ReadPolicyDef` governing the same entity. */
133
+ readonly duplicateReadPolicy: "DUPLICATE_READ_POLICY";
134
+ /** A `QueryDef.filter` that is not a boolean expression, or reads outside `rowScopeId` / parameters / `PRINCIPAL`. */
135
+ readonly invalidQueryPredicate: "INVALID_QUERY_PREDICATE";
136
+ /** A `QuerySortKey` whose projected key is not an orderable type. */
137
+ readonly invalidQuerySort: "INVALID_QUERY_SORT";
138
+ /** A projected field that is not on the projection entity, or whose value type is incompatible. */
139
+ readonly invalidQueryProjection: "INVALID_QUERY_PROJECTION";
140
+ /** A `sum`/`average` aggregate over a non-numeric key, a `count` carrying a key, or a missing `as`. */
141
+ readonly invalidQueryAggregate: "INVALID_QUERY_AGGREGATE";
142
+ /** `groupBy` without `aggregate`, or a group key that is not comparable. */
143
+ readonly invalidQueryGrouping: "INVALID_QUERY_GROUPING";
144
+ /** A duplicate parameter id, an invalid parameter `TypeRef`, or a parameter id colliding with a node. */
145
+ readonly invalidQueryParameter: "INVALID_QUERY_PARAMETER";
146
+ /** Cursor pagination whose ordering is not provably deterministic — no unique key and no usable identity tie-breaker. */
147
+ readonly unstablePagination: "UNSTABLE_PAGINATION";
148
+ /** A `query` operation used where it cannot execute — inside a `for-each`. */
149
+ readonly invalidQueryOperation: "INVALID_QUERY_OPERATION";
150
+ /** A `provider-record` location whose entity does not resolve, or whose identity field is not that entity's identity. */
151
+ readonly invalidProviderRecordLocation: "INVALID_PROVIDER_RECORD_LOCATION";
152
+ /** A `MigrationDef` whose `fromSchema`/`toSchema` are not consecutive positive integers, or a migration whose `fromSchema` is at or beyond `graph.schemaVersion`. */
153
+ readonly invalidMigrationVersion: "INVALID_MIGRATION_VERSION";
154
+ /** No contiguous `MigrationDef` chain connects schema 1 to `graph.schemaVersion` — a step is missing (spec11 §13). */
155
+ readonly migrationPathNotFound: "MIGRATION_PATH_NOT_FOUND";
156
+ /** Two `MigrationDef` nodes share the same `fromSchema` — the upgrade path would be ambiguous. */
157
+ readonly migrationChainFork: "MIGRATION_CHAIN_FORK";
158
+ /** Two migration operations share an `id` — `approveDestructive` could not address them unambiguously. */
159
+ readonly duplicateMigrationOperationId: "DUPLICATE_MIGRATION_OPERATION_ID";
160
+ /** An `add-field` operation adds a `required` field with no `populate` expression — existing rows cannot become valid, and Axiom does not invent a value (spec11 §18). */
161
+ readonly migrationRequiredFieldWithoutDefault: "MIGRATION_REQUIRED_FIELD_WITHOUT_DEFAULT";
162
+ /** A `remove-field` / `remove-entity` / narrowing operation not marked `destructive: true` — dropping persisted data must be acknowledged, never silent (spec11 §19, §20, §77). */
163
+ readonly migrationDestructiveUnmarked: "MIGRATION_DESTRUCTIVE_UNMARKED";
164
+ /** A structurally malformed migration operation — an empty `change-field.to`, a `transform-record.produce` that is not an `object` expression, a relationship operation with no relationship. */
165
+ readonly invalidMigrationOperation: "INVALID_MIGRATION_OPERATION";
166
+ /** A migration transform expression that is not pure — it calls `now` or `uuid`, or reads a scope other than the old record and declared constants (spec11 §25, §26). */
167
+ readonly migrationTransformImpure: "MIGRATION_TRANSFORM_IMPURE";
168
+ /** A `transform-field` whose declared `toType` does not match the field's type in the target schema, or an `add-field` `populate` whose value cannot satisfy the field (spec11 §77). */
169
+ readonly migrationTransformTypeMismatch: "MIGRATION_TRANSFORM_TYPE_MISMATCH";
122
170
  };
123
171
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -109,4 +109,56 @@ export const VALIDATION_CODES = {
109
109
  invalidBlobEntity: 'INVALID_BLOB_ENTITY',
110
110
  /** A blob operation that cannot execute as written — inside a `for-each`, or with no readable key. */
111
111
  invalidBlobOperation: 'INVALID_BLOB_OPERATION',
112
+ // Semantic data access & query layer (0.10). `validateGraph` rejects a query that could
113
+ // not execute, rather than letting it validate and then fail at the provider.
114
+ /** A `QueryDef.source`, a `RelationshipDef` endpoint entity, or a `ReadPolicyDef.entityId` that does not resolve to an entity. */
115
+ unknownQueryEntity: 'UNKNOWN_QUERY_ENTITY',
116
+ /** A `QueryRelationshipUse.relationshipId`, or a `query` operation's `queryId`, that does not resolve. */
117
+ unknownRelationship: 'UNKNOWN_RELATIONSHIP',
118
+ /** A `RelationshipDef` whose endpoints are inconsistent — a to-one whose `to.fieldId` is not the target's identity, a field not on its stated entity. */
119
+ invalidRelationship: 'INVALID_RELATIONSHIP',
120
+ /** A `QueryDef.readPolicyId` that does not resolve to a `read-policy` node. */
121
+ unknownReadPolicy: 'UNKNOWN_READ_POLICY',
122
+ /** A malformed `ReadPolicyDef` — non-boolean predicate, missing entity, or a scope that collides. */
123
+ invalidReadPolicy: 'INVALID_READ_POLICY',
124
+ /** More than one `ReadPolicyDef` governing the same entity. */
125
+ duplicateReadPolicy: 'DUPLICATE_READ_POLICY',
126
+ /** A `QueryDef.filter` that is not a boolean expression, or reads outside `rowScopeId` / parameters / `PRINCIPAL`. */
127
+ invalidQueryPredicate: 'INVALID_QUERY_PREDICATE',
128
+ /** A `QuerySortKey` whose projected key is not an orderable type. */
129
+ invalidQuerySort: 'INVALID_QUERY_SORT',
130
+ /** A projected field that is not on the projection entity, or whose value type is incompatible. */
131
+ invalidQueryProjection: 'INVALID_QUERY_PROJECTION',
132
+ /** A `sum`/`average` aggregate over a non-numeric key, a `count` carrying a key, or a missing `as`. */
133
+ invalidQueryAggregate: 'INVALID_QUERY_AGGREGATE',
134
+ /** `groupBy` without `aggregate`, or a group key that is not comparable. */
135
+ invalidQueryGrouping: 'INVALID_QUERY_GROUPING',
136
+ /** A duplicate parameter id, an invalid parameter `TypeRef`, or a parameter id colliding with a node. */
137
+ invalidQueryParameter: 'INVALID_QUERY_PARAMETER',
138
+ /** Cursor pagination whose ordering is not provably deterministic — no unique key and no usable identity tie-breaker. */
139
+ unstablePagination: 'UNSTABLE_PAGINATION',
140
+ /** A `query` operation used where it cannot execute — inside a `for-each`. */
141
+ invalidQueryOperation: 'INVALID_QUERY_OPERATION',
142
+ /** A `provider-record` location whose entity does not resolve, or whose identity field is not that entity's identity. */
143
+ invalidProviderRecordLocation: 'INVALID_PROVIDER_RECORD_LOCATION',
144
+ // Schema evolution & semantic migrations (0.11). `validateGraph` rejects an internally
145
+ // inconsistent migration declaration before any persisted data is touched (spec11 §77, §78).
146
+ /** A `MigrationDef` whose `fromSchema`/`toSchema` are not consecutive positive integers, or a migration whose `fromSchema` is at or beyond `graph.schemaVersion`. */
147
+ invalidMigrationVersion: 'INVALID_MIGRATION_VERSION',
148
+ /** No contiguous `MigrationDef` chain connects schema 1 to `graph.schemaVersion` — a step is missing (spec11 §13). */
149
+ migrationPathNotFound: 'MIGRATION_PATH_NOT_FOUND',
150
+ /** Two `MigrationDef` nodes share the same `fromSchema` — the upgrade path would be ambiguous. */
151
+ migrationChainFork: 'MIGRATION_CHAIN_FORK',
152
+ /** Two migration operations share an `id` — `approveDestructive` could not address them unambiguously. */
153
+ duplicateMigrationOperationId: 'DUPLICATE_MIGRATION_OPERATION_ID',
154
+ /** An `add-field` operation adds a `required` field with no `populate` expression — existing rows cannot become valid, and Axiom does not invent a value (spec11 §18). */
155
+ migrationRequiredFieldWithoutDefault: 'MIGRATION_REQUIRED_FIELD_WITHOUT_DEFAULT',
156
+ /** A `remove-field` / `remove-entity` / narrowing operation not marked `destructive: true` — dropping persisted data must be acknowledged, never silent (spec11 §19, §20, §77). */
157
+ migrationDestructiveUnmarked: 'MIGRATION_DESTRUCTIVE_UNMARKED',
158
+ /** A structurally malformed migration operation — an empty `change-field.to`, a `transform-record.produce` that is not an `object` expression, a relationship operation with no relationship. */
159
+ invalidMigrationOperation: 'INVALID_MIGRATION_OPERATION',
160
+ /** A migration transform expression that is not pure — it calls `now` or `uuid`, or reads a scope other than the old record and declared constants (spec11 §25, §26). */
161
+ migrationTransformImpure: 'MIGRATION_TRANSFORM_IMPURE',
162
+ /** A `transform-field` whose declared `toType` does not match the field's type in the target schema, or an `add-field` `populate` whose value cannot satisfy the field (spec11 §77). */
163
+ migrationTransformTypeMismatch: 'MIGRATION_TRANSFORM_TYPE_MISMATCH',
112
164
  };
@@ -57,7 +57,7 @@ export interface UnaryExpression {
57
57
  * must be implemented by the runtime: a function that is declared here but unevaluated
58
58
  * would be a construct that typechecks, validates and then does nothing.
59
59
  */
60
- export type BuiltinFunction = 'required' | 'is-empty' | 'non-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
60
+ export type BuiltinFunction = 'required' | 'is-empty' | 'non-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'trim' | 'substring-before' | 'substring-after' | 'now' | 'uuid';
61
61
  export declare const BUILTIN_FUNCTIONS: readonly BuiltinFunction[];
62
62
  /** Functions that reduce a collection of numbers to a number. */
63
63
  export declare const AGGREGATE_FUNCTIONS: readonly BuiltinFunction[];
@@ -32,6 +32,9 @@ export const BUILTIN_FUNCTIONS = [
32
32
  'sum',
33
33
  'lowercase',
34
34
  'to-string',
35
+ 'trim',
36
+ 'substring-before',
37
+ 'substring-after',
35
38
  'now',
36
39
  'uuid',
37
40
  ];
package/dist/graph.d.ts CHANGED
@@ -32,6 +32,13 @@ export declare class ApplicationGraph {
32
32
  get id(): string;
33
33
  get name(): string;
34
34
  get version(): string;
35
+ /**
36
+ * The application's semantic schema version (spec11 §6) — a monotonic integer, distinct
37
+ * from {@link version}. Defaults to `1`. A `MigrationDef` chain must connect every
38
+ * integer from the persisted version up to this one.
39
+ */
40
+ get schemaVersion(): number;
41
+ setSchemaVersion(schemaVersion: number): void;
35
42
  /**
36
43
  * The application's visual identity, completed against the default theme. A theme is
37
44
  * presentation only: changing it cannot change an action, a constraint or a route.
package/dist/graph.js CHANGED
@@ -23,7 +23,7 @@ export class ApplicationGraph {
23
23
  /** Bumped by every change, so the derived edge index can never serve stale data. */
24
24
  revision = 0;
25
25
  semanticIndex;
26
- constructor(id, name, version = '0.9.0') {
26
+ constructor(id, name, version = '0.11.0') {
27
27
  this.data = { id, name, version, nodes: {}, edges: {} };
28
28
  }
29
29
  get id() {
@@ -35,6 +35,21 @@ export class ApplicationGraph {
35
35
  get version() {
36
36
  return this.data.version;
37
37
  }
38
+ /**
39
+ * The application's semantic schema version (spec11 §6) — a monotonic integer, distinct
40
+ * from {@link version}. Defaults to `1`. A `MigrationDef` chain must connect every
41
+ * integer from the persisted version up to this one.
42
+ */
43
+ get schemaVersion() {
44
+ return this.data.schemaVersion ?? 1;
45
+ }
46
+ setSchemaVersion(schemaVersion) {
47
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
48
+ throw new Error(`schemaVersion must be a positive integer, got ${schemaVersion}`);
49
+ }
50
+ this.data.schemaVersion = schemaVersion;
51
+ this.revision += 1;
52
+ }
38
53
  /**
39
54
  * The application's visual identity, completed against the default theme. A theme is
40
55
  * presentation only: changing it cannot change an action, a constraint or a route.
package/dist/index.d.ts CHANGED
@@ -13,17 +13,24 @@ export * from './events.js';
13
13
  export * from './triggers.js';
14
14
  export * from './subscriptions.js';
15
15
  export * from './storage.js';
16
+ export * from './query.js';
17
+ export * from './relationships.js';
18
+ export * from './read-policy.js';
19
+ export * from './migration.js';
16
20
  export * from './renderer-capabilities.js';
17
21
  export * from './trigger-capabilities.js';
18
22
  export * from './authoring-metadata.js';
19
23
  export * from './ui.js';
20
24
  export * from './types.js';
21
25
  export * from './graph.js';
26
+ export * from './schema-identity.js';
27
+ export * from './schema-diff.js';
22
28
  export * from './infer.js';
23
29
  export * from './context.js';
24
30
  export * from './validate-location.js';
25
31
  export * from './resolve-presentation.js';
26
32
  export * from './validate.js';
33
+ export * from './validate-migration.js';
27
34
  export * from './validate-presentation.js';
28
35
  export * from './validate-authority.js';
29
36
  export * from './validate-value.js';
package/dist/index.js CHANGED
@@ -13,17 +13,24 @@ export * from './events.js';
13
13
  export * from './triggers.js';
14
14
  export * from './subscriptions.js';
15
15
  export * from './storage.js';
16
+ export * from './query.js';
17
+ export * from './relationships.js';
18
+ export * from './read-policy.js';
19
+ export * from './migration.js';
16
20
  export * from './renderer-capabilities.js';
17
21
  export * from './trigger-capabilities.js';
18
22
  export * from './authoring-metadata.js';
19
23
  export * from './ui.js';
20
24
  export * from './types.js';
21
25
  export * from './graph.js';
26
+ export * from './schema-identity.js';
27
+ export * from './schema-diff.js';
22
28
  export * from './infer.js';
23
29
  export * from './context.js';
24
30
  export * from './validate-location.js';
25
31
  export * from './resolve-presentation.js';
26
32
  export * from './validate.js';
33
+ export * from './validate-migration.js';
27
34
  export * from './validate-presentation.js';
28
35
  export * from './validate-authority.js';
29
36
  export * from './validate-value.js';
package/dist/infer.js CHANGED
@@ -8,6 +8,10 @@ export function inferLocationType(location, context) {
8
8
  switch (location.kind) {
9
9
  case 'state':
10
10
  return context.getState(location.stateId)?.valueType;
11
+ case 'provider-record':
12
+ return context.getEntity(location.sourceEntityId)
13
+ ? { kind: 'entity', entityId: location.sourceEntityId }
14
+ : undefined;
11
15
  case 'field': {
12
16
  const parent = unwrap(inferLocationType(location.target, context));
13
17
  if (parent?.kind === 'entity') {
@@ -30,12 +34,29 @@ export function inferLocationType(location, context) {
30
34
  }
31
35
  /** Derived state is readable but never writable; everything else follows its root. */
32
36
  export function locationCapabilities(location, context) {
37
+ // A provider-backed record is canonical authoritative data: readable and writable by
38
+ // definition, and never a derivation.
39
+ if (locationProviderRoot(location)) {
40
+ return { readable: true, writable: true };
41
+ }
33
42
  const root = rootState(location, context);
34
43
  if (!root) {
35
44
  return { readable: false, writable: false };
36
45
  }
37
46
  return { readable: true, writable: root.derivation === undefined };
38
47
  }
48
+ function locationProviderRoot(location) {
49
+ switch (location.kind) {
50
+ case 'provider-record':
51
+ return true;
52
+ case 'field':
53
+ return locationProviderRoot(location.target);
54
+ case 'collection-item':
55
+ return locationProviderRoot(location.collection);
56
+ default:
57
+ return false;
58
+ }
59
+ }
39
60
  function rootState(location, context) {
40
61
  switch (location.kind) {
41
62
  case 'state':
@@ -5,11 +5,41 @@ import type { FieldId, NodeId } from './ids.js';
5
5
  * opposed to an Expression, which says what a value is. Nothing writable is ever
6
6
  * expressed as an Expression, so no mutation depends on JavaScript object identity.
7
7
  */
8
- export type Location = StateLocation | FieldLocation | CollectionItemLocation;
8
+ export type Location = StateLocation | FieldLocation | CollectionItemLocation | ProviderRecordLocation;
9
9
  export interface StateLocation {
10
10
  kind: 'state';
11
11
  stateId: NodeId;
12
12
  }
13
+ /**
14
+ * Addresses one canonical **provider-backed** record by its identity, without that record
15
+ * ever being materialized into a `StateDef` (spec 0.10 §37-39). It is the read/write
16
+ * counterpart of a `QueryDef`: a `QueryDef` reads many rows a page at a time, a
17
+ * `provider-record` location names exactly one for a `set` or `remove` inside an action's
18
+ * transaction.
19
+ *
20
+ * It is not a parallel mutation model. The authority loads the addressed rows into the
21
+ * action's transaction, the ordinary mutation engine applies the `set`/`remove` and
22
+ * re-checks every constraint and transition rule over the proposed rows, and the provider
23
+ * commits the touched row set atomically or not at all. A rollback sends the provider
24
+ * nothing.
25
+ *
26
+ * ```ts
27
+ * // confirmOrder(orderId): set Order[id == $orderId].status = 'confirmed'
28
+ * fieldLocation(
29
+ * providerRecordLocation(ENTITY_ORDER, F_ORDER_ID, ref(P_ORDER_ID)),
30
+ * F_ORDER_STATUS,
31
+ * )
32
+ * ```
33
+ */
34
+ export interface ProviderRecordLocation {
35
+ kind: 'provider-record';
36
+ /** The entity whose canonical store the record lives in. */
37
+ sourceEntityId: NodeId;
38
+ /** The identity field the record is selected by — must be the entity's `identityFieldId`. */
39
+ identityFieldId: FieldId;
40
+ /** The identity value, evaluated in the action's argument scope before the transaction opens. */
41
+ identityValue: Expression;
42
+ }
13
43
  export interface FieldLocation {
14
44
  kind: 'field';
15
45
  target: Location;
@@ -36,13 +66,26 @@ export declare function fieldLocation(target: Location, fieldId: FieldId): Field
36
66
  export declare function itemLocation(collection: Location, selector: CollectionSelector): CollectionItemLocation;
37
67
  export declare function identitySelector(fieldId: FieldId, value: Expression): IdentitySelector;
38
68
  export declare function indexSelector(index: Expression): IndexSelector;
69
+ export declare function providerRecordLocation(sourceEntityId: NodeId, identityFieldId: FieldId, identityValue: Expression): ProviderRecordLocation;
70
+ /** Convenience: one field of one provider-backed record selected by identity. */
71
+ export declare function providerRecordFieldLocation(sourceEntityId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
39
72
  /** Convenience for the common shape: one field of one item of a collection state. */
40
73
  export declare function itemFieldLocation(stateId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
41
- /** The state node every location is ultimately rooted in. */
74
+ /**
75
+ * The node every location is ultimately rooted in.
76
+ *
77
+ * For an ordinary location this is the `StateDef` id. For a `provider-record` location
78
+ * there is no state — the record was never materialized — so this returns the **source
79
+ * entity id** instead. Callers that resolve the result as a state (`context.states.get`)
80
+ * naturally get `undefined` and skip it, which is correct: nothing materialized holds the
81
+ * record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
82
+ */
42
83
  export declare function locationRootStateId(location: Location): NodeId;
84
+ /** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
85
+ export declare function locationProviderEntityId(location: Location): NodeId | undefined;
43
86
  /**
44
- * Expressions embedded in a location — selector values and indexes. These are read
45
- * dependencies of whatever uses the location.
87
+ * Expressions embedded in a location — selector values, indexes and a provider-record's
88
+ * identity value. These are read dependencies of whatever uses the location.
46
89
  */
47
90
  export declare function locationExpressions(location: Location): Expression[];
48
91
  /** Fields a write through this location touches, outermost first. */
package/dist/location.js CHANGED
@@ -13,15 +13,32 @@ export function identitySelector(fieldId, value) {
13
13
  export function indexSelector(index) {
14
14
  return { kind: 'index', index };
15
15
  }
16
+ export function providerRecordLocation(sourceEntityId, identityFieldId, identityValue) {
17
+ return { kind: 'provider-record', sourceEntityId, identityFieldId, identityValue };
18
+ }
19
+ /** Convenience: one field of one provider-backed record selected by identity. */
20
+ export function providerRecordFieldLocation(sourceEntityId, identityFieldId, identityValue, fieldId) {
21
+ return fieldLocation(providerRecordLocation(sourceEntityId, identityFieldId, identityValue), fieldId);
22
+ }
16
23
  /** Convenience for the common shape: one field of one item of a collection state. */
17
24
  export function itemFieldLocation(stateId, identityFieldId, identityValue, fieldId) {
18
25
  return fieldLocation(itemLocation(stateLocation(stateId), identitySelector(identityFieldId, identityValue)), fieldId);
19
26
  }
20
- /** The state node every location is ultimately rooted in. */
27
+ /**
28
+ * The node every location is ultimately rooted in.
29
+ *
30
+ * For an ordinary location this is the `StateDef` id. For a `provider-record` location
31
+ * there is no state — the record was never materialized — so this returns the **source
32
+ * entity id** instead. Callers that resolve the result as a state (`context.states.get`)
33
+ * naturally get `undefined` and skip it, which is correct: nothing materialized holds the
34
+ * record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
35
+ */
21
36
  export function locationRootStateId(location) {
22
37
  switch (location.kind) {
23
38
  case 'state':
24
39
  return location.stateId;
40
+ case 'provider-record':
41
+ return location.sourceEntityId;
25
42
  case 'field':
26
43
  return locationRootStateId(location.target);
27
44
  case 'collection-item':
@@ -30,14 +47,29 @@ export function locationRootStateId(location) {
30
47
  throw new Error(`Unknown location kind "${location.kind}"`);
31
48
  }
32
49
  }
50
+ /** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
51
+ export function locationProviderEntityId(location) {
52
+ switch (location.kind) {
53
+ case 'provider-record':
54
+ return location.sourceEntityId;
55
+ case 'field':
56
+ return locationProviderEntityId(location.target);
57
+ case 'collection-item':
58
+ return locationProviderEntityId(location.collection);
59
+ default:
60
+ return undefined;
61
+ }
62
+ }
33
63
  /**
34
- * Expressions embedded in a location — selector values and indexes. These are read
35
- * dependencies of whatever uses the location.
64
+ * Expressions embedded in a location — selector values, indexes and a provider-record's
65
+ * identity value. These are read dependencies of whatever uses the location.
36
66
  */
37
67
  export function locationExpressions(location) {
38
68
  switch (location.kind) {
39
69
  case 'state':
40
70
  return [];
71
+ case 'provider-record':
72
+ return [location.identityValue];
41
73
  case 'field':
42
74
  return locationExpressions(location.target);
43
75
  case 'collection-item': {
@@ -57,6 +89,8 @@ export function locationSelectorFieldIds(location) {
57
89
  switch (location.kind) {
58
90
  case 'state':
59
91
  return [];
92
+ case 'provider-record':
93
+ return [location.identityFieldId];
60
94
  case 'field':
61
95
  return locationSelectorFieldIds(location.target);
62
96
  case 'collection-item':