-
Notifications
You must be signed in to change notification settings - Fork 0
feat: initial version 0.1.0 #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+5,262
−0
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
bd57731
feat: version 0.1
b2m9 6913a82
chore: detailed error
b2m9 3c5717e
chore: readme
b2m9 0844660
chore: readme
b2m9 e601e88
chore: update node version
b2m9 80b4b69
chore: add agents
b2m9 ea9be76
fix: opaque error
b2m9 d8ab422
fix: preserve no-ops folds without dropping explicit insert
b2m9 d06a90d
chore: readme
b2m9 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| name: CI | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| node_modules/ | ||
| dist/ | ||
| coverage/ | ||
| .DS_Store | ||
| *.tgz |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "$schema": "./node_modules/oxfmt/configuration_schema.json", | ||
| "ignorePatterns": [] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| @AGENTS.md |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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