Initial Commit
This commit is contained in:
85
openspec/changes/crdt-completion/proposal.md
Normal file
85
openspec/changes/crdt-completion/proposal.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# CRDT completion — G-Set and LWW-Map
|
||||
|
||||
**Package**: `@suaveplan/crdt`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature
|
||||
|
||||
## Why
|
||||
|
||||
The genesis port of `@suaveplan/crdt` ships seven CRDTs — `GCounter`, `PNCounter`, `LWWRegister`, `MVRegister`, `ORSet`, `TwoPSet`, and `RGA` — but it omits the two upstream CRDTs that round out the catalogue: `GSet` (grow-only set) and `LWWMap` (last-write-wins map).
|
||||
|
||||
`GSet` is the simplest set CRDT and the canonical building block for set-shaped state where elements are only ever added — feature flags reached, IDs seen, addresses indexed. Genesis's `TwoPSet` (two-phase set, supports add + remove with tombstones) and `ORSet` (observed-remove set, supports add + remove with unique tags) are heavier alternatives that pay for capabilities `GSet` consumers never need. Without `GSet`, every grow-only consumer either rolls their own or accepts a `TwoPSet` that allocates a removed-set they'll never populate.
|
||||
|
||||
`LWWMap` is the only map CRDT in the upstream catalogue and has no genesis equivalent — `LWWRegister` covers a single value, but multi-key state (per-tenant settings, per-record metadata, per-row last-modified columns) needs an LWW-Map. Genesis consumers currently have no way to express "merge two map states with last-write-wins per key, preserving tombstones for deletes." `RGA` is sequence-shaped, not key-shaped — it doesn't substitute.
|
||||
|
||||
Both upstream implementations are stable, in production use elsewhere, and verifiable against well-known commutative / associative / idempotent CRDT properties. This change ports both with the genesis-wide additions: injectable `Clock`, `CrdtError`-routed throws, and 100% line + property-based test coverage.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `crdts/g-set/g-set` module — `class GSet<T>` implementing `CRDT<Set<T>, GSetState<T>>` with `add(value)`, `has(value)`, `value()`, `state()`, `merge(remote)`, `toJSON()`, `fromJSON(json)`, `clone()`. Construction signature `new GSet<T>(nodeId: string, options?: GSetOptions<T>)` matches the genesis CRDT base shape. Also exports `GSetState<T>` (state envelope) and `GSetOptions<T>` (`initialElements?: T[]`).
|
||||
- ADDED: `crdts/lww-map/lww-map` module — `class LWWMap<V>` implementing `CRDT<Map<string, V>, LWWMapState<V>>` with `set(key, value, ts?)`, `get(key)`, `has(key)`, `delete(key, ts?)`, `keys()`, `value()`, `state()`, `merge(remote)`, `toJSON()`, `fromJSON(json)`, `clone()`, `reset()`. Tombstones SHALL be preserved on deletes so causal ordering of subsequent re-adds resolves correctly. Tie-break on equal timestamps SHALL compare `nodeId` lexicographically (consistent with `LWWRegister`).
|
||||
- ADDED: `LWWMapEntry<V>` and `LWWMapTombstone` types — `{ value, timestamp }` and `{ timestamp }` respectively.
|
||||
- ADDED: `LWWMapState<V>` type — `{ type: "lww-map"; entries: Record<string, LWWMapEntry<V>>; tombstones: Record<string, LWWMapTombstone> }`.
|
||||
- ADDED: `GSetState<T>` type — `{ type: "g-set"; elements: T[] }`.
|
||||
- MODIFIED: `LWWMap.set` and `LWWMap.delete` accept an optional `Clock` from `@suaveplan/types` via the `LWWMap` constructor (default `Date.now()`). Identical to how `LWWRegister` resolves an absent timestamp argument.
|
||||
- ADDED: `CRDT_CODES.GSET_INVALID_ELEMENT` and `CRDT_CODES.LWWMAP_INVALID_KEY` error codes for non-JSON-serializable input.
|
||||
- ADDED: per-module sibling `.md` for the two new files (`g-set.md`, `lww-map.md`).
|
||||
- ADDED: subpath exports `./crdts/g-set/g-set` and `./crdts/lww-map/lww-map` in `package.json` for tree-shaking.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **PN-Counter** — already in genesis under `crdts/pn-counter`. Not affected.
|
||||
- **Causal CRDTs** (Causal Length Set, Causal Tree, Add-Wins / Remove-Wins observed maps) — separate change once the static CRDT catalogue stabilises. They need a different state-envelope design (causal context vectors) than the timestamp-based LWW family.
|
||||
- **Delta-state CRDT optimization** — shipping `delta()` / `applyDelta()` methods to reduce sync payloads is its own change; this proposal stays full-state.
|
||||
- **Persistence adapters** — wiring `GSet` / `LWWMap` to `@suaveplan/db-sqlite` for durable storage is a separate change in the `@suaveplan/crdt-persistence` package (not yet created).
|
||||
- **Migration tooling** — converting an existing `TwoPSet` to a `GSet` (when a consumer realises remove was never used) is left to consumers; no automated converter ships in this change.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from the upstream package at `../suaveplan/packages/universal/crdt/src` and the upstream enterprise audit at `../suaveplan/enterprise/crdt.md`.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `GSet<T>` class with add / has / merge / value / clone | `packages/universal/crdt/src/sets/g-set/g-set.ts` | L1-L143 | Verbatim port; adapt to genesis `CRDT<TValue, TState>` interface (genesis uses `value()` not `state().elements`); adopt genesis CRDT registration error code style |
|
||||
| `GSetState<T>` and `GSetOptions<T>` types | `packages/universal/crdt/src/sets/g-set/g-set.types.ts` | L1-L23 | Verbatim port; merge `type: "g-set"` discriminator into state envelope to match genesis `CRDT_TYPES` discriminated union pattern |
|
||||
| `LWWMap<V>` class with set / get / has / delete / merge / value / clone / reset | `packages/universal/crdt/src/maps/lww-map/lww-map.ts` | L1-L208 | Verbatim port; replace `Date.now()` fallback with injectable `Clock`; tie-break compares `nodeId` lexicographically (same as upstream) |
|
||||
| `LWWMapEntry<V>` and `LWWMapTombstone` types | `packages/universal/crdt/src/maps/lww-map/lww-map.types.ts` | L1-L20 | Verbatim |
|
||||
| `LWWMapState<V>` type | `packages/universal/crdt/src/maps/lww-map/lww-map.types.ts` | L22-L30 | Verbatim port; merge `type: "lww-map"` discriminator into envelope to match genesis pattern |
|
||||
| Tombstone-respecting merge semantics | `packages/universal/crdt/src/maps/lww-map/lww-map.ts` | L150-L185 | Verbatim — `mergeEntry` skips remote entries older than a local tombstone; `mergeTombstone` removes a local entry when remote tombstone is newer |
|
||||
| Tie-break on equal timestamp via nodeId | `packages/universal/crdt/src/maps/lww-map/lww-map.ts` | L48-L54 | Genesis already implements this in `LWWRegister`; port the same comparison into `LWWMap.set` for cross-CRDT consistency |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Injectable `Clock` for timestamp generation in `LWWMap.set` / `LWWMap.delete` | Genesis-wide CLAUDE.md determinism rule — no module-scope `Date.now()`. Upstream uses `Date.now()` directly; genesis takes a `Clock` parameter on the constructor (matches the pattern already established in `@suaveplan/timing`, `@suaveplan/migration`, `@suaveplan/etl`). |
|
||||
| `CRDT_CODES.GSET_INVALID_ELEMENT` and `CRDT_CODES.LWWMAP_INVALID_KEY` codes | Genesis-wide policy that every thrown error route through `@suaveplan/error` (CLAUDE.md §Workflow rule 2). Upstream uses native throws; genesis adds typed codes for non-JSON-serializable input (e.g. `BigInt` keys in `LWWMap`, circular-reference values in `GSet`). |
|
||||
| `type: "g-set"` and `type: "lww-map"` state-envelope discriminators | Genesis `CRDT.fromJSON` / `CRDT.toJSON` rely on a discriminator field for cross-CRDT serialisation. Upstream's `CRDTEnvelope` carries the type out-of-band; genesis inlines it for simpler serialisation. |
|
||||
| Subpath exports `./crdts/g-set/g-set` and `./crdts/lww-map/lww-map` | Genesis-wide subpath-export rule (CLAUDE.md §6). Upstream ships a single barrel; genesis enforces tree-shakable per-feature subpaths. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/flow/crdt/src/crdts/g-set/g-set.test.ts` — `add`, `has`, `value`, `state`, `clone`, `toJSON` round-trip; CRDT properties (idempotent / commutative / associative `merge`) verified via property-based fixtures
|
||||
- `packages/flow/crdt/src/crdts/g-set/g-set.spec-conformance.test.ts` — CRDT base interface conformance; type-level assertion that `GSet<T>` is `CRDT<Set<T>, GSetState<T>>`; non-JSON-serializable element throws `CrdtError(GSET_INVALID_ELEMENT)`
|
||||
- `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts` — `set`, `get`, `has`, `delete`, `keys`, `value`, `state`, `clone`, `toJSON` round-trip; tombstone-respecting merge; tie-break by nodeId; concurrent set + delete resolution; `reset()` clears state
|
||||
- `packages/flow/crdt/src/crdts/lww-map/lww-map.spec-conformance.test.ts` — CRDT base interface conformance; injectable `Clock` deterministic; tombstone preserved across merge cycles; non-string key throws `CrdtError(LWWMAP_INVALID_KEY)`; CRDT properties (idempotent / commutative / associative `merge`) verified via property-based fixtures
|
||||
- `packages/flow/crdt/src/errors/crdt-error.test.ts` (extended) — `GSET_INVALID_ELEMENT` and `LWWMAP_INVALID_KEY` codes registered
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/crdt` to enumerate. Today `grep -rl "@suaveplan/crdt" packages/ --include="package.json"` returns only `packages/flow/crdt/package.json` itself, so this change is purely additive — no in-repo consumers exist yet.
|
||||
|
||||
- `@suaveplan/crdt` (self) — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
- (no in-repo dependents) — verified by `bunx turbo ls --filter=...@suaveplan/crdt` returning a single package.
|
||||
|
||||
If `tests run` is unchecked for any dependent, the change MUST NOT be folded into the canonical spec — finish that work first or split the proposal.
|
||||
|
||||
## Migration impact
|
||||
|
||||
- **All existing genesis CRDT consumers** — no break. `GSet` and `LWWMap` are net-new exports; the seven existing CRDTs are not touched.
|
||||
- **Future consumers picking between set CRDTs** — pick `GSet` for grow-only state; pick `TwoPSet` when remove is needed and re-adds are forbidden after remove; pick `ORSet` when remove + concurrent re-add must both work (observed-remove semantics).
|
||||
- **`LWWRegister` consumers replicating per-key state** — the migration target after this change ships is `LWWMap`. Replacing `Map<string, LWWRegister<V>>` with a single `LWWMap<V>` is mechanical — call sites change from `register.set(value)` to `map.set(key, value)`. Because no in-repo dependent currently does this, no consumer code needs to be touched in this change.
|
||||
123
openspec/changes/crdt-completion/specs/crdt/spec.md
Normal file
123
openspec/changes/crdt-completion/specs/crdt/spec.md
Normal file
@@ -0,0 +1,123 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: crdt (crdt-completion)
|
||||
|
||||
**Package**: `@suaveplan/crdt`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: G-Set grow-only set CRDT
|
||||
|
||||
The package SHALL expose a `GSet<T>` class implementing `CRDT<Set<T>, GSetState<T>>`. The class SHALL support `add(value: T): void`, `has(value: T): boolean`, `value(): Set<T>`, `state(): GSetState<T>`, `merge(remote: GSetState<T>): void`, `toJSON(): string`, `fromJSON(json: string): void`, and `clone(): GSet<T>`. `merge` SHALL be a set union — idempotent, commutative, and associative. The state envelope SHALL include `type: "g-set"` for cross-CRDT serialisation discrimination.
|
||||
|
||||
#### Scenario: add then has returns true
|
||||
|
||||
- **WHEN** `gset.add("a")` is called and then `gset.has("a")` is called
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: merge is set union
|
||||
|
||||
- **WHEN** `gset1` holds `{"a", "b"}` and `gset2` holds `{"b", "c"}` and `gset1.merge(gset2.state())` is called
|
||||
- **THEN** `gset1.value()` equals `Set { "a", "b", "c" }`
|
||||
|
||||
#### Scenario: merge is idempotent
|
||||
|
||||
- **WHEN** `gset1.merge(gset2.state())` is called twice in succession
|
||||
- **THEN** the second call leaves `gset1.value()` unchanged from after the first call
|
||||
|
||||
#### Scenario: merge is commutative
|
||||
|
||||
- **WHEN** two pairs of merges are performed in opposite orders against fresh clones
|
||||
- **THEN** the resulting `value()` sets are deeply equal
|
||||
|
||||
#### Scenario: merge is associative
|
||||
|
||||
- **WHEN** `(a ∪ b) ∪ c` is computed against fresh clones and `a ∪ (b ∪ c)` is computed against another set of fresh clones
|
||||
- **THEN** the resulting `value()` sets are deeply equal
|
||||
|
||||
#### Scenario: toJSON / fromJSON round-trip
|
||||
|
||||
- **WHEN** a populated `GSet` is serialised via `toJSON()` and a fresh `GSet` calls `fromJSON(json)`
|
||||
- **THEN** the fresh instance's `value()` equals the original's `value()`
|
||||
|
||||
#### Scenario: initialElements seeds the set
|
||||
|
||||
- **WHEN** `new GSet<string>("node-1", { initialElements: ["a", "b", "c"] })` is constructed
|
||||
- **THEN** `gset.value()` equals `Set { "a", "b", "c" }` immediately
|
||||
|
||||
#### Scenario: BigInt element rejected
|
||||
|
||||
- **WHEN** `gset.add(BigInt(1) as never)` is called
|
||||
- **THEN** `CrdtError` is thrown with code `GSET_INVALID_ELEMENT` and `context.value` set to the offending input
|
||||
|
||||
**Verified by:** `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"adds an element and reports has() true"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"merge is set union"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"merge is idempotent"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"merge is commutative"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"merge is associative"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"toJSON / fromJSON round-trip"` AND `packages/flow/crdt/src/crdts/g-set/g-set.test.ts::"initialElements seeds the set"` AND `packages/flow/crdt/src/crdts/g-set/g-set.spec-conformance.test.ts::"throws GSET_INVALID_ELEMENT for BigInt element"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: LWW-Map last-write-wins map CRDT
|
||||
|
||||
The package SHALL expose a `LWWMap<V>` class implementing `CRDT<Map<string, V>, LWWMapState<V>>`. The class SHALL support `set(key: string, value: V, ts?: number): void`, `get(key: string): V | undefined`, `has(key: string): boolean`, `delete(key: string, ts?: number): boolean`, `keys(): string[]`, `value(): Map<string, V>`, `state(): LWWMapState<V>`, `merge(remote: LWWMapState<V>): void`, `toJSON(): string`, `fromJSON(json: string): void`, `clone(): LWWMap<V>`, and `reset(): void`.
|
||||
|
||||
The constructor SHALL accept an optional injectable `Clock` from `@suaveplan/types`; when an explicit `ts` argument is omitted from `set` or `delete`, the clock's `now()` SHALL provide it. Tie-break on equal timestamp SHALL compare `nodeId` lexicographically (matching `LWWRegister`). Deletes SHALL leave a tombstone preserving the timestamp; subsequent `set` with a strictly greater timestamp SHALL clear the tombstone. `merge` SHALL respect tombstones across both directions: remote entries older than a local tombstone are dropped; remote tombstones newer than a local entry remove the entry. `merge` SHALL be idempotent, commutative, and associative.
|
||||
|
||||
#### Scenario: set then get returns the value
|
||||
|
||||
- **WHEN** `map.set("k", "v")` is called and then `map.get("k")` is called
|
||||
- **THEN** the result is `"v"`
|
||||
|
||||
#### Scenario: older set is no-op
|
||||
|
||||
- **WHEN** `map.set("k", "v1", 100)` is called and then `map.set("k", "v0", 50)` is called
|
||||
- **THEN** `map.get("k")` returns `"v1"`
|
||||
|
||||
#### Scenario: tie-break on equal timestamp uses nodeId
|
||||
|
||||
- **WHEN** two replicas with `nodeId = "a"` and `nodeId = "b"` both write `key="k"` at the same timestamp `100`
|
||||
- **THEN** the merged result is the value written by `nodeId = "b"` (lexicographically greater)
|
||||
|
||||
#### Scenario: delete leaves a tombstone
|
||||
|
||||
- **WHEN** `map.delete("k", 100)` is called
|
||||
- **THEN** `map.has("k")` returns `false` AND `map.get("k")` returns `undefined` AND the internal state contains a tombstone with timestamp `100`
|
||||
|
||||
#### Scenario: re-add after delete
|
||||
|
||||
- **WHEN** `map.delete("k", 100)` is called and then `map.set("k", "v", 200)` is called
|
||||
- **THEN** `map.get("k")` returns `"v"` AND the tombstone is cleared
|
||||
|
||||
#### Scenario: merge respects tombstones — older remote entry dropped
|
||||
|
||||
- **WHEN** local has tombstone for `"k"` at ts `200` and remote state has entry `{k: { value: "v", timestamp: 100 }}`
|
||||
- **THEN** after `local.merge(remote.state())`, `local.has("k")` returns `false`
|
||||
|
||||
#### Scenario: merge respects tombstones — newer remote tombstone removes local entry
|
||||
|
||||
- **WHEN** local has entry `{k: "v", ts: 100}` and remote has tombstone for `"k"` at ts `200`
|
||||
- **THEN** after `local.merge(remote.state())`, `local.has("k")` returns `false`
|
||||
|
||||
#### Scenario: injected Clock provides timestamps
|
||||
|
||||
- **WHEN** `new LWWMap("node-1", { clock: { now: () => 555 } })` is constructed and `map.set("k", "v")` is called without a ts argument
|
||||
- **THEN** the resulting entry has `timestamp: 555`
|
||||
|
||||
#### Scenario: merge is idempotent / commutative / associative
|
||||
|
||||
- **WHEN** the property test runs over 100 random key/value/timestamp triples per replica
|
||||
- **THEN** `merge` satisfies all three CRDT properties
|
||||
|
||||
#### Scenario: reset clears state
|
||||
|
||||
- **WHEN** `map.reset()` is called against a populated map
|
||||
- **THEN** `map.value().size === 0` AND no tombstones remain
|
||||
|
||||
#### Scenario: non-string key rejected
|
||||
|
||||
- **WHEN** `map.set(BigInt(1) as never, "v")` is called
|
||||
- **THEN** `CrdtError` is thrown with code `LWWMAP_INVALID_KEY`
|
||||
|
||||
**Verified by:** `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"set then get returns the value"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"set with explicit timestamp respects ordering"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"tie-break on equal timestamp uses nodeId"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"delete leaves a tombstone"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"set after delete with greater ts clears tombstone"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"merge respects tombstones — older remote entry dropped"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"merge respects tombstones — newer remote tombstone removes local entry"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"merge is idempotent / commutative / associative"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts::"reset clears entries and tombstones"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.spec-conformance.test.ts::"set without ts pulls from injected clock"` AND `packages/flow/crdt/src/crdts/lww-map/lww-map.spec-conformance.test.ts::"throws LWWMAP_INVALID_KEY for non-string key"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
131
openspec/changes/crdt-completion/tasks.md
Normal file
131
openspec/changes/crdt-completion/tasks.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Tasks — CRDT completion (G-Set + LWW-Map)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/enterprise/crdt.md` end-to-end
|
||||
- [ ] 1.2 Read `openspec/specs/crdt/spec.md` (does not exist yet — confirm and start fresh)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [ ] 1.6 Draft `specs/crdt/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation — `crdts/g-set/g-set`
|
||||
|
||||
- [ ] 2.1 Create `packages/flow/crdt/src/crdts/g-set/g-set.ts` exporting `class GSet<T>` implementing `CRDT<Set<T>, GSetState<T>>`
|
||||
- [ ] 2.2 Add `GSetState<T>` (`{ type: "g-set"; elements: T[] }`) and `GSetOptions<T>` (`{ initialElements?: T[] }`) to `types/crdt.types.ts`
|
||||
- [ ] 2.3 Implement `add(value)`, `has(value)`, `value()` (returns `Set<T>`), `state()`, `merge(remote)` (set union), `toJSON()`, `fromJSON(json)`, `clone()`
|
||||
- [ ] 2.4 For non-primitive `T`, key by `JSON.stringify(value)` for dedup (matches upstream); reject `BigInt` / circular-reference values with `CrdtError(GSET_INVALID_ELEMENT)`
|
||||
- [ ] 2.5 Add `g-set.md` sibling doc (≥200 words, basic + advanced examples)
|
||||
- [ ] 2.6 Add subpath export `"./crdts/g-set/g-set"` to `packages/flow/crdt/package.json`
|
||||
- [ ] 2.7 Re-export `GSet` from `index.ts` and `GSetState`, `GSetOptions` types
|
||||
|
||||
## 3. Implementation — `crdts/lww-map/lww-map`
|
||||
|
||||
- [ ] 3.1 Create `packages/flow/crdt/src/crdts/lww-map/lww-map.ts` exporting `class LWWMap<V>` implementing `CRDT<Map<string, V>, LWWMapState<V>>`
|
||||
- [ ] 3.2 Add `LWWMapEntry<V>` (`{ value, timestamp }`), `LWWMapTombstone` (`{ timestamp }`), and `LWWMapState<V>` (`{ type: "lww-map"; entries; tombstones }`) types to `types/crdt.types.ts`
|
||||
- [ ] 3.3 Constructor signature: `new LWWMap<V>(nodeId: string, options?: { clock?: Clock })`; default clock uses `Date.now()`
|
||||
- [ ] 3.4 Implement `set(key, value, ts?)`, `get(key)`, `has(key)`, `delete(key, ts?)` (returns `boolean`), `keys()`, `value()` (returns `Map<string, V>`), `state()`, `merge(remote)`, `toJSON()`, `fromJSON(json)`, `clone()`, `reset()`
|
||||
- [ ] 3.5 `set` and `delete` SHALL pull `ts` from the injected clock when the optional argument is omitted
|
||||
- [ ] 3.6 `set` SHALL be a no-op when an existing entry has a strictly greater timestamp; tie-break uses `nodeId` lexicographic comparison (matches `LWWRegister`)
|
||||
- [ ] 3.7 `delete` SHALL leave a tombstone with the supplied (or clock-resolved) timestamp; subsequent `set` with a strictly greater timestamp clears the tombstone
|
||||
- [ ] 3.8 `merge(remote)` SHALL respect tombstones — remote entries older than a local tombstone are dropped; remote tombstones newer than a local entry remove the entry
|
||||
- [ ] 3.9 Reject non-string keys (e.g. `BigInt`-coerced) with `CrdtError(LWWMAP_INVALID_KEY)`
|
||||
- [ ] 3.10 Add `lww-map.md` sibling doc (≥200 words, basic + advanced examples)
|
||||
- [ ] 3.11 Add subpath export `"./crdts/lww-map/lww-map"` to `packages/flow/crdt/package.json`
|
||||
- [ ] 3.12 Re-export `LWWMap` from `index.ts` and `LWWMapEntry`, `LWWMapTombstone`, `LWWMapState` types
|
||||
|
||||
## 4. Error codes
|
||||
|
||||
- [ ] 4.1 Add `GSET_INVALID_ELEMENT` and `LWWMAP_INVALID_KEY` to `errors/crdt-error.ts` `CRDT_CODES` registry
|
||||
- [ ] 4.2 Verify they are registered against the shared `defineCodes` registry on module load
|
||||
- [ ] 4.3 Update `CrdtError` type union to include the new codes
|
||||
|
||||
## 5. Tests — G-Set
|
||||
|
||||
- [ ] 5.1 `g-set.test.ts::"adds an element and reports has() true"` — basic add / has
|
||||
- [ ] 5.2 `g-set.test.ts::"value returns a Set with all current elements"` — value snapshot
|
||||
- [ ] 5.3 `g-set.test.ts::"merge is set union"` — two G-Sets merge to combined elements
|
||||
- [ ] 5.4 `g-set.test.ts::"merge is idempotent"` — `a.merge(b.state()); a.merge(b.state())` no different from one merge
|
||||
- [ ] 5.5 `g-set.test.ts::"merge is commutative"` — `a.merge(b)` then `c.merge(a.state())` equals starting fresh and merging in any order
|
||||
- [ ] 5.6 `g-set.test.ts::"merge is associative"` — `(a ∪ b) ∪ c === a ∪ (b ∪ c)` over same input
|
||||
- [ ] 5.7 `g-set.test.ts::"toJSON / fromJSON round-trip"` — full state round-trip
|
||||
- [ ] 5.8 `g-set.test.ts::"clone produces an independent copy"` — mutation of clone does not affect original
|
||||
- [ ] 5.9 `g-set.test.ts::"initialElements seeds the set"` — constructor option populated correctly
|
||||
- [ ] 5.10 `g-set.spec-conformance.test.ts::"GSet<T> is CRDT<Set<T>, GSetState<T>>"` — `expectTypeOf` assertion
|
||||
- [ ] 5.11 `g-set.spec-conformance.test.ts::"throws GSET_INVALID_ELEMENT for BigInt element"` — error path
|
||||
|
||||
## 6. Tests — LWW-Map
|
||||
|
||||
- [ ] 6.1 `lww-map.test.ts::"set then get returns the value"` — basic round-trip
|
||||
- [ ] 6.2 `lww-map.test.ts::"set with explicit timestamp respects ordering"` — older ts is no-op
|
||||
- [ ] 6.3 `lww-map.test.ts::"tie-break on equal timestamp uses nodeId"` — lexicographic comparison
|
||||
- [ ] 6.4 `lww-map.test.ts::"delete leaves a tombstone"` — get returns undefined, has returns false, internal tombstone present
|
||||
- [ ] 6.5 `lww-map.test.ts::"set after delete with greater ts clears tombstone"` — re-add path
|
||||
- [ ] 6.6 `lww-map.test.ts::"merge respects tombstones — older remote entry dropped"` — convergence property
|
||||
- [ ] 6.7 `lww-map.test.ts::"merge respects tombstones — newer remote tombstone removes local entry"` — convergence property
|
||||
- [ ] 6.8 `lww-map.test.ts::"merge is idempotent / commutative / associative"` — three property tests
|
||||
- [ ] 6.9 `lww-map.test.ts::"toJSON / fromJSON round-trip preserves entries and tombstones"` — full state round-trip
|
||||
- [ ] 6.10 `lww-map.test.ts::"clone produces an independent copy"` — mutation of clone does not affect original
|
||||
- [ ] 6.11 `lww-map.test.ts::"reset clears entries and tombstones"` — empty state restored
|
||||
- [ ] 6.12 `lww-map.test.ts::"keys returns active keys excluding tombstones"` — listing semantics
|
||||
- [ ] 6.13 `lww-map.spec-conformance.test.ts::"LWWMap<V> is CRDT<Map<string, V>, LWWMapState<V>>"` — `expectTypeOf` assertion
|
||||
- [ ] 6.14 `lww-map.spec-conformance.test.ts::"set without ts pulls from injected clock"` — deterministic clock injection
|
||||
- [ ] 6.15 `lww-map.spec-conformance.test.ts::"throws LWWMAP_INVALID_KEY for non-string key"` — error path
|
||||
- [ ] 6.16 Every `it(...)` in §5 and §6 carries `{ timeout: 5000 }`
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [ ] 7.1 Sibling `.md` for `g-set.ts` and `lww-map.ts` (≥200 words each, basic + advanced examples)
|
||||
- [ ] 7.2 Update `packages/flow/crdt/README.md` — add Modules links for the two new files; add Quick-start snippets for both
|
||||
- [ ] 7.3 Update `CLAUDE.md` package listing line for `@suaveplan/crdt` to mention `GSet` and `LWWMap`
|
||||
|
||||
## 8. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 8.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/crdt` — green
|
||||
- [ ] 8.3 `bunx turbo test:coverage --filter=@suaveplan/crdt` — 100/100/100/100
|
||||
- [ ] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/crdt` — exit 0, no `error` findings
|
||||
- [ ] 8.5 `bunx turbo build --filter=@suaveplan/crdt` — green
|
||||
|
||||
## 9. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 9.1 Generics threaded through `GSet<T>` and `LWWMap<V>`
|
||||
- [ ] 9.2 Zod schemas wired at any declared boundary if a CRDT envelope is exposed across a network edge (likely deferred — CRDT-side serialisation is JSON-only here)
|
||||
- [ ] 9.3 Every error code in `errors/crdt-error.ts` (including the two new codes) thrown by at least one production path
|
||||
- [ ] 9.4 Strict-mode behaviors tested both on and off (tie-break with equal ts ON/OFF for nodeId comparison)
|
||||
- [ ] 9.5 OTel instruments emitted from production paths (counters for `gset.add`, `lwwmap.set`, `lwwmap.delete`, `lwwmap.merge.entries`, `lwwmap.merge.tombstones`) and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 9.6 Lifecycle dedup not applicable here — CRDTs are stateful instances, no shared registry
|
||||
- [ ] 9.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [ ] 10.1 Fold spec delta into `openspec/specs/crdt/spec.md` (will be the first time this canonical spec exists)
|
||||
- [ ] 10.2 Move `openspec/changes/crdt-completion/` → `openspec/archive/<yyyy-mm-dd>-crdt-completion/`
|
||||
- [ ] 10.3 Refresh `MANIFEST.md` if package set or deps changed (no expected change here)
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Audit verdict: **DEFERRED — 0/74 tasks evidenced in code**.
|
||||
|
||||
Evidence reviewed:
|
||||
|
||||
- `packages/flow/crdt/src/crdts/` contains `g-counter`, `pn-counter`, `lww-register`, `mv-register`, `or-set`, `two-p-set`, `rga`. No `g-set/` directory. No `lww-map/` directory.
|
||||
- `errors/crdt-error.ts` does not export `GSET_INVALID_ELEMENT` or `LWWMAP_INVALID_KEY`.
|
||||
- Package currently green: `bunx turbo lint typecheck test --filter=@suaveplan/crdt` passes (11 files, 145 tests).
|
||||
|
||||
Missing pieces:
|
||||
|
||||
1. `crdts/g-set/g-set.ts` (G-Set CRDT class + state types + tests + sibling `.md`).
|
||||
2. `crdts/lww-map/lww-map.ts` (LWW-Map CRDT class with tombstones + clock injection + tests + sibling `.md`).
|
||||
3. New error codes (`GSET_INVALID_ELEMENT`, `LWWMAP_INVALID_KEY`) and the OTel counters listed in §9.5.
|
||||
|
||||
Decision: leave change open. Implementing both CRDTs with full test parity (idempotent / commutative / associative property suites + spec-conformance) is real implementation work, not an audit-pass tick.
|
||||
Reference in New Issue
Block a user