@coldsmirk/abacus-core 0.4.1 → 0.6.0

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
@@ -9,6 +9,7 @@ Part of [abacus](https://github.com/coldsmirk/abacus). To edit expressions in an
9
9
  - **Evaluate** ZEN expressions against a data context, sync or async.
10
10
  - **Compile** a structured, UI-authored condition tree into a single ZEN boolean expression — and **lift** the canonical form back into a tree for round-trip editing — plus **select** a matching branch by priority.
11
11
  - **Type-analyze** an expression against a variable-context type tree — the inferred type of every span, autocomplete metadata, and positioned diagnostics that power an editor.
12
+ - **Project and infer JSON Schema** through a lossless field-tree subset, then turn that tree into an expression variable type.
12
13
  - **Localize** all editor-produced text through a typed, extensible message catalog.
13
14
  - **Strong typing** end to end, dual ESM / CJS, and a single lazily-loaded WebAssembly engine instance.
14
15
 
@@ -18,7 +19,7 @@ Part of [abacus](https://github.com/coldsmirk/abacus). To edit expressions in an
18
19
  pnpm add @coldsmirk/abacus-core
19
20
  ```
20
21
 
21
- The ZEN WebAssembly engine (`@gorules/zen-engine-wasm`) is a regular dependency and installs automatically. Node.js >= 22; browsers need ES2023 support (Chrome/Edge 111+, Safari 16.2+, Firefox 115+) — the bundles ship untranspiled.
22
+ The ZEN WebAssembly engine (`@gorules/zen-engine-wasm`) is a regular dependency and installs automatically. Node.js >= 24; browsers need ES2023 support (Chrome/Edge 111+, Safari 16.2+, Firefox 115+) — the bundles ship untranspiled.
22
23
 
23
24
  ## Quick start
24
25
 
@@ -131,7 +132,7 @@ configureEngine({ wasmInput: await fetch("https://cdn.example.com/zen.wasm").the
131
132
  await loadEngine();
132
133
  ```
133
134
 
134
- `configureEngine()` throws if called after the engine has started loading. `resetEngine()` drops the loaded engine, the cached type context, the latched error, and the configured input — mainly for tests, but also the way to re-`configureEngine()` after a load.
135
+ `configureEngine()` throws if called after the engine has started loading. `resetEngine()` drops the loaded engine, the cached type context, the latched error, and the configured input, and orphans any in-flight load its later settlement is discarded, and the next `loadEngine()` waits for it to settle before touching the wasm initializer (which tolerates no concurrent calls). Mainly for tests, and the retry primitive for a **failed** load (reset, re-`configureEngine()`, load again). Once the wasm module has initialized successfully it stays instantiated for the lifetime of the JS realm, so a *different* `wasmInput` cannot take effect after a successful load.
135
136
 
136
137
  ## Type analysis & intelligence
137
138
 
@@ -193,7 +194,7 @@ interface ExpressionTypeSpan {
193
194
  error: string | null; // a type error attached to this span, if any
194
195
  kind: ExpressionType; // the inferred type of this span
195
196
  nodeKind: string; // the syntax-node kind
196
- span: [number, number]; // [from, to] character offsets
197
+ span: [number, number]; // [from, to] UTF-16 code-unit offsets
197
198
  }
198
199
  interface ExpressionDiagnostic {
199
200
  from: number; // start offset
@@ -213,6 +214,49 @@ interface ExpressionCompletion {
213
214
 
214
215
  > Building an editor? You usually do not call these directly — `@coldsmirk/abacus-codemirror` and `@coldsmirk/abacus-react` wire them into CodeMirror for you.
215
216
 
217
+ ## JSON Schema field trees
218
+
219
+ The schema helpers provide the data model behind a visual JSON Schema builder without depending on a UI framework. The tree hosts an object-oriented draft 2020-12 subset: `type`, `properties`, `items`, `required`, and `description`, plus the optional root `$schema` declaration. Parsing refuses every unsupported construct with a structured `SchemaTreeIssue`; it never accepts a keyword and then silently removes it on serialization.
220
+
221
+ ```ts
222
+ import {
223
+ inferSchema,
224
+ parseSchemaTree,
225
+ schemaTreeToExpressionType,
226
+ serializeSchemaTree
227
+ } from "@coldsmirk/abacus-core";
228
+
229
+ const inferred = inferSchema({
230
+ amount: 120,
231
+ customer: { name: "Ada" },
232
+ tags: ["priority"]
233
+ });
234
+
235
+ const parsed = parseSchemaTree(JSON.stringify(inferred));
236
+
237
+ if (parsed.ok) {
238
+ serializeSchemaTree(parsed.tree); // pretty-printed schema JSON
239
+ schemaTreeToExpressionType(parsed.tree); // { Object: { amount: "Number", ... } }
240
+ } else {
241
+ parsed.issue; // a localizable SchemaTreeIssue
242
+ }
243
+ ```
244
+
245
+ ```ts
246
+ function inferSchema(sample: Json): Record<string, Json>;
247
+ function parseSchemaTree(text: string): SchemaTreeParseResult;
248
+ function serializeSchemaTree(tree: SchemaTree): string;
249
+ function schemaTreeToExpressionType(tree: SchemaTree): ExpressionType;
250
+ function newSchemaTreeField(overrides?: Partial<Omit<SchemaTreeField, "id">>): SchemaTreeField;
251
+ const SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
252
+ ```
253
+
254
+ `inferSchema` is intentionally conservative: integer samples infer `number`, no property becomes `required` from one observation, `null` says nothing (`{}`), and heterogeneous arrays merge to the loosest schema the field-tree subset can express.
255
+
256
+ The parser preserves JSON Schema applicability, not merely its visible fields. A schema with `properties` or `items` but no `type` still permits instances of other types, so `SchemaTree.explicitType` and an object/array field's `explicitType` remember that omission and serialization does not add an object/array type. Hand-built trees that omit the optional metadata keep the original builder default and emit an explicit type.
257
+
258
+ Property names are arbitrary JSON strings. Empty, whitespace-only, and prototype-like names such as `__proto__` round-trip and enter the generated `ExpressionType`. A parsed blank name carries `preserveBlankName: true`; a blank row from `newSchemaTreeField()` has no marker and remains an unfinished UI draft that is omitted from serialization and expression types. Hosts editing a parsed tree should update nodes immutably (for example, with object spread) so these optional source-preservation fields survive.
259
+
216
260
  ## Compiling conditions
217
261
 
218
262
  A common need is to let users author conditions in a UI (field / operator / value rows, AND/OR groups, prioritized branches) and turn that structure into a ZEN expression. This package models that structure and compiles it — without depending on any editor.
@@ -254,7 +298,7 @@ interface ConditionBranchInput { // a branch guarded by groups co
254
298
  | `contains` / `not_contains` | `contains(subject, value)` / `not contains(subject, value)` |
255
299
  | `starts_with` / `ends_with` | `startsWith(subject, value)` / `endsWith(subject, value)` |
256
300
  | `in` / `not_in` | `subject in [..]` / `not (subject in [..])` (a scalar value is wrapped to a one-element array) |
257
- | `is_empty` / `is_not_empty` | a typed emptiness test (null / blank string / empty array) and its negation |
301
+ | `is_empty` / `is_not_empty` | a typed emptiness test (null / blank string / empty array / empty object) and its negation |
258
302
 
259
303
  ### Compiling and selecting
260
304
 
@@ -296,17 +340,20 @@ type BranchSelection =
296
340
 
297
341
  ### Condition trees
298
342
 
299
- The flat model above is one AND-group per branch. For a structured builder UI — arbitrarily nested AND/OR groups of typed comparison rules — the package also models a **condition tree** and compiles it to a single canonical expression, with an exact inverse:
343
+ The flat model above is one AND-group per branch. For a structured builder UI — bounded AND/OR groups of typed comparison rules — the package also models a **condition tree**, compiles it to a single canonical expression, and lifts non-empty canonical expressions back to trees:
300
344
 
301
345
  ```ts
302
346
  interface ConditionTreeRule { // a leaf comparison
303
347
  kind: "rule";
348
+ id?: string; // stable UI identity (React keys); ignored by the compiler
304
349
  left: string; // an identifier path, like `subject`
305
350
  operator: ConditionTreeOperator; // the compiler's full 14-operator set
306
351
  right?: ConditionTreeValue; // per the operator's arity (see below)
352
+ rightElementType?: "string" | "number"; // UI hint: element type intended for an EMPTY membership array
307
353
  }
308
- interface ConditionTreeGroup { // an and/or group of rules and subgroups
354
+ interface ConditionTreeGroup { // an and/or group, at most 64 nested group edges below the root
309
355
  kind: "group";
356
+ id?: string;
310
357
  op: "and" | "or";
311
358
  items: readonly ConditionTreeNode[];
312
359
  }
@@ -315,6 +362,10 @@ type ConditionTreeNode = ConditionTreeGroup | ConditionTreeRule;
315
362
  function compileConditionTree(tree: ConditionTreeGroup): string;
316
363
  function liftConditionTree(expression: string): ConditionTreeGroup | null;
317
364
  function conditionOperatorArity(operator: ConditionOperator): "scalar" | "array" | "none";
365
+ function emptyConditionGroup(): ConditionTreeGroup; // a fresh "no condition yet" and-group
366
+ function newConditionNodeId(): string; // a fresh id for hand-built UI nodes
367
+ function ensureConditionNodeIds(tree: ConditionTreeGroup): ConditionTreeGroup;
368
+ const MAX_CONDITION_TREE_DEPTH = 64;
318
369
  ```
319
370
 
320
371
  ```ts
@@ -339,20 +390,23 @@ A rule's `right` follows its operator's **arity** — a single scalar (`string |
339
390
 
340
391
  The emitted expression is **canonical**: nested groups are always parenthesized (never relying on `and`/`or` precedence), single-item groups collapse to their item, and a rule the tree cannot represent canonically — a non-path `left`, an off-arity `right`, a value with no ZEN literal — is dropped like an invalid flat condition. A tree with nothing compilable yields `""`.
341
392
 
342
- `liftConditionTree` recognizes exactly that canonical subset (whitespace-tolerantly), so the two functions round-trip: everything `compileConditionTree` emits lifts back to an equal tree, and anything else — a raw hand-authored expression, mixed `and`/`or` without parentheses, groups nested beyond 64 levels — returns `null` rather than guessing at structure. That `null` is a host's signal to fall back to raw-expression editing instead of a structured builder; [`@coldsmirk/abacus-mantine`](https://www.npmjs.com/package/@coldsmirk/abacus-mantine)'s `<ConditionBuilder>` is built on exactly this contract.
393
+ `liftConditionTree` recognizes exactly that non-empty canonical subset (whitespace-tolerantly): every non-empty expression `compileConditionTree` emits lifts back to the same **compiled** structure. This is not an identity round-trip for an arbitrary input tree. Compilation drops incomplete or unrepresentable rules, collapses single-item groups, and returns `""` when nothing is compilable (`liftConditionTree("")` returns `null`). The UI-only fields never reach the expression either, so a compile → lift trip regenerates `id`s and **loses `rightElementType`**: an empty membership's Number intent survives while you store the tree, but not through the expression (`a in []` lifts back with the default Text intent). Persist the tree itself when incomplete editing state, exact group shape, or UI intent must survive. Anything outside the canonical expression subset — a raw hand-authored expression, mixed `and`/`or` without parentheses, groups nested beyond 64 levels, number literals the compiler could never emit (`9007199254740993` would silently round through float64, `1e999` collapses to Infinity, `-0` stringifies to `0`, `-9223372036854776000` has no faithful literal even though its positive twin does) makes the lifter return `null` rather than guessing at structure or rewriting a value. That `null` is a host's signal to fall back to raw-expression editing instead of a structured builder; [`@coldsmirk/abacus-mantine`](https://www.npmjs.com/package/@coldsmirk/abacus-mantine)'s `<ConditionBuilder>` is built on exactly this contract.
394
+
395
+ All tree consumers share `MAX_CONDITION_TREE_DEPTH`: the root is depth 0 and up to 64 nested group edges are accepted. `compileConditionTree` and `ensureConditionNodeIds` throw `ExpressionError` for a deeper tree or an ancestor cycle, before entering their bounded recursive work; `liftConditionTree` returns `null` when expression nesting exceeds the same budget. The builder disables subgroup creation at the limit, so it cannot produce a tree the compiler would reject.
343
396
 
344
397
  ### Safety: subjects, values, and escaping
345
398
 
346
399
  The compiler is designed not to become an expression-injection sink:
347
400
 
348
- - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`) whose segments are not ZEN reserved words (`and`, `or`, `not`, `in`, `true`, `false`, `null`). Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
349
- - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a delimiter the value does not contain; a value that cannot be represented (an object, a string containing both quote styles, a non-finite number) makes the condition degrade to `null`.
401
+ - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`). ZEN keywords cannot start the path; in dotted member positions the parser accepts `and` / `or` / `not` / `in` / `null` as object keys (`metadata.in` is valid), while `true` / `false` remain literal tokens and are rejected there too. Every array index token must also be inside ZEN's unsigned 96-bit decimal domain (`0` through `2^96 - 1`); the next value is a parser error, so the compiler rejects it instead of poisoning a larger expression. Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
402
+ - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a single-quote, double-quote, or backtick delimiter the value does not contain; only a string containing all three delimiters is unrepresentable. For numbers, faithful representation means passing `isZenRepresentableNumber`: inside ZEN's 96-bit decimal domain (finite, magnitude below ~7.9e28, no finer than 1e-28) **and** held identically by the engine's own context conversion — the engine converts context JS numbers lossily (roughly: integers whose text fits u64 are exact; other f64s are trimmed to ~16 significant digits), so a value like `0.1 + 0.2` or `1.2345678901234567` would compile into a literal that silently compares **unequal to itself** in a context. Such values are rejected instead.
350
403
 
351
404
  ```ts
352
405
  function toZenLiteral(value: unknown): string; // throws if the value has no faithful ZEN representation
353
406
 
354
407
  toZenLiteral("active"); // "'active'"
355
408
  toZenLiteral("it's"); // "\"it's\"" (switches delimiter)
409
+ toZenLiteral(`He said "it's"`); // "`He said \"it's\"`"
356
410
  toZenLiteral(1500); // "1500"
357
411
  toZenLiteral(["BJ", "SH"]); // "['BJ', 'SH']"
358
412
  toZenLiteral(null); // "null"
@@ -381,7 +435,19 @@ configureExpressionMessages({ locale: "zh-CN" }); // switc
381
435
  configureExpressionMessages({ messages: { typeCheckSource: "Validation" } }); // override individual strings
382
436
  ```
383
437
 
384
- The catalog is **process-global and last-write-wins** — there is one active catalog per runtime, so configure it once at startup. Built-in catalogs `enMessages` (the default) and `zhCNMessages` are exported. `getExpressionMessages()` returns the active one.
438
+ The catalog is **process-global and last-write-wins** — there is one active catalog per runtime, so configure it once at startup. Built-in catalogs `enMessages` (the default) and `zhCNMessages` are exported. `getExpressionMessages()` returns the active one. Each `messages` object replaces the previous override set over the current locale base; omitting it removes earlier overrides instead of accumulating them.
439
+
440
+ Framework integrations that keep already-rendered diagnostics can subscribe to catalog changes. The listener runs synchronously after the new catalog becomes active; the returned cleanup is idempotent:
441
+
442
+ ```ts
443
+ import { subscribeExpressionMessages } from "@coldsmirk/abacus-core";
444
+
445
+ const unsubscribe = subscribeExpressionMessages(messages => {
446
+ refreshDiagnostics(messages);
447
+ });
448
+
449
+ unsubscribe();
450
+ ```
385
451
 
386
452
  To add a language the library does not ship, register a full catalog under a key, then select it like any built-in — built-in and custom locales share one registry, so there is no privileged path:
387
453
 
@@ -415,11 +481,12 @@ class ExpressionNotReadyError extends ExpressionError { // a *Sync helper calle
415
481
  - **Engine lifecycle** — `loadEngine`, `isEngineReady`, `getEngineSync`, `getEngineError`, `configureEngine`, `resetEngine`
416
482
  - **Type analysis** — `analyzeTypes` / `analyzeTypesSync`, `getDiagnostics` / `getDiagnosticsSync`, `getCompletionItems` / `getCompletionItemsSync`, `satisfiesType` / `satisfiesTypeSync`
417
483
  - **Templates** — `analyzeTemplate` / `analyzeTemplateSync`, `getTemplateDiagnostics` / `getTemplateDiagnosticsSync`, `parseTemplateHoles`, `templateHoleAt`
418
- - **Conditions** — `compileCondition`, `compileGroup`, `compileBranch`, `selectBranch`, `selectBranchWith`, `toZenLiteral`, `CONDITION_OPERATORS`, `conditionOperatorArity`
419
- - **Condition trees** — `compileConditionTree`, `liftConditionTree`, `CONDITION_TREE_OPERATORS`
420
- - **Localization** — `configureExpressionMessages`, `registerExpressionLocale`, `getExpressionMessages`, `enMessages`, `zhCNMessages`
484
+ - **Conditions** — `compileCondition`, `compileGroup`, `compileBranch`, `selectBranch`, `selectBranchWith`, `toZenLiteral`, `isZenRepresentableNumber`, `CONDITION_OPERATORS`, `conditionOperatorArity`
485
+ - **Condition trees** — `compileConditionTree`, `liftConditionTree`, `emptyConditionGroup`, `newConditionNodeId`, `ensureConditionNodeIds`, `MAX_CONDITION_TREE_DEPTH`, `CONDITION_TREE_OPERATORS`
486
+ - **JSON Schema** — `inferSchema`, `parseSchemaTree`, `serializeSchemaTree`, `schemaTreeToExpressionType`, `newSchemaTreeField`, `SCHEMA_DIALECT_2020_12`
487
+ - **Localization** — `configureExpressionMessages`, `registerExpressionLocale`, `getExpressionMessages`, `subscribeExpressionMessages`, `enMessages`, `zhCNMessages`
421
488
  - **Errors** — `ExpressionError`, `ExpressionNotReadyError`
422
- - **Types** — `ExpressionType`, `ExpressionMode`, `TemplateHole`, `ExpressionContext`, `ExpressionEngine`, `LoadEngineOptions`, `ExpressionAnalysis`, `ExpressionTypeSpan`, `ExpressionCompletion`, `ExpressionDiagnostic`, `ExpressionMessages`, `ExpressionLocale`, `BuiltInExpressionLocale`, `ConfigureMessagesOptions`, `ConditionInput`, `FieldConditionInput`, `ExpressionConditionInput`, `ConditionGroupInput`, `ConditionBranchInput`, `ConditionOperator`, `ConditionOperatorArity`, `BranchSelection`, `ConditionTreeGroup`, `ConditionTreeNode`, `ConditionTreeRule`, `ConditionTreeOperator`, `ConditionTreeValue`, `ConditionScalar`
489
+ - **Types** — `ExpressionType`, `ExpressionMode`, `TemplateHole`, `ExpressionContext`, `ExpressionEngine`, `LoadEngineOptions`, `ExpressionAnalysis`, `ExpressionTypeSpan`, `ExpressionCompletion`, `ExpressionDiagnostic`, `ExpressionMessages`, `ExpressionMessagesListener`, `ExpressionLocale`, `BuiltInExpressionLocale`, `ConfigureMessagesOptions`, `ConditionInput`, `FieldConditionInput`, `ExpressionConditionInput`, `ConditionGroupInput`, `ConditionBranchInput`, `ConditionOperator`, `ConditionOperatorArity`, `BranchSelection`, `ConditionTreeGroup`, `ConditionTreeNode`, `ConditionTreeRule`, `ConditionTreeOperator`, `ConditionTreeValue`, `ConditionScalar`, `Json`, `SchemaTree`, `SchemaTreeField`, `SchemaTreeFieldType`, `SchemaTreeIssue`, `SchemaTreeParseResult`
423
490
 
424
491
  ## License
425
492