@git-stunts/git-warp 11.5.0 → 12.0.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.
Files changed (51) hide show
  1. package/README.md +145 -1
  2. package/bin/cli/commands/registry.js +4 -0
  3. package/bin/cli/commands/reindex.js +41 -0
  4. package/bin/cli/commands/verify-index.js +59 -0
  5. package/bin/cli/infrastructure.js +7 -2
  6. package/bin/cli/schemas.js +19 -0
  7. package/bin/cli/types.js +2 -0
  8. package/index.d.ts +49 -12
  9. package/package.json +2 -2
  10. package/src/domain/WarpGraph.js +62 -2
  11. package/src/domain/errors/ShardIdOverflowError.js +28 -0
  12. package/src/domain/errors/index.js +1 -0
  13. package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
  14. package/src/domain/services/BitmapIndexReader.js +32 -10
  15. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  16. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  17. package/src/domain/services/CheckpointService.js +77 -12
  18. package/src/domain/services/GraphTraversal.js +1239 -0
  19. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  20. package/src/domain/services/JoinReducer.js +310 -46
  21. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  22. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  23. package/src/domain/services/LogicalIndexReader.js +315 -0
  24. package/src/domain/services/LogicalTraversal.js +321 -202
  25. package/src/domain/services/MaterializedViewService.js +379 -0
  26. package/src/domain/services/ObserverView.js +138 -47
  27. package/src/domain/services/PatchBuilderV2.js +3 -3
  28. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  29. package/src/domain/services/PropertyIndexReader.js +111 -0
  30. package/src/domain/services/SyncController.js +576 -0
  31. package/src/domain/services/TemporalQuery.js +128 -14
  32. package/src/domain/types/PatchDiff.js +90 -0
  33. package/src/domain/types/WarpTypesV2.js +4 -4
  34. package/src/domain/utils/MinHeap.js +45 -17
  35. package/src/domain/utils/canonicalCbor.js +36 -0
  36. package/src/domain/utils/fnv1a.js +20 -0
  37. package/src/domain/utils/roaring.js +14 -3
  38. package/src/domain/utils/shardKey.js +40 -0
  39. package/src/domain/utils/toBytes.js +17 -0
  40. package/src/domain/utils/validateShardOid.js +13 -0
  41. package/src/domain/warp/_internal.js +0 -9
  42. package/src/domain/warp/_wiredMethods.d.ts +8 -2
  43. package/src/domain/warp/checkpoint.methods.js +21 -5
  44. package/src/domain/warp/materialize.methods.js +17 -5
  45. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  46. package/src/domain/warp/query.methods.js +78 -12
  47. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  48. package/src/ports/BlobPort.js +1 -1
  49. package/src/ports/NeighborProviderPort.js +59 -0
  50. package/src/ports/SeekCachePort.js +4 -3
  51. package/src/domain/warp/sync.methods.js +0 -554
