Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This workflow depends on mutable major-version action tags for both checkout and setup-node, so a retagged or compromised action release can change CI code without a repository diff. Consider pinning each action to a verified full-length commit SHA.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yml, line 19:

<comment>This workflow depends on mutable major-version action tags for both checkout and setup-node, so a retagged or compromised action release can change CI code without a repository diff. Consider pinning each action to a verified full-length commit SHA.</comment>

<file context>
@@ -0,0 +1,25 @@
+        node-version: [20, 24]
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
</file context>


on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
check:
strategy:
matrix:
node-version: [22, 24, 26]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run check
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
.DS_Store
*.tgz
4 changes: 4 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": []
}
67 changes: 67 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# @b2m9/keyfold — agent guide

`@b2m9/keyfold` immutably folds partial deltas into plain state trees, with
identity-keyed list reconciliation. The public contract and limits are in
`README.md`; the type contract is in `src/types.ts`; the executable laws are in
`test/laws.test.ts`. Read those first. This file records the constraints the
compiler cannot enforce and how changes are made here.

## Toolchain

ESM-only, Node >=22. The project uses npm, TypeScript, Vitest, Oxlint, and
Oxfmt. Run `npm test` while iterating and `npm run check` before handing off;
the full check also builds and validates the published package and types.

## Constraints to preserve

- **Zero runtime dependencies.** The published fold ships only its own code.
- **Immutable and failure-safe.** Never mutate the base or delta. A throw must
not leave a partial write behind.
- **Structural sharing is observable.** Semantic no-ops return `base` by
reference, and untouched sibling branches retain their references.
- **The fold stays deterministic and idempotent.** Reapplying a valid delta
returns the previous result by reference.
- **Replacement is a boundary.** Unkeyed arrays, non-plain objects, and paths
configured with `replace` swap wholesale. Do not traverse, clone, sanitize,
or interpret operators inside them.
- **Keyed lists have stable order.** Match string and non-`NaN` number
identities without coercion, retain surviving base order, and append new
items in delta order. Missing or duplicate identities throw.
- **Deletes remain explicit and scoped.** `undefined` means unmentioned,
`DELETE` removes an optional field, `DELETE_TOKEN` is interpreted only at
object-field positions when `wireDeletes` is enabled, and `$delete` is only a
keyed-item tombstone. Operators outside interpreted positions are data.
- **Policies compile once.** Reject malformed, contradictory, or unreachable
path policy when the merger is created. A keyed-list item edge is entered
only through reconciliation.
- **Unsafe keys are never followed.** Recursive object folds ignore
`__proto__`, `constructor`, and `prototype` from deltas.
- **Work remains delta-proportional.** Do not add a full-state traversal;
touched keyed lists are the deliberate O(list) exception.

## Design principles

This library folds one authoritative delta. It is not a store, schema
validator, normalized cache, persistence layer, CRDT, operational transform,
or RFC 7396 implementation.

- Prefer removing code to adding it. A new option, operator, path feature, or
public export must earn its maintenance cost.
- Compose behavior from `keyBy`, `replace`, and `wireDeletes` before adding a
new primitive.
- Keep runtime behavior and `Delta<T>` honest about their different limits;
do not claim static guarantees for path strings, keyed-array policy, parsed
wire data, or complete inserted items.
- Preserve focused regression tests at semantic boundaries, and extend the law
tests when a change affects a library-wide invariant.

## Comments

Match the comments already in `src/`:

- Explain why: the invariant being held or the failure being guarded, not a
paraphrase of the code.
- Comment only at decision points such as a guard, non-obvious ordering, or
deliberate no-op.
- Use terse, full sentences in the present tense. Imitate the nearest existing
comment when in doubt.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
219 changes: 219 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# @b2m9/keyfold

Fold partial deltas into a nested state tree: **JSON Merge Patch that understands your keyed lists.**

Partial payloads and arrays are an awkward combination. `lodash.merge` matches arrays by index, so this update:

```ts
const base = {
items: [
{ id: "a", quantity: 1 },
{ id: "b", quantity: 3 },
],
};
const delta = { items: [{ id: "b", quantity: 5 }] };
```

can corrupt the list into two `b` items. JSON Merge Patch (RFC 7396) avoids the corruption only by treating every array as opaque and replacing it wholesale, so the standard abandons you the moment your state has keyed lists. `keyfold` matches configured lists by identity instead:

```ts
import { createMerger } from "@b2m9/keyfold";

const merge = createMerger<typeof base>({ keyBy: { items: "id" } });

merge(base, delta).items;
// [{ id: "a", quantity: 1 }, { id: "b", quantity: 5 }]
```

