Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 0 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ jobs:
- run: yarn format
- run: yarn lint
- run: yarn build
- run: yarn workspace mutxt-element run bundle
- run: yarn workspace mu-txt run electron:build
- run: yarn vitest
- run: yarn workspace @jsonjoy.com/rpc-server test:e2e:all
- run: yarn workspace @jsonjoy.com/json-crdt-server test:e2e
Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,6 @@ jobs:
- name: Run typecheck
run: yarn typecheck

- name: Bundle mutxt-element (Webpack)
run: yarn workspace mutxt-element run bundle

- name: Build mu-txt Electron (renderer + main process)
run: yarn workspace mu-txt run electron:build

- name: Run tests (vitest)
run: yarn vitest

Expand Down
6 changes: 0 additions & 6 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ const packages = [
'collaborative-ui',
'json-crdt-explorer',
'json-joy',
'mutxt-element',
'mutxt-react',
'peritext',
'ui',
];
Expand Down Expand Up @@ -113,10 +111,6 @@ const config: StorybookConfig = {
'@jsonjoy.com/json-equal': resolve(__dirname, '../packages/json-equal/src'),
'@jsonjoy.com/keyboard/lib': resolve(__dirname, '../packages/keyboard/src'),
'@jsonjoy.com/keyboard': resolve(__dirname, '../packages/keyboard/src'),
'mutxt-react/lib': resolve(__dirname, '../packages/mutxt-react/src'),
'mutxt-react': resolve(__dirname, '../packages/mutxt-react/src'),
'mutxt-element/lib': resolve(__dirname, '../packages/mutxt-element/src'),
'mutxt-element': resolve(__dirname, '../packages/mutxt-element/src'),
'@jsonjoy.com/json-expression/lib': resolve(__dirname, '../packages/json-expression/src'),
'@jsonjoy.com/json-pack/lib': resolve(__dirname, '../packages/json-pack/src'),
'@jsonjoy.com/json-path/lib': resolve(__dirname, '../packages/json-path/src'),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
}
},
"devDependencies": {
"@biomejs/biome": "^2.2.6",
"@biomejs/biome": "2.4.16",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-icons": "^1.3.1",
"@storybook/addon-a11y": "^10.3.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/click-json/src/ClickableJson/JsonHoverable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const JsonHoverable: React.FC<JsonHoverableProps> = ({pointer, children})
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onDelete={
!!onChange && !input.focused && prefixedPointer === focused
onChange && !input.focused && prefixedPointer === focused
? () => onChange([{op: 'remove', path: pointer}])
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const tryExtractPeritextOperation = (tr: Transaction, txt: Peritext, doc: PmNode
const step = steps[0];
if (!(step instanceof ReplaceStep)) return;
const slice = step.slice;
if (!!slice.openStart || !!slice.openEnd) return;
if (slice.openStart || slice.openEnd) return;
const content = slice.content;
let insertedText = '';
const deleteLen = step.to - step.from;
Expand Down
2 changes: 1 addition & 1 deletion packages/collaborative-slate/src/SlateFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class SlateFacade implements RichtextEditorFacade {
if (installHistory) withHistory(editor);
this._origOnChange = (editor as any).onChange;
(editor.onChange as SlateEditorOnChange) = this._slateOnChange = (options?: {operation?: SlateOperation}) => {
if (this._disposed || !!this._remoteCnt) {
if (this._disposed || this._remoteCnt) {
this._origOnChange?.call(editor);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const JsonBlockClickable: React.FC<JsonBlockClickableProps> = ({value, pa
}, [value, path]);

return (
<div className={css.block} style={{paddingLeft: !!view && typeof view === 'object' ? undefined : '1px'}}>
<div className={css.block} style={{paddingLeft: view && typeof view === 'object' ? undefined : '1px'}}>
<ClickableJson
doc={view}
compact
Expand Down
2 changes: 1 addition & 1 deletion packages/collaborative-ui/src/JsonCrdtModel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export const JsonCrdtModel: React.FC<JsonCrdtModelProps> = ({
listItems.push(
<JsonCrdtModelView key="view" readonly={readonly} model={model} state={state} filename={filename} />,
);
} else if (item === 'display' && !!renderDisplay) {
} else if (item === 'display' && renderDisplay) {
listItems.push(
<Display
key="display"
Expand Down
6 changes: 3 additions & 3 deletions packages/collaborative-ui/src/LiveCrdt/LiveCrdtRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {Input} from '@jsonjoy.com/ui/lib/2-inline-block/Input';
import {CopyInput} from '@jsonjoy.com/ui/lib/2-inline-block/CopyInput';
import {Button} from '@jsonjoy.com/ui/lib/2-inline-block/Button';
import {LiveCrdt, type LiveCrdtProps} from '.';
import {MiniTitle} from '@jsonjoy.com/ui/src/3-list-item/MiniTitle';
import {Separator} from '@jsonjoy.com/ui/src/3-list-item/Separator';
import {Split} from '@jsonjoy.com/ui/src/3-list-item/Split';
import {MiniTitle} from '@jsonjoy.com/ui/lib/3-list-item/MiniTitle';
import {Separator} from '@jsonjoy.com/ui/lib/3-list-item/Separator';
import {Split} from '@jsonjoy.com/ui/lib/3-list-item/Split';

export interface LiveCrdtRoomProps extends Omit<LiveCrdtProps, 'id'> {
/** Optional initial room ID. When omitted the entry screen is shown. */
Expand Down
11 changes: 5 additions & 6 deletions packages/json-crdt-repo/src/local/level/LevelLocalRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,7 @@ export class LevelLocalRepo implements LocalRepo {
const model = Model.load(blob, this.sid);
return model;
} catch (error) {
if (!!error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND')
throw new Error('NOT_FOUND');
if (error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND') throw new Error('NOT_FOUND');
throw error;
}
}
Expand Down Expand Up @@ -487,7 +486,7 @@ export class LevelLocalRepo implements LocalRepo {
const meta = await this.readMeta(keyBase);
if (meta.seq === -1) {
remote.create(remoteId).catch((error) => {
const code = !!error && typeof error === 'object' ? (error as any).code : undefined;
const code = error && typeof error === 'object' ? (error as any).code : undefined;
const message = error instanceof Error ? error.message : undefined;
if (code === 'CONFLICT' || message === 'CONFLICT') return;
this.emitSyncError(error);
Expand Down Expand Up @@ -747,7 +746,7 @@ export class LevelLocalRepo implements LocalRepo {
if (!model) throw RpcError.notFound();
return {model, cursor};
} catch (error) {
const code = !!error && typeof error === 'object' ? (error as any).code : undefined;
const code = error && typeof error === 'object' ? (error as any).code : undefined;
const message = error instanceof Error ? error.message : undefined;
const notFound = code === 'NOT_FOUND' || message === 'NOT_FOUND' || message === 'Not Found';
if (remote && notFound) return await this.load(id);
Expand Down Expand Up @@ -967,7 +966,7 @@ export class LevelLocalRepo implements LocalRepo {
const {model, meta} = await this.pullExisting(id, keyBase);
return {model, cursor: meta.seq};
} catch (error) {
if (!!error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND') {
if (error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND') {
try {
const {model, meta} = await this.pullNew(id, keyBase);
return {model, cursor: meta.seq};
Expand Down Expand Up @@ -1168,7 +1167,7 @@ export class LevelLocalRepo implements LocalRepo {
return;
}
} catch (error) {
if (!!error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND') {
if (error && typeof error === 'object' && (error as any).code === 'LEVEL_NOT_FOUND') {
if (this._stopped) return;
await this.pullNew(id, keyBase);
return;
Expand Down
4 changes: 2 additions & 2 deletions packages/json-crdt-repo/src/session/EditSessionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class EditSessionFactory {
return session;
} catch (error) {
const errorCode =
!!error && typeof error === 'object'
error && typeof error === 'object'
? (error as Record<string, unknown>).code || (error as Record<string, unknown>).message || ''
: '';
if (errorCode === 'NOT_FOUND') {
Expand All @@ -81,7 +81,7 @@ export class EditSessionFactory {
return session;
} catch (error) {
const errorCode =
!!error && typeof error === 'object'
error && typeof error === 'object'
? (error as Record<string, unknown>).code || (error as Record<string, unknown>).message || ''
: '';
switch (errorCode) {
Expand Down
104 changes: 104 additions & 0 deletions packages/json-expression/docs/collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
Operators that build and transform arrays and objects, plus a JSON Patch `add`
for non-destructive updates. The higher-order array operators (`filter`, `map`,
`reduce`) run a sub-expression per element, binding a named variable you read
with `$`.

Remember to box literal arrays: `[[1, 2, 3]]` is the array `[1, 2, 3]`, while a
bare `[1, 2, 3]` is read as a call to the operator named `1`.

## Array operators

```ts
['concat', ['$', '/a'], ['$', '/b']]; // a ++ b
['head', ['$', '/items'], 3]; // first 3 items
['in', [['a', 'b', 'c']], ['$', '/x']]; // is x one of a/b/c?
```

| Operator | Aliases | Arity | Result |
|----------|---------|-------|--------|
| `concat` | `++` | variadic | Concatenate arrays into one |
| `push` | | variadic | `(array, ...items)` --- array with items appended (non-mutating) |
| `head` | | 2 | `(array, n)` --- first `n` items; negative `n` takes from the end |
| `slice` | | 3 | `(array, start, end)` --- sub-array |
| `sort` | | 1 | Sorted copy |
| `reverse` | | 1 | Reversed copy |
| `in` | | 2 | `(array, value)` --- whether `value` is in `array` (deep equality) |
| `indexOf` | | 2 | `(array, value)` --- index of `value`, or `-1` |
| `zip` | | 2 | Pair up two arrays into `[a, b]` tuples |
| `fromEntries` | | 1 | Build an object from `[key, value]` pairs |
| `filter` | | 3 | Keep elements matching a predicate (see below) |
| `map` | | 3 | Transform each element (see below) |
| `reduce` | | 5 | Fold to a single value (see below) |

### `filter` and `map`

Both take `[op, array, varname, expression]`. `varname` is a constant string
naming the per-element variable; `expression` runs once per element and reads
the element via `['$', varname]`.

```ts
// Keep even numbers.
['filter', [[1, 2, 3, 4, 5]], 'x', ['not', ['%', ['$', 'x'], 2]]]; // => [2, 4]

// Triple each number.
['map', [[1, 2, 3]], 'x', ['*', ['$', 'x'], 3]]; // => [3, 6, 9]
```

The element variable coexists with the input, so `map`/`filter` bodies can still
read the outer data:

```ts
['map', ['$', '/arr'], 'x', ['*', ['$', 'x'], ['$', '/multiple']]];
```

### `reduce`

`reduce` takes `[reduce, array, initial, accName, varName, expression]`. The
expression reads the accumulator via `['$', accName]` and the current element
via `['$', varName]`, and returns the next accumulator.

```ts
['reduce', [[1, 2, 3, 4, 5]], 0, 'acc', 'x', ['+', ['$', 'acc'], ['$', 'x']]]; // => 15
```

## Object operators

```ts
['keys', ['$', '/user']]; // property names
['o.set', ['$', '/user'], 'active', true]; // user with active: true
['o.del', ['$', '/user'], 'password']; // user without password
```

| Operator | Arity | Result |
|----------|-------|--------|
| `keys` | 1 | Array of the object's keys |
| `values` | 1 | Array of the object's values |
| `entries` | 1 | Array of `[key, value]` pairs |
| `o.set` | variadic | `(object, key, value, ...)` --- object with the keys set (non-mutating) |
| `o.del` | variadic | `(object, key, ...)` --- object with the keys removed |

`o.set` takes alternating key/value operands and refuses `__proto__`:

```ts
['o.set', {}, 'foo', 'bar', 'baz', ['+', ['$', ''], 3]];
// with input 2 => {foo: 'bar', baz: 5}
```

## JSON Patch: `jp.add`

`jp.add` applies one or more JSON Patch *add* operations, addressing locations
by JSON Pointer. Operands alternate pointer and value; the original input is not
mutated.

```ts
['jp.add', {}, '/foo', 'bar', '/baz', ['+', ['$', ''], 3]];
// with input 2 => {foo: 'bar', baz: 5}
```

| Operator | Arity | Result |
|----------|-------|--------|
| `jp.add` | variadic | `(document, pointer, value, ...)` --- document with values added at each pointer |

Pointers must be constant strings. Adding at an array index inserts at that
position; adding at the array's length appends, and an out-of-range index
throws.
91 changes: 91 additions & 0 deletions packages/json-expression/docs/evaluate-and-compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
There are two ways to run an expression: interpret it with `evaluate`, or
JIT-compile it to a JavaScript function with `JsonExpressionCodegen`. Both
share the same operator set and produce identical results.

## `evaluate`

`evaluate` walks the expression tree once and returns the result. It is bound to
the full operator set, so you only pass the expression and a context with a
`Vars` instance:

```ts
import {evaluate, Vars} from '@jsonjoy.com/json-expression';

evaluate(['*', 2, ['$', '/x']], {vars: new Vars({x: 21})}); // => 42
```

Reach for `evaluate` when the expression runs once, changes often, or is
supplied at runtime --- there is no compile step to amortize.

## `JsonExpressionCodegen`

The codegen compiles an expression into a plain function `(vars) => result`.
Compiling has up-front cost but the returned function runs about an order of
magnitude faster, so it pays off when one expression runs against many inputs.

Unlike `evaluate`, the codegen needs the operator set passed explicitly:

```ts
import {JsonExpressionCodegen, Vars} from '@jsonjoy.com/json-expression';
import {operatorsMap} from '@jsonjoy.com/json-expression/lib/operators';

const codegen = new JsonExpressionCodegen({
expression: ['*', 2, ['$', '/x']],
operators: operatorsMap,
});
const fn = codegen.run().compile();

fn(new Vars({x: 21})); // => 42
fn(new Vars({x: 50})); // => 100
```

Call `.run()` once to build the code, then `.compile()` to get the function.
`.compile()` wraps execution so thrown non-`Error` values are normalized;
`.compileRaw()` skips that wrapper. `.generate()` returns the generated source
if you want to inspect what was produced.

## Constant folding

The compiler evaluates *pure* sub-expressions whose operands are all literals at
compile time, baking the result into the generated code:

```ts
// ['+', 1, 2] is folded to the constant 3 during compilation.
const fn = new JsonExpressionCodegen({
expression: ['+', ['+', 1, 2], ['$', '/x']],
operators: operatorsMap,
}).run().compile();
```

Operators that read input or otherwise have side effects --- `$`, `get`, `get?` --- are
marked impure and are never folded, so they re-run on every call.

## Matching with `createPattern`

The `matches` string operator needs a pattern compiler, supplied via the
`createPattern` option on *both* `evaluate` and the codegen. It receives the
pattern string and returns a `(value: string) => boolean` tester. This keeps the
choice of regex/glob engine (and its security trade-offs) in your hands:

```ts
const createPattern = (pattern: string) => {
const re = new RegExp(pattern);
return (value: string) => re.test(value);
};

evaluate(['matches', ['$', '/name'], 'A.*'], {
vars: new Vars({name: 'Alice'}),
createPattern,
}); // => true
```

Without `createPattern`, `matches` throws.

## Which to use

| Situation | Use |
|-----------|-----|
| Expression supplied at runtime, runs once | `evaluate` |
| One expression, many inputs (hot path) | `JsonExpressionCodegen` |
| Need to inspect generated JS | `JsonExpressionCodegen` + `.generate()` |
| Using `matches` | either, with `createPattern` |
Loading
Loading