@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 +81 -14
- package/dist/index.cjs +684 -61
- package/dist/index.d.cts +255 -43
- package/dist/index.d.ts +255 -43
- package/dist/index.js +673 -62
- package/package.json +2 -2
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
|
+
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 —
|
|
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]
|
|
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 —
|
|
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
|
|
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)
|
|
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]`)
|
|
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
|
|
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
|
-
- **
|
|
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
|
|