`keyfold` applies partial updates to plain state trees, immutably and with zero dependencies. Objects merge, keyed lists reconcile, unkeyed arrays and scalars replace, and deletes are explicit.

Use it for websocket reducers, optimistic UI reconciliation, form autosave, configurator recalculation, and other places where partial updates from a single authoritative source land in nested, keyed state. Normalizing caches such as Apollo, Relay, and RTK Query already do this internally; `keyfold` is for the state they don't hold.

## Install

```sh
npm install @b2m9/keyfold
```

`keyfold` is a typed, tree-shakeable ESM package with no runtime dependencies.

## Usage

```ts
import { createMerger, DELETE, type Delta } from "@b2m9/keyfold";

interface OrderState {
order: {
customer: { name: string; tier?: string };
coupon?: string;
items: Array<{
id: string;
sku: string;
quantity: number;
tags?: string[];
}>;
};
}

const merge = createMerger<OrderState>({
keyBy: { "order.items": "id" },
});

const next = merge(state, {
order: {
customer: { tier: "platinum" },
coupon: DELETE,
items: [
{ id: "b", quantity: 5, tags: ["rush"] },
{ id: "a", $delete: true },
],
},
});
```

The original `state` is untouched. Unmentioned object fields and list items survive, matching items merge in place, new items append, and tombstones remove whole items. Untouched sibling subtrees retain their references.

## API

### `createMerger<T, Atomic = never>(options?)`

Creates a reusable `(base: T, delta: Delta<T, Atomic>) => T` function.

```ts
const merge = createMerger<State>({
keyBy: {
"order.items": "id",
"order.items[].components": "sku",
},
replace: ["order.shippingAddress"],
wireDeletes: true,
});
```

`keyBy` maps a list path to its identity field. Identity values must be stable, unique strings or numbers. Matching is strict, so `1` and `"1"` are different items, while `0` and `""` are valid identities. Missing, duplicate, `NaN`, and non-string/non-number identities throw.

`replace` makes a path swap wholesale instead of deep-merging or reconciling. A wholesale swap never recurses, so `createMerger` rejects any policy nested below a replaced path. The `"order.items[]"` form is the item-swap idiom: the list still matches items by identity, but each matched item is replaced by its incoming value instead of merged:

```ts
const replaceItems = createMerger<State>({
keyBy: { "order.items": "id" },
replace: ["order.items[]"],
});
```

Replacement values are explicit data, so an empty object or array at a
`replace` path is materialized rather than treated as a recursive no-op.

`wireDeletes` lets JSON deltas spell a field delete as the exported `DELETE_TOKEN` string. It is off by default and detailed under Deletes below.

The optional `Atomic` type parameter keeps custom non-plain values whole in the delta type, matching the runtime rule that class instances replace rather than merge:

```ts
class Money {
readonly #nominal = true;

constructor(readonly cents: number) {}
}

interface State {
total: Money;
}

const mergeMoney = createMerger<State, Money>();
mergeMoney(state, { total: new Money(1999) });
```

Built-ins such as `Date`, `Error`, `Map`, `Set`, `RegExp`, and promises are already atomic. Declare custom classes and other application-specific opaque types explicitly; otherwise TypeScript cannot distinguish their instances from structural object types. `Atomic` only changes the delta type, and TypeScript matching stays structural, so give the class a private field, as above, when actual identity matters.

### Deletes

The three field operations are deliberately distinct:

```ts
merge(state, {
coupon: DELETE, // remove the field
note: null, // keep the field and set it to null
title: undefined, // ignore this field
});
```

Deleting a missing field is a no-op all the way up the tree. For example,
deleting `profile.nickname` from `{}` returns the original `{}` instead of
creating `{ profile: {} }`.

Inside a keyed list, `{ id, $delete: true }` removes the whole item. `$delete` is reserved on keyed items; any other value or a tombstone carrying patch fields throws.

For JSON transports, both sides can use the fixed exported token:

```ts
import { createMerger, DELETE_TOKEN, type Delta } from "@b2m9/keyfold";

const merge = createMerger<State>({ wireDeletes: true });
const json = JSON.stringify({ coupon: DELETE_TOKEN });
const delta = JSON.parse(json) as Delta<State>;

merge(state, delta); // removes coupon
```

The token is interpreted only as an object field value. Nested inside a wholesale `replace` value or an unkeyed array, it remains an ordinary string. Item tombstones are already JSON-native and do not require `wireDeletes`.