@@ -0,0 +1,140 @@
1
+ /**
2
+ * NeighborProvider backed by in-memory adjacency maps.
3
+ *
4
+ * Wraps the { outgoing, incoming } Maps produced by _buildAdjacency().
5
+ * Adjacency lists are pre-sorted at construction by (neighborId, label)
6
+ * using strict codepoint comparison. Label filtering via Set.has() in-memory.
7
+ *
8
+ * @module domain/services/AdjacencyNeighborProvider
9
+ */
10
+
11
+ import NeighborProviderPort from '../../ports/NeighborProviderPort.js';
12
+
13
+ /**
14
+ * Comparator for (neighborId, label) sorting.
15
+ * Strict codepoint comparison — never localeCompare.
16
+ *
17
+ * @param {{ neighborId: string, label: string }} a
18
+ * @param {{ neighborId: string, label: string }} b
19
+ * @returns {number}
20
+ */
21
+ function edgeCmp(a, b) {
22
+ if (a.neighborId < b.neighborId) { return -1; }
23
+ if (a.neighborId > b.neighborId) { return 1; }
24
+ if (a.label < b.label) { return -1; }
25
+ if (a.label > b.label) { return 1; }
26
+ return 0;
27
+ }
28
+
29
+ /**
30
+ * Pre-sorts all adjacency lists and freezes the result.
31
+ *
32
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} adjMap
33
+ * @returns {Map<string, Array<{neighborId: string, label: string}>>}
34
+ */
35
+ function sortAdjacencyMap(adjMap) {
36
+ const result = new Map();
37
+ for (const [nodeId, edges] of adjMap) {
38
+ const sorted = edges.slice().sort(edgeCmp);
39
+ result.set(nodeId, sorted);
40
+ }
41
+ return result;
42
+ }
43
+
44
+ /**
45
+ * Filters an edge list by a label set. Returns the original array when
46
+ * no filter is provided.
47
+ *
48
+ * @param {Array<{neighborId: string, label: string}>} edges
49
+ * @param {Set<string>|undefined} labels
50
+ * @returns {Array<{neighborId: string, label: string}>}
51
+ */
52
+ function filterByLabels(edges, labels) {
53
+ if (!labels) {
54
+ return edges;
55
+ }
56
+ return edges.filter((e) => labels.has(e.label));
57
+ }
58
+
59
+ /**
60
+ * Merges two pre-sorted edge lists, deduplicating by (neighborId, label).
61
+ *
62
+ * @param {Array<{neighborId: string, label: string}>} a
63
+ * @param {Array<{neighborId: string, label: string}>} b
64
+ * @returns {Array<{neighborId: string, label: string}>}
65
+ */
66
+ function mergeSorted(a, b) {
67
+ const result = [];
68
+ let i = 0;
69
+ let j = 0;
70
+ while (i < a.length && j < b.length) {
71
+ const cmp = edgeCmp(a[i], b[j]);
72
+ if (cmp < 0) {
73
+ result.push(a[i++]);
74
+ } else if (cmp > 0) {
75
+ result.push(b[j++]);
76
+ } else {
77
+ // Duplicate — take one, skip both
78
+ result.push(a[i++]);
79
+ j++;
80
+ }
81
+ }
82
+ while (i < a.length) { result.push(a[i++]); }
83
+ while (j < b.length) { result.push(b[j++]); }
84
+ return result;
85
+ }
86
+
87
+ export default class AdjacencyNeighborProvider extends NeighborProviderPort {
88
+ /**
89
+ * @param {Object} params
90
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} params.outgoing
91
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} params.incoming
92
+ * @param {Set<string>} params.aliveNodes - Set of alive nodeIds for hasNode()
93
+ */
94
+ constructor({ outgoing, incoming, aliveNodes }) {
95
+ super();
96
+ if (!aliveNodes) {
97
+ throw new Error('AdjacencyNeighborProvider: aliveNodes is required');
98
+ }
99
+ /** @type {Map<string, Array<{neighborId: string, label: string}>>} */
100
+ this._outgoing = sortAdjacencyMap(outgoing);
101
+ /** @type {Map<string, Array<{neighborId: string, label: string}>>} */
102
+ this._incoming = sortAdjacencyMap(incoming);
103
+ /** @type {Set<string>} */
104
+ this._aliveNodes = aliveNodes;
105
+ }
106
+
107
+ /**
108
+ * @param {string} nodeId
109
+ * @param {import('../../ports/NeighborProviderPort.js').Direction} direction
110
+ * @param {import('../../ports/NeighborProviderPort.js').NeighborOptions} [options]
111
+ * @returns {Promise<import('../../ports/NeighborProviderPort.js').NeighborEdge[]>}
112
+ */
113
+ getNeighbors(nodeId, direction, options) {
114
+ const labels = options?.labels;
115
+ const outEdges = filterByLabels(this._outgoing.get(nodeId) || [], labels);
116
+ const inEdges = filterByLabels(this._incoming.get(nodeId) || [], labels);
117
+
118
+ if (direction === 'out') {
119
+ return Promise.resolve(outEdges);
120
+ }
121
+ if (direction === 'in') {
122
+ return Promise.resolve(inEdges);
123
+ }
124
+ // 'both': merge two pre-sorted lists, dedup by (neighborId, label)
125
+ return Promise.resolve(mergeSorted(outEdges, inEdges));
126
+ }
127
+
128
+ /**
129
+ * @param {string} nodeId
130
+ * @returns {Promise<boolean>}
131
+ */
132
+ hasNode(nodeId) {
133
+ return Promise.resolve(this._aliveNodes.has(nodeId));
134
+ }
135
+
136
+ /** @returns {'sync'} */
137
+ get latencyClass() {
138
+ return 'sync';
139
+ }
140
+ }
@@ -4,6 +4,7 @@ import nullLogger from '../utils/nullLogger.js';
4
4
  import LRUCache from '../utils/LRUCache.js';
5
5
  import { getRoaringBitmap32 } from '../utils/roaring.js';
6
6
  import { canonicalStringify } from '../utils/canonicalStringify.js';
7
+ import { isValidShardOid } from '../utils/validateShardOid.js';
7
8
 
8
9
  /** @typedef {import('../../ports/IndexStoragePort.js').default} IndexStoragePort */
9
10
  /** @typedef {import('../types/WarpPersistence.js').IndexStorage} IndexStorage */
@@ -50,24 +51,24 @@ const computeChecksum = async (data, version, crypto) => {
50
51
  * - {@link ShardCorruptionError} for invalid shard format
51
52
  * - {@link ShardValidationError} for version or checksum mismatches
52
53
  *
53
- * In non-strict mode (default), validation failures are logged as warnings
54
+ * In non-strict mode (strict: false), validation failures are logged as warnings
54
55
  * and an empty shard is returned for graceful degradation.
55
56
  *
56
57
  * **Note**: Storage errors (e.g., `storage.readBlob` failures) always throw
57
58
  * {@link ShardLoadError} regardless of strict mode.
58
59
  *
59
60
  * @example
60
- * // Non-strict mode (default) - graceful degradation on validation errors
61
+ * // Strict mode (default) - throws on any validation failure
61
62
  * const reader = new BitmapIndexReader({ storage });
62
63
  * reader.setup(shardOids);
63
64
  * const parents = await reader.getParents('abc123...');
64
65
  *
65
66
  * @example
66
- * // Strict mode - throws on any validation failure
67
- * const strictReader = new BitmapIndexReader({ storage, strict: true });
68
- * strictReader.setup(shardOids);
67
+ * // Non-strict mode - graceful degradation on validation errors
68
+ * const lenientReader = new BitmapIndexReader({ storage, strict: false });
69
+ * lenientReader.setup(shardOids);
69
70
  * try {
70
- * const parents = await strictReader.getParents('abc123...');
71
+ * const parents = await lenientReader.getParents('abc123...');
71
72
  * } catch (err) {
72
73
  * if (err instanceof ShardValidationError) {
73
74
  * console.error('Shard validation failed:', err.field, err.expected, err.actual);
@@ -83,14 +84,14 @@ export default class BitmapIndexReader {
83
84
  * Creates a BitmapIndexReader instance.
84
85
  * @param {Object} options
85
86
  * @param {IndexStoragePort} options.storage - Storage adapter for reading index data
86
- * @param {boolean} [options.strict=false] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
+ * @param {boolean} [options.strict=true] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
88
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging.
88
89
  * Defaults to NoOpLogger (no logging).
89
90
  * @param {number} [options.maxCachedShards=100] - Maximum number of shards to keep in the LRU cache.
90
91
  * When exceeded, least recently used shards are evicted to free memory.
91
92
  * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance for checksum verification.
92
93
  */
93
- constructor({ storage, strict = false, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
+ constructor({ storage, strict = true, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
95
  if (!storage) {
95
96
  throw new Error('BitmapIndexReader requires a storage adapter');
96
97
  }
@@ -132,8 +133,29 @@ export default class BitmapIndexReader {
132
133
  * const parents = await reader.getParents('abcd1234...'); // loads meta_ab, shards_rev_ab
133
134
  */
134
135
  setup(shardOids) {
135
- this.shardOids = new Map(Object.entries(shardOids));
136
- this._idToShaCache = null; // Clear cache when shards change
136
+ const entries = Object.entries(shardOids);
137
+ /** @type {[string, string][]} */
138
+ const validEntries = [];
139
+ for (const [path, oid] of entries) {
140
+ if (isValidShardOid(oid)) {
141
+ validEntries.push([path, oid]);
142
+ } else if (this.strict) {
143
+ throw new ShardCorruptionError('Invalid shard OID', {
144
+ shardPath: path,
145
+ oid,
146
+ reason: 'invalid_oid',
147
+ });
148
+ } else {
149
+ this.logger.warn('Skipping shard with invalid OID', {
150
+ operation: 'setup',
151
+ shardPath: path,
152
+ oid,
153
+ reason: 'invalid_oid',
154
+ });
155
+ }
156
+ }
157
+ this.shardOids = new Map(validEntries);
158
+ this._idToShaCache = null;
137
159
  this.loadedShards.clear();
138
160
  }
139
161
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * NeighborProvider backed by bitmap indexes.
3
+ *
4
+ * Two modes:
5
+ * 1. **Commit DAG** (`indexReader`): Wraps BitmapIndexReader for parent/child
6
+ * relationships. Edges use label = '' (empty string sentinel).
7
+ * 2. **Logical graph** (`logicalIndex`): Wraps CBOR-based logical bitmap index
8
+ * with labeled edges, per-label bitmap filtering, and alive bitmap checks.
9
+ *
10
+ * @module domain/services/BitmapNeighborProvider
11
+ */
12
+
13
+ import NeighborProviderPort from '../../ports/NeighborProviderPort.js';
14
+
15
+ /** @typedef {import('./BitmapIndexReader.js').default} BitmapIndexReader */
16
+
17
+ /**
18
+ * @typedef {Object} LogicalIndex
19
+ * @property {(nodeId: string) => number|undefined} getGlobalId
20
+ * @property {(nodeId: string) => boolean} isAlive
21
+ * @property {(globalId: number) => string|undefined} getNodeId
22
+ * @property {(nodeId: string, direction: string, labelIds?: number[]) => Array<{neighborId: string, label: string}>} getEdges
23
+ * @property {() => Map<string, number>} getLabelRegistry
24
+ */
25
+
26
+ /**
27
+ * Sorts edges by (neighborId, label) using strict codepoint comparison.
28
+ *
29
+ * @param {Array<{neighborId: string, label: string}>} edges
30
+ * @returns {Array<{neighborId: string, label: string}>}
31
+ */
32
+ function sortEdges(edges) {
33
+ return edges.sort((a, b) => {
34
+ if (a.neighborId < b.neighborId) { return -1; }
35
+ if (a.neighborId > b.neighborId) { return 1; }
36
+ if (a.label < b.label) { return -1; }
37
+ if (a.label > b.label) { return 1; }
38
+ return 0;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Deduplicates a sorted edge list by (neighborId, label).
44
+ *
45
+ * @param {Array<{neighborId: string, label: string}>} edges
46
+ * @returns {Array<{neighborId: string, label: string}>}
47
+ */
48
+ function dedupSorted(edges) {
49
+ if (edges.length <= 1) { return edges; }
50
+ const result = [edges[0]];
51
+ for (let i = 1; i < edges.length; i++) {
52
+ const prev = result[result.length - 1];
53
+ if (edges[i].neighborId !== prev.neighborId || edges[i].label !== prev.label) {
54
+ result.push(edges[i]);
55
+ }
56
+ }
57
+ return result;
58
+ }
59
+
60
+ export default class BitmapNeighborProvider extends NeighborProviderPort {
61
+ /**
62
+ * @param {Object} params
63
+ * @param {BitmapIndexReader} [params.indexReader] - For commit DAG mode
64
+ * @param {LogicalIndex} [params.logicalIndex] - For logical graph mode
65
+ */
66
+ constructor({ indexReader, logicalIndex }) {
67
+ super();
68
+ if (!indexReader && !logicalIndex) {
69
+ throw new Error('BitmapNeighborProvider requires either indexReader or logicalIndex');
70
+ }
71
+ this._reader = indexReader ?? null;
72
+ this._logical = logicalIndex ?? null;
73
+ }
74
+
75
+ /**
76
+ * @param {string} nodeId
77
+ * @param {import('../../ports/NeighborProviderPort.js').Direction} direction
78
+ * @param {import('../../ports/NeighborProviderPort.js').NeighborOptions} [options]
79
+ * @returns {Promise<import('../../ports/NeighborProviderPort.js').NeighborEdge[]>}
80
+ */
81
+ async getNeighbors(nodeId, direction, options) {
82
+ if (this._logical) {
83
+ return this._getLogicalNeighbors(nodeId, direction, options);
84
+ }
85
+ return await this._getDagNeighbors(nodeId, direction, options);
86
+ }
87
+
88
+ /**
89
+ * @param {string} nodeId
90
+ * @returns {Promise<boolean>}
91
+ */
92
+ async hasNode(nodeId) {
93
+ if (this._logical) {
94
+ return this._logical.isAlive(nodeId);
95
+ }
96
+ if (this._reader) {
97
+ const id = await this._reader.lookupId(nodeId);
98
+ return id !== undefined;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /** @returns {'async-local'} */
104
+ get latencyClass() {
105
+ return 'async-local';
106
+ }
107
+
108
+ // ── Commit DAG mode ─────────────────────────────────────────────────
109
+
110
+ /**
111
+ * @param {string} nodeId
112
+ * @param {import('../../ports/NeighborProviderPort.js').Direction} direction
113
+ * @param {import('../../ports/NeighborProviderPort.js').NeighborOptions} [options]
114
+ * @returns {Promise<import('../../ports/NeighborProviderPort.js').NeighborEdge[]>}
115
+ * @private
116
+ */
117
+ async _getDagNeighbors(nodeId, direction, options) {
118
+ if (!this._reader) { return []; }
119
+
120
+ if (options?.labels) {
121
+ if (!options.labels.has('')) { return []; }
122
+ }
123
+
124
+ if (direction === 'out') {
125
+ const children = await this._reader.getChildren(nodeId);
126
+ return sortEdges(children.map((id) => ({ neighborId: id, label: '' })));
127
+ }
128
+
129
+ if (direction === 'in') {
130
+ const parents = await this._reader.getParents(nodeId);
131
+ return sortEdges(parents.map((id) => ({ neighborId: id, label: '' })));
132
+ }
133
+
134
+ const [children, parents] = await Promise.all([
135
+ this._reader.getChildren(nodeId),
136
+ this._reader.getParents(nodeId),
137
+ ]);
138
+ const all = children.map((id) => ({ neighborId: id, label: '' }))
139
+ .concat(parents.map((id) => ({ neighborId: id, label: '' })));
140
+ return dedupSorted(sortEdges(all));
141
+ }
142
+
143
+ // ── Logical graph mode ──────────────────────────────────────────────
144
+
145
+ /**
146
+ * @param {string} nodeId
147
+ * @param {import('../../ports/NeighborProviderPort.js').Direction} direction
148
+ * @param {import('../../ports/NeighborProviderPort.js').NeighborOptions} [options]
149
+ * @returns {import('../../ports/NeighborProviderPort.js').NeighborEdge[]}
150
+ * @private
151
+ */
152
+ _getLogicalNeighbors(nodeId, direction, options) {
153
+ const logical = /** @type {LogicalIndex} */ (this._logical);
154
+
155
+ // Resolve label filter to labelIds
156
+ /** @type {number[]|undefined} */
157
+ let labelIds;
158
+ if (options?.labels) {
159
+ const registry = logical.getLabelRegistry();
160
+ labelIds = [];
161
+ for (const label of options.labels) {
162
+ const id = registry.get(label);
163
+ if (id !== undefined) {
164
+ labelIds.push(id);
165
+ }
166
+ }
167
+ if (labelIds.length === 0) { return []; }
168
+ }
169
+
170
+ if (direction === 'both') {
171
+ const outEdges = logical.getEdges(nodeId, 'out', labelIds);
172
+ const inEdges = logical.getEdges(nodeId, 'in', labelIds);
173
+ return dedupSorted(sortEdges([...outEdges, ...inEdges]));
174
+ }
175
+
176
+ return sortEdges(logical.getEdges(nodeId, direction, labelIds));
177
+ }
178
+ }
@@ -60,8 +60,8 @@ export function encodeCheckpointMessage({ graph, stateHash, frontierOid, indexOi
60
60
  [TRAILER_KEYS.schema]: String(schema),
61
61
  };
62
62
 
63
- // Add checkpoint version marker for V5 format (schema:2 and schema:3)
64
- if (schema === 2 || schema === 3) {
63
+ // Add checkpoint version marker for V5 format (schema:2, schema:3, schema:4)
64
+ if (schema === 2 || schema === 3 || schema === 4) {
65
65
  trailers[TRAILER_KEYS.checkpointVersion] = 'v5';
66
66
  }
67
67
 
@@ -126,7 +126,7 @@ export function decodeCheckpointMessage(message) {
126
126
  throw new Error(`Invalid checkpoint message: eg-schema must be a positive integer, got '${schemaStr}'`);
127
127
  }
128
128
 
129
- // Extract optional checkpoint version (v5 for schema:2)
129
+ // Extract optional checkpoint version (v5 for schema:2/3/4)
130
130
  const checkpointVersion = trailers[TRAILER_KEYS.checkpointVersion] || null;
131
131
 
132
132
  return {
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Checkpoint Service for WARP multi-writer graph database.
3
3
  *
4
- * Provides functionality for creating and loading schema:2 and schema:3
4
+ * Provides functionality for creating and loading schema:2, schema:3, and schema:4
5
5
  * checkpoints, as well as incremental state materialization from checkpoints.
6
6
  *
7
- * This service supports schema:2 and schema:3 (V5) checkpoints. Schema:1 (V4)
7
+ * This service supports schema:2, schema:3, and schema:4 (V5) checkpoints. Schema:1 (V4)
8
8
  * checkpoints must be migrated before use.
9
9
  *
10
10
  * @module CheckpointService
@@ -28,6 +28,53 @@ import { cloneStateV5, reduceV5 } from './JoinReducer.js';
28
28
  import { encodeEdgeKey, encodePropKey, CONTENT_PROPERTY_KEY, decodePropKey, isEdgePropKey, decodeEdgePropKey } from './KeyCodec.js';
29
29
  import { ProvenanceIndex } from './ProvenanceIndex.js';
30
30
 
31
+ // ============================================================================
32
+ // Internal Helpers
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Writes index tree shards as blobs and creates a subtree.
37
+ *
38
+ * @param {Record<string, Uint8Array>} indexTree - path → buffer mapping
39
+ * @param {{ writeBlob(buf: Uint8Array): Promise<string>, writeTree(entries: string[]): Promise<string> }} persistence
40
+ * @returns {Promise<string>} subtree OID
41
+ */
42
+ async function writeIndexSubtree(indexTree, persistence) {
43
+ const paths = Object.keys(indexTree).sort();
44
+ const oids = await Promise.all(
45
+ paths.map((p) => persistence.writeBlob(indexTree[p]))
46
+ );
47
+
48
+ const entries = paths.map(
49
+ (path, i) => `100644 blob ${oids[i]}\t${path}`
50
+ );
51
+ return await persistence.writeTree(entries);
52
+ }
53
+
54
+ /**
55
+ * Partitions readTreeOids output into core entries and index shard OIDs.
56
+ *
57
+ * Entries prefixed with `index/` are stripped and collected separately.
58
+ *
59
+ * @param {Record<string, string>} rawOids
60
+ * @returns {{ treeOids: Record<string, string>, indexShardOids: Record<string, string> }}
61
+ */
62
+ function partitionTreeOids(rawOids) {
63
+ /** @type {Record<string, string>} */
64
+ const treeOids = {};
65
+ /** @type {Record<string, string>} */
66
+ const indexShardOids = {};
67
+
68
+ for (const [path, oid] of Object.entries(rawOids)) {
69
+ if (path.startsWith('index/')) {
70
+ indexShardOids[path.slice(6)] = oid;
71
+ } else {
72
+ treeOids[path] = oid;
73
+ }
74
+ }
75
+ return { treeOids, indexShardOids };
76
+ }
77
+
31
78
  // ============================================================================
32
79
  // Checkpoint Creation (WARP spec Section 10)
33
80
  // ============================================================================
@@ -55,10 +102,11 @@ import { ProvenanceIndex } from './ProvenanceIndex.js';
55
102
  * @param {import('./ProvenanceIndex.js').ProvenanceIndex} [options.provenanceIndex] - Optional provenance index to persist
56
103
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization
57
104
  * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort for state hash computation
105
+ * @param {Record<string, Uint8Array>} [options.indexTree] - Optional materialized view index tree (triggers schema 4)
58
106
  * @returns {Promise<string>} The checkpoint commit SHA
59
107
  */
60
- export async function create({ persistence, graphName, state, frontier, parents = [], compact = true, provenanceIndex, codec, crypto }) {
61
- return await createV5({ persistence, graphName, state, frontier, parents, compact, provenanceIndex, codec, crypto });
108
+ export async function create({ persistence, graphName, state, frontier, parents = [], compact = true, provenanceIndex, codec, crypto, indexTree }) {
109
+ return await createV5({ persistence, graphName, state, frontier, parents, compact, provenanceIndex, codec, crypto, indexTree });
62
110
  }
63
111
 
64
112
  /**
@@ -84,6 +132,7 @@ export async function create({ persistence, graphName, state, frontier, parents
84
132
  * @param {import('./ProvenanceIndex.js').ProvenanceIndex} [options.provenanceIndex] - Optional provenance index to persist
85
133
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization
86
134
  * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort for state hash computation
135
+ * @param {Record<string, Uint8Array>} [options.indexTree] - Optional materialized view index tree (triggers schema 4)
87
136
  * @returns {Promise<string>} The checkpoint commit SHA
88
137
  */
89
138
  export async function createV5({
@@ -96,6 +145,7 @@ export async function createV5({
96
145
  provenanceIndex,
97
146
  codec,
98
147
  crypto,
148
+ indexTree,
99
149
  }) {
100
150
  // 1. Compute appliedVV from actual state dots
101
151
  const appliedVV = computeAppliedVV(state);
@@ -132,7 +182,13 @@ export async function createV5({
132
182
  provenanceIndexBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (provenanceIndexBuffer));
133
183
  }
134
184
 
135
- // 6c. Collect content blob OIDs from state properties for GC anchoring.
185
+ // 6c. Optionally write index subtree (schema 4)
186
+ let indexSubtreeOid = null;
187
+ if (indexTree) {
188
+ indexSubtreeOid = await writeIndexSubtree(indexTree, persistence);
189
+ }
190
+
191
+ // 6d. Collect content blob OIDs from state properties for GC anchoring.
136
192
  // If patch commits are ever pruned, content blobs remain reachable via
137
193
  // the checkpoint tree. Without this, git gc would nuke content blobs
138
194
  // whose only anchor was the (now-pruned) patch commit tree.
@@ -159,6 +215,11 @@ export async function createV5({
159
215
  treeEntries.push(`100644 blob ${provenanceIndexBlobOid}\tprovenanceIndex.cbor`);
160
216
  }
161
217
 
218
+ // Add index subtree if present (schema 4)
219
+ if (indexSubtreeOid) {
220
+ treeEntries.push(`040000 tree ${indexSubtreeOid}\tindex`);
221
+ }
222
+
162
223
  // Add content blob anchors
163
224
  for (const oid of contentOids) {
164
225
  treeEntries.push(`100644 blob ${oid}\t_content_${oid}`);
@@ -168,7 +229,7 @@ export async function createV5({
168
229
  treeEntries.sort((a, b) => {
169
230
  const filenameA = a.split('\t')[1];
170
231
  const filenameB = b.split('\t')[1];
171
- return filenameA.localeCompare(filenameB);
232
+ return filenameA < filenameB ? -1 : filenameA > filenameB ? 1 : 0;
172
233
  });
173
234
 
174
235
  const treeOid = await persistence.writeTree(treeEntries);
@@ -179,7 +240,7 @@ export async function createV5({
179
240
  stateHash,
180
241
  frontierOid: frontierBlobOid,
181
242
  indexOid: treeOid,
182
- schema: 2,
243
+ schema: indexTree ? 4 : 2,
183
244
  });
184
245
 
185
246
  // 9. Create the checkpoint commit
@@ -212,7 +273,7 @@ export async function createV5({
212
273
  * @param {string} checkpointSha - The checkpoint commit SHA to load
213
274
  * @param {Object} [options] - Load options
214
275
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR deserialization
215
- * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: import('./Frontier.js').Frontier, stateHash: string, schema: number, appliedVV: Map<string, number>|null, provenanceIndex?: import('./ProvenanceIndex.js').ProvenanceIndex}>} The loaded checkpoint data
276
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: import('./Frontier.js').Frontier, stateHash: string, schema: number, appliedVV: Map<string, number>|null, provenanceIndex?: import('./ProvenanceIndex.js').ProvenanceIndex, indexShardOids: Record<string, string>|null}>} The loaded checkpoint data
216
277
  * @throws {Error} If checkpoint is schema:1 (migration required)
217
278
  */
218
279
  export async function loadCheckpoint(persistence, checkpointSha, { codec } = {}) {
@@ -220,16 +281,19 @@ export async function loadCheckpoint(persistence, checkpointSha, { codec } = {})
220
281
  const message = await persistence.showNode(checkpointSha);
221
282
  const decoded = /** @type {{ schema: number, stateHash: string, indexOid: string }} */ (decodeCheckpointMessage(message));
222
283
 
223
- // 2. Reject schema:1 checkpoints - migration required
224
- if (decoded.schema !== 2 && decoded.schema !== 3) {
284
+ // 2. Reject unsupported schemas - migration required for schema:1
285
+ if (decoded.schema !== 2 && decoded.schema !== 3 && decoded.schema !== 4) {
225
286
  throw new Error(
226
287
  `Checkpoint ${checkpointSha} is schema:${decoded.schema}. ` +
227
- `Only schema:2 and schema:3 checkpoints are supported. Please migrate using MigrationService.`
288
+ `Only schema:2, schema:3, and schema:4 checkpoints are supported. Please migrate using MigrationService.`
228
289
  );
229
290
  }
230
291
 
231
292
  // 3. Read tree entries via the indexOid from the message (points to the tree)
232
- const treeOids = await persistence.readTreeOids(decoded.indexOid);
293
+ const rawTreeOids = await persistence.readTreeOids(decoded.indexOid);
294
+
295
+ // 3b. Partition: entries with 'index/' prefix are bitmap index shards
296
+ const { treeOids, indexShardOids } = partitionTreeOids(rawTreeOids);
233
297
 
234
298
  // 4. Read frontier.cbor blob
235
299
  const frontierOid = treeOids['frontier.cbor'];
@@ -272,6 +336,7 @@ export async function loadCheckpoint(persistence, checkpointSha, { codec } = {})
272
336
  schema: decoded.schema,
273
337
  appliedVV,
274
338
  provenanceIndex: provenanceIndex || undefined,
339
+ indexShardOids: Object.keys(indexShardOids).length > 0 ? indexShardOids : null,
275
340
  };
276
341
  }
277
342