There is deliberately no "clear the list" operator: an empty keyed-list delta
performs no item operations, so it preserves the original value and does not
materialize an absent list. To empty an existing keyed list, tombstone every
item, or leave the list unkeyed on a merger where wholesale replacement is
what you mean. A new keyed item is constructed from the fields it supplies, so
an explicit empty nested object or keyed list on that item remains present.

### Errors

`createMerger` throws `KeyfoldConfigError` for malformed or contradictory options. A merger throws `KeyfoldMergeError` when base or delta data violates the contract, such as duplicate identities or malformed tombstones. Both are exported for `instanceof` matching, and a throw never leaves a partially merged tree behind.

## Paths

Paths are dot-separated property names. `[]` means “inside each keyed item of the preceding list”:

```ts
"order.items";
"order.items[].components";
"order.items[].components[]";
```

There are no wildcards, indices, root tokens, or escaping. Properties containing `.`, `[`, or `]` are not addressable in v1. The root cannot be keyed; wrap a top-level array in an object when it needs reconciliation.

All configuration is validated when the merger is created: bad grammar, reserved names, duplicates, `[]` segments under lists that have no key, and policies made unreachable by a broader `replace` all throw. Paths are never checked against `T` or runtime data.

## Semantics

- Plain objects deep-merge using own enumerable fields.
- Keyed lists reconcile by identity; survivors retain base order and inserts append.
- Unkeyed arrays, scalars, and non-plain objects replace wholesale.
- Object and keyed-list deltas use empty working containers when the base has the wrong shape. A no-op preserves the original value and reference; a real field or item produces the correctly shaped result.
- New keyed items consume nested operators at interpreted positions while retaining explicitly supplied empty containers. A `replace` boundary or unkeyed array inside the item is still taken verbatim.
- `__proto__`, `constructor`, and `prototype` keys in deltas are ignored.
- The base is never mutated. A throw cannot leave a partial write behind.
- A merge that changes nothing returns the base reference. Merged values count as unchanged when they are equal by value; replaced values count as unchanged only when they are the very same reference.
- Every valid delta is deterministic and idempotent: re-applying the same delta returns the previous result by reference, so a store can drop duplicate frames with one equality check.

Replaced values, unkeyed arrays, and non-plain objects are taken as-is, never scanned or sanitized; that is what keeps replacement cheap. A `DELETE` or `$delete` inside such a value is not an operator, just data the caller put there.

## Guardrails

`keyfold` folds one authoritative delta into a state tree. It is last-delta-wins: not a CRDT, operational transform, store, normalized cache, schema validator, or persistence layer.

Nor is it an implementation of JSON Merge Patch; the tagline describes lineage, not wire compatibility. RFC 7396 spells deletion as `null`, which `keyfold` deliberately keeps as data, so feeding an actual merge-patch document to a merger sets fields to `null` instead of deleting them. If you need to consume real merge-patch documents, translate them before merging.

The cost of a merge scales with the delta, not with the state: branches the delta never mentions are never visited. Keyed lists are the exception, because a touched list is scanned in full to index its identities. Updating one item in a list of ten thousand costs O(list), not O(1).

Config paths are trusted strings. Bad grammar and contradictory policies throw when the merger is created, and a policy that lands on the wrong runtime shape usually throws during the merge. A typo such as `"order.itmes"`, however, is valid syntax: the real `order.items` stays unkeyed and quietly replaces wholesale.

Validating wire input is the caller's job. `keyfold` does not inspect untrusted data, and validating a delta is not the same as validating full state: deltas are partial and can carry tombstones or the wire token. `Delta<T>` prevents the in-memory `DELETE` symbol from deleting required fields, but that static protection cannot extend to `DELETE_TOKEN`: TypeScript cannot exclude one reserved literal from a general `string` field.

`Delta<T>` cannot know which arrays are keyed. It admits `$delete` on object array items, then runtime policy decides whether that operator is meaningful. Likewise, the type cannot prove that a newly inserted keyed item or a wholesale replacement contains every field your application needs.

Identity fields are the caller's central responsibility: they must remain stable and unique within each list. Two names are reserved in exchange: `$delete` cannot be a real field on keyed entities, and with `wireDeletes: true` the string `"@@keyfold/delete"` can no longer be stored as ordinary field data. Field deletes over JSON also require both ends to speak that protocol; a third-party producer needs a translation layer.

Inputs are assumed to be finite, JSON-shaped trees. A cyclic base or delta is outside the contract and overflows the call stack; `keyfold` spends no cycles detecting it.

## License

MIT © 2026 Bob Massarczyk
Loading