@git-stunts/git-warp 11.5.1 → 12.1.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.
- package/README.md +137 -10
- package/bin/cli/commands/registry.js +4 -0
- package/bin/cli/commands/reindex.js +41 -0
- package/bin/cli/commands/verify-index.js +59 -0
- package/bin/cli/infrastructure.js +7 -2
- package/bin/cli/schemas.js +19 -0
- package/bin/cli/types.js +2 -0
- package/index.d.ts +52 -15
- package/package.json +3 -2
- package/src/domain/WarpGraph.js +40 -0
- package/src/domain/errors/ShardIdOverflowError.js +28 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
- package/src/domain/services/BitmapNeighborProvider.js +178 -0
- package/src/domain/services/CheckpointMessageCodec.js +3 -3
- package/src/domain/services/CheckpointService.js +77 -12
- package/src/domain/services/GraphTraversal.js +1239 -0
- package/src/domain/services/IncrementalIndexUpdater.js +765 -0
- package/src/domain/services/JoinReducer.js +233 -5
- package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
- package/src/domain/services/LogicalIndexBuildService.js +108 -0
- package/src/domain/services/LogicalIndexReader.js +315 -0
- package/src/domain/services/LogicalTraversal.js +321 -202
- package/src/domain/services/MaterializedViewService.js +379 -0
- package/src/domain/services/ObserverView.js +132 -69
- package/src/domain/services/PatchBuilderV2.js +3 -3
- package/src/domain/services/PropertyIndexBuilder.js +64 -0
- package/src/domain/services/PropertyIndexReader.js +111 -0
- package/src/domain/services/QueryBuilder.js +15 -44
- package/src/domain/services/TemporalQuery.js +128 -14
- package/src/domain/services/TranslationCost.js +8 -24
- package/src/domain/types/PatchDiff.js +90 -0
- package/src/domain/types/WarpTypesV2.js +4 -4
- package/src/domain/utils/MinHeap.js +45 -17
- package/src/domain/utils/canonicalCbor.js +36 -0
- package/src/domain/utils/fnv1a.js +20 -0
- package/src/domain/utils/matchGlob.js +51 -0
- package/src/domain/utils/roaring.js +14 -3
- package/src/domain/utils/shardKey.js +40 -0
- package/src/domain/utils/toBytes.js +17 -0
- package/src/domain/warp/_wiredMethods.d.ts +7 -1
- package/src/domain/warp/checkpoint.methods.js +21 -5
- package/src/domain/warp/materialize.methods.js +17 -5
- package/src/domain/warp/materializeAdvanced.methods.js +142 -3
- package/src/domain/warp/query.methods.js +83 -15
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
- package/src/ports/BlobPort.js +1 -1
- package/src/ports/NeighborProviderPort.js +59 -0
- package/src/ports/SeekCachePort.js +4 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds property index shards from node properties.
|
|
3
|
+
*
|
|
4
|
+
* Produces `props_XX.cbor` shards keyed by shard key, where each
|
|
5
|
+
* shard maps nodeId → { key: value, ... }.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/services/PropertyIndexBuilder
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import defaultCodec from '../utils/defaultCodec.js';
|
|
11
|
+
import computeShardKey from '../utils/shardKey.js';
|
|
12
|
+
|
|
13
|
+
export default class PropertyIndexBuilder {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {import('../../ports/CodecPort.js').default} [options.codec]
|
|
17
|
+
*/
|
|
18
|
+
constructor({ codec } = {}) {
|
|
19
|
+
this._codec = codec || defaultCodec;
|
|
20
|
+
/** @type {Map<string, Map<string, Record<string, unknown>>>} shardKey → (nodeId → props) */
|
|
21
|
+
this._shards = new Map();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Adds a property for a node.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} nodeId
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
*/
|
|
31
|
+
addProperty(nodeId, key, value) {
|
|
32
|
+
const shardKey = computeShardKey(nodeId);
|
|
33
|
+
let shard = this._shards.get(shardKey);
|
|
34
|
+
if (!shard) {
|
|
35
|
+
shard = new Map();
|
|
36
|
+
this._shards.set(shardKey, shard);
|
|
37
|
+
}
|
|
38
|
+
let nodeProps = shard.get(nodeId);
|
|
39
|
+
if (!nodeProps) {
|
|
40
|
+
nodeProps = /** @type {Record<string, unknown>} */ (Object.create(null));
|
|
41
|
+
shard.set(nodeId, nodeProps);
|
|
42
|
+
}
|
|
43
|
+
/** @type {Record<string, unknown>} */ (nodeProps)[key] = value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serializes all property shards.
|
|
48
|
+
*
|
|
49
|
+
* @returns {Record<string, Uint8Array>}
|
|
50
|
+
*/
|
|
51
|
+
serialize() {
|
|
52
|
+
/** @type {Record<string, Uint8Array>} */
|
|
53
|
+
const tree = {};
|
|
54
|
+
for (const [shardKey, shard] of this._shards) {
|
|
55
|
+
// Encode as array of [nodeId, props] pairs to avoid __proto__ key issues
|
|
56
|
+
// when CBOR decodes into plain objects. Sorted by nodeId for determinism.
|
|
57
|
+
const entries = [...shard.entries()]
|
|
58
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
59
|
+
.map(([nodeId, props]) => [nodeId, props]);
|
|
60
|
+
tree[`props_${shardKey}.cbor`] = this._codec.encode(entries).slice();
|
|
61
|
+
}
|
|
62
|
+
return tree;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads property index shards lazily with LRU caching.
|
|
3
|
+
*
|
|
4
|
+
* Loads `props_XX.cbor` shards on demand via IndexStoragePort.readBlob.
|
|
5
|
+
*
|
|
6
|
+
* @module domain/services/PropertyIndexReader
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import defaultCodec from '../utils/defaultCodec.js';
|
|
10
|
+
import computeShardKey from '../utils/shardKey.js';
|
|
11
|
+
import LRUCache from '../utils/LRUCache.js';
|
|
12
|
+
|
|
13
|
+
export default class PropertyIndexReader {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {import('../../ports/IndexStoragePort.js').default} [options.storage]
|
|
17
|
+
* @param {import('../../ports/CodecPort.js').default} [options.codec]
|
|
18
|
+
* @param {number} [options.maxCachedShards=64]
|
|
19
|
+
*/
|
|
20
|
+
constructor({ storage, codec, maxCachedShards = 64 } = /** @type {{ storage?: import('../../ports/IndexStoragePort.js').default, codec?: import('../../ports/CodecPort.js').default, maxCachedShards?: number }} */ ({})) {
|
|
21
|
+
this._storage = storage;
|
|
22
|
+
this._codec = codec || defaultCodec;
|
|
23
|
+
/** @type {Map<string, string>} path → oid */
|
|
24
|
+
this._shardOids = new Map();
|
|
25
|
+
/** @type {LRUCache<string, Record<string, Record<string, unknown>>>} */
|
|
26
|
+
this._cache = new LRUCache(maxCachedShards);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configures OID mappings for lazy loading.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, string>} shardOids - path → blob OID
|
|
33
|
+
*/
|
|
34
|
+
setup(shardOids) {
|
|
35
|
+
this._shardOids = new Map(Object.entries(shardOids));
|
|
36
|
+
this._cache.clear();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns all properties for a node, or null if not found.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} nodeId
|
|
43
|
+
* @returns {Promise<Record<string, unknown>|null>}
|
|
44
|
+
*/
|
|
45
|
+
async getNodeProps(nodeId) {
|
|
46
|
+
const shard = await this._loadShard(nodeId);
|
|
47
|
+
if (!shard) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return shard[nodeId] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns a single property value, or undefined.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} nodeId
|
|
57
|
+
* @param {string} key
|
|
58
|
+
* @returns {Promise<unknown|undefined>}
|
|
59
|
+
*/
|
|
60
|
+
async getProperty(nodeId, key) {
|
|
61
|
+
const props = await this.getNodeProps(nodeId);
|
|
62
|
+
if (!props) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return props[key];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} nodeId
|
|
70
|
+
* @returns {Promise<Record<string, Record<string, unknown>>|null>}
|
|
71
|
+
* @private
|
|
72
|
+
*/
|
|
73
|
+
async _loadShard(nodeId) {
|
|
74
|
+
const shardKey = computeShardKey(nodeId);
|
|
75
|
+
const path = `props_${shardKey}.cbor`;
|
|
76
|
+
|
|
77
|
+
const cached = this._cache.get(path);
|
|
78
|
+
if (cached !== undefined) {
|
|
79
|
+
return cached;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const oid = this._shardOids.get(path);
|
|
83
|
+
if (!oid) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!this._storage) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const buffer = await /** @type {{ readBlob(oid: string): Promise<Buffer|Uint8Array|undefined|null> }} */ (this._storage).readBlob(oid);
|
|
92
|
+
if (buffer === null || buffer === undefined) {
|
|
93
|
+
throw new Error(`PropertyIndexReader: missing blob for OID '${oid}' (${path})`);
|
|
94
|
+
}
|
|
95
|
+
const decoded = this._codec.decode(buffer);
|
|
96
|
+
|
|
97
|
+
// Shards are stored as array of [nodeId, props] pairs (proto-safe)
|
|
98
|
+
if (!Array.isArray(decoded)) {
|
|
99
|
+
const shape = decoded === null ? 'null' : typeof decoded;
|
|
100
|
+
throw new Error(`PropertyIndexReader: invalid shard format for '${path}' (expected array, got ${shape})`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
104
|
+
const data = Object.create(null);
|
|
105
|
+
for (const [nid, props] of /** @type {Array<[string, Record<string, unknown>]>} */ (decoded)) {
|
|
106
|
+
data[nid] = props;
|
|
107
|
+
}
|
|
108
|
+
this._cache.set(path, data);
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import QueryError from '../errors/QueryError.js';
|
|
8
|
+
import { matchGlob } from '../utils/matchGlob.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_PATTERN = '*';
|
|
10
11
|
|
|
@@ -48,15 +49,18 @@ const DEFAULT_PATTERN = '*';
|
|
|
48
49
|
*/
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
|
-
* Asserts that a match pattern is a string.
|
|
52
|
+
* Asserts that a match pattern is a string or array of strings.
|
|
52
53
|
*
|
|
53
54
|
* @param {unknown} pattern - The pattern to validate
|
|
54
|
-
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
|
|
55
|
+
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
|
|
55
56
|
* @private
|
|
56
57
|
*/
|
|
57
58
|
function assertMatchPattern(pattern) {
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const isString = typeof pattern === 'string';
|
|
60
|
+
const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');
|
|
61
|
+
|
|
62
|
+
if (!isString && !isStringArray) {
|
|
63
|
+
throw new QueryError('match() expects a string pattern or array of string patterns', {
|
|
60
64
|
code: 'E_QUERY_MATCH_TYPE',
|
|
61
65
|
context: { receivedType: typeof pattern },
|
|
62
66
|
});
|
|
@@ -165,41 +169,6 @@ function sortIds(ids) {
|
|
|
165
169
|
return [...ids].sort();
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
/**
|
|
169
|
-
* Escapes special regex characters in a string so it can be used as a literal match.
|
|
170
|
-
*
|
|
171
|
-
* @param {string} value - The string to escape
|
|
172
|
-
* @returns {string} The escaped string safe for use in a RegExp
|
|
173
|
-
* @private
|
|
174
|
-
*/
|
|
175
|
-
function escapeRegex(value) {
|
|
176
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Tests whether a node ID matches a glob-style pattern.
|
|
181
|
-
*
|
|
182
|
-
* Supports:
|
|
183
|
-
* - `*` as the default pattern, matching all node IDs
|
|
184
|
-
* - Wildcard `*` anywhere in the pattern, matching zero or more characters
|
|
185
|
-
* - Literal match when pattern contains no wildcards
|
|
186
|
-
*
|
|
187
|
-
* @param {string} nodeId - The node ID to test
|
|
188
|
-
* @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
|
|
189
|
-
* @returns {boolean} True if the node ID matches the pattern
|
|
190
|
-
* @private
|
|
191
|
-
*/
|
|
192
|
-
function matchesPattern(nodeId, pattern) {
|
|
193
|
-
if (pattern === DEFAULT_PATTERN) {
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
if (pattern.includes('*')) {
|
|
197
|
-
const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
|
|
198
|
-
return regex.test(nodeId);
|
|
199
|
-
}
|
|
200
|
-
return nodeId === pattern;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
172
|
/**
|
|
204
173
|
* Recursively freezes an object and all nested objects/arrays.
|
|
205
174
|
*
|
|
@@ -494,7 +463,7 @@ export default class QueryBuilder {
|
|
|
494
463
|
*/
|
|
495
464
|
constructor(graph) {
|
|
496
465
|
this._graph = graph;
|
|
497
|
-
/** @type {string|null} */
|
|
466
|
+
/** @type {string|string[]|null} */
|
|
498
467
|
this._pattern = null;
|
|
499
468
|
/** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
|
|
500
469
|
this._operations = [];
|
|
@@ -505,19 +474,21 @@ export default class QueryBuilder {
|
|
|
505
474
|
}
|
|
506
475
|
|
|
507
476
|
/**
|
|
508
|
-
* Sets the match pattern for filtering nodes by ID.
|
|
477
|
+
* Sets the match pattern(s) for filtering nodes by ID.
|
|
509
478
|
*
|
|
510
479
|
* Supports glob-style patterns:
|
|
511
480
|
* - `*` matches all nodes
|
|
512
481
|
* - `user:*` matches all nodes starting with "user:"
|
|
513
482
|
* - `*:admin` matches all nodes ending with ":admin"
|
|
483
|
+
* - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
|
|
514
484
|
*
|
|
515
|
-
* @param {string} pattern - Glob pattern to match node IDs against
|
|
485
|
+
* @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
|
|
516
486
|
* @returns {QueryBuilder} This builder for chaining
|
|
517
|
-
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
|
|
487
|
+
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
|
|
518
488
|
*/
|
|
519
489
|
match(pattern) {
|
|
520
490
|
assertMatchPattern(pattern);
|
|
491
|
+
/** @type {string|string[]|null} */
|
|
521
492
|
this._pattern = pattern;
|
|
522
493
|
return this;
|
|
523
494
|
}
|
|
@@ -682,7 +653,7 @@ export default class QueryBuilder {
|
|
|
682
653
|
const pattern = this._pattern ?? DEFAULT_PATTERN;
|
|
683
654
|
|
|
684
655
|
let workingSet;
|
|
685
|
-
workingSet = allNodes.filter((nodeId) =>
|
|
656
|
+
workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
|
|
686
657
|
|
|
687
658
|
for (const op of this._operations) {
|
|
688
659
|
if (op.type === 'where') {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* @see Paper IV - Echo and the WARP Core (CTL* temporal logic on histories)
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { createEmptyStateV5, join as joinPatch } from './JoinReducer.js';
|
|
29
|
+
import { createEmptyStateV5, cloneStateV5, join as joinPatch } from './JoinReducer.js';
|
|
30
30
|
import { decodePropKey } from './KeyCodec.js';
|
|
31
31
|
import { orsetContains } from '../crdt/ORSet.js';
|
|
32
32
|
|
|
@@ -83,6 +83,64 @@ function extractNodeSnapshot(state, nodeId) {
|
|
|
83
83
|
return { id: nodeId, exists, props };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Evaluates checkpoint boundary semantics for `always()`.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} params
|
|
90
|
+
* @param {import('./JoinReducer.js').WarpStateV5} params.state
|
|
91
|
+
* @param {string} params.nodeId
|
|
92
|
+
* @param {Function} params.predicate
|
|
93
|
+
* @param {number|null} params.checkpointMaxLamport
|
|
94
|
+
* @param {number} params.since
|
|
95
|
+
* @returns {{ nodeEverExisted: boolean, shouldReturn: boolean, returnValue: boolean }}
|
|
96
|
+
* @private
|
|
97
|
+
*/
|
|
98
|
+
function evaluateAlwaysCheckpointBoundary({
|
|
99
|
+
state,
|
|
100
|
+
nodeId,
|
|
101
|
+
predicate,
|
|
102
|
+
checkpointMaxLamport,
|
|
103
|
+
since,
|
|
104
|
+
}) {
|
|
105
|
+
if (checkpointMaxLamport !== since) {
|
|
106
|
+
return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
|
|
107
|
+
}
|
|
108
|
+
const snapshot = extractNodeSnapshot(state, nodeId);
|
|
109
|
+
if (!snapshot.exists) {
|
|
110
|
+
return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
|
|
111
|
+
}
|
|
112
|
+
if (!predicate(snapshot)) {
|
|
113
|
+
return { nodeEverExisted: true, shouldReturn: true, returnValue: false };
|
|
114
|
+
}
|
|
115
|
+
return { nodeEverExisted: true, shouldReturn: false, returnValue: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluates checkpoint boundary semantics for `eventually()`.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} params
|
|
122
|
+
* @param {import('./JoinReducer.js').WarpStateV5} params.state
|
|
123
|
+
* @param {string} params.nodeId
|
|
124
|
+
* @param {Function} params.predicate
|
|
125
|
+
* @param {number|null} params.checkpointMaxLamport
|
|
126
|
+
* @param {number} params.since
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
function evaluateEventuallyCheckpointBoundary({
|
|
131
|
+
state,
|
|
132
|
+
nodeId,
|
|
133
|
+
predicate,
|
|
134
|
+
checkpointMaxLamport,
|
|
135
|
+
since,
|
|
136
|
+
}) {
|
|
137
|
+
if (checkpointMaxLamport !== since) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const snapshot = extractNodeSnapshot(state, nodeId);
|
|
141
|
+
return snapshot.exists && predicate(snapshot);
|
|
142
|
+
}
|
|
143
|
+
|
|
86
144
|
/**
|
|
87
145
|
* TemporalQuery provides temporal logic operators over graph history.
|
|
88
146
|
*
|
|
@@ -94,10 +152,14 @@ export class TemporalQuery {
|
|
|
94
152
|
* @param {Object} options
|
|
95
153
|
* @param {Function} options.loadAllPatches - Async function that returns
|
|
96
154
|
* all patches as Array<{ patch, sha }> in causal order.
|
|
155
|
+
* @param {Function} [options.loadCheckpoint] - Async function returning
|
|
156
|
+
* { state: WarpStateV5, maxLamport: number } or null.
|
|
97
157
|
*/
|
|
98
|
-
constructor({ loadAllPatches }) {
|
|
158
|
+
constructor({ loadAllPatches, loadCheckpoint }) {
|
|
99
159
|
/** @type {Function} */
|
|
100
160
|
this._loadAllPatches = loadAllPatches;
|
|
161
|
+
/** @type {Function|null} */
|
|
162
|
+
this._loadCheckpoint = loadCheckpoint || null;
|
|
101
163
|
}
|
|
102
164
|
|
|
103
165
|
/**
|
|
@@ -128,19 +190,27 @@ export class TemporalQuery {
|
|
|
128
190
|
const since = options.since ?? 0;
|
|
129
191
|
const allPatches = await this._loadAllPatches();
|
|
130
192
|
|
|
131
|
-
const state =
|
|
132
|
-
|
|
193
|
+
const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
|
|
194
|
+
const boundary = evaluateAlwaysCheckpointBoundary({
|
|
195
|
+
state,
|
|
196
|
+
nodeId,
|
|
197
|
+
predicate,
|
|
198
|
+
checkpointMaxLamport,
|
|
199
|
+
since,
|
|
200
|
+
});
|
|
201
|
+
if (boundary.shouldReturn) {
|
|
202
|
+
return boundary.returnValue;
|
|
203
|
+
}
|
|
204
|
+
let { nodeEverExisted } = boundary;
|
|
133
205
|
|
|
134
|
-
for (
|
|
135
|
-
|
|
206
|
+
for (let i = startIdx; i < allPatches.length; i++) {
|
|
207
|
+
const { patch, sha } = allPatches[i];
|
|
136
208
|
joinPatch(state, patch, sha);
|
|
137
209
|
|
|
138
|
-
// Skip patches before the `since` threshold
|
|
139
210
|
if (patch.lamport < since) {
|
|
140
211
|
continue;
|
|
141
212
|
}
|
|
142
213
|
|
|
143
|
-
// Extract node snapshot at this tick
|
|
144
214
|
const snapshot = extractNodeSnapshot(state, nodeId);
|
|
145
215
|
|
|
146
216
|
if (snapshot.exists) {
|
|
@@ -151,7 +221,6 @@ export class TemporalQuery {
|
|
|
151
221
|
}
|
|
152
222
|
}
|
|
153
223
|
|
|
154
|
-
// If the node never existed in the range, return false
|
|
155
224
|
return nodeEverExisted;
|
|
156
225
|
}
|
|
157
226
|
|
|
@@ -181,18 +250,26 @@ export class TemporalQuery {
|
|
|
181
250
|
const since = options.since ?? 0;
|
|
182
251
|
const allPatches = await this._loadAllPatches();
|
|
183
252
|
|
|
184
|
-
const state =
|
|
253
|
+
const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
|
|
185
254
|
|
|
186
|
-
|
|
187
|
-
|
|
255
|
+
if (evaluateEventuallyCheckpointBoundary({
|
|
256
|
+
state,
|
|
257
|
+
nodeId,
|
|
258
|
+
predicate,
|
|
259
|
+
checkpointMaxLamport,
|
|
260
|
+
since,
|
|
261
|
+
})) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (let i = startIdx; i < allPatches.length; i++) {
|
|
266
|
+
const { patch, sha } = allPatches[i];
|
|
188
267
|
joinPatch(state, patch, sha);
|
|
189
268
|
|
|
190
|
-
// Skip patches before the `since` threshold
|
|
191
269
|
if (patch.lamport < since) {
|
|
192
270
|
continue;
|
|
193
271
|
}
|
|
194
272
|
|
|
195
|
-
// Extract node snapshot at this tick
|
|
196
273
|
const snapshot = extractNodeSnapshot(state, nodeId);
|
|
197
274
|
|
|
198
275
|
if (snapshot.exists && predicate(snapshot)) {
|
|
@@ -202,4 +279,41 @@ export class TemporalQuery {
|
|
|
202
279
|
|
|
203
280
|
return false;
|
|
204
281
|
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Resolves the initial state and start index for temporal replay.
|
|
285
|
+
*
|
|
286
|
+
* When `since > 0` and a checkpoint is available with
|
|
287
|
+
* `maxLamport <= since`, uses the checkpoint state and skips
|
|
288
|
+
* patches already covered by it. Otherwise falls back to an
|
|
289
|
+
* empty state starting from index 0.
|
|
290
|
+
*
|
|
291
|
+
* **Checkpoint `maxLamport` invariant**: The checkpoint's `maxLamport` value
|
|
292
|
+
* MUST represent a fully-closed Lamport tick — i.e. ALL patches with
|
|
293
|
+
* `lamport <= maxLamport` are included in the checkpoint state. The
|
|
294
|
+
* `findIndex` below uses strict `>` to locate the first patch *after* the
|
|
295
|
+
* checkpoint boundary. If a checkpoint were created mid-tick (some but not
|
|
296
|
+
* all patches at a given Lamport value included), this would silently skip
|
|
297
|
+
* the remaining same-tick patches. Checkpoint creators MUST guarantee the
|
|
298
|
+
* all-or-nothing inclusion property for any given Lamport tick.
|
|
299
|
+
*
|
|
300
|
+
* @param {Array<{patch: {lamport: number, [k: string]: unknown}, sha: string}>} allPatches
|
|
301
|
+
* @param {number} since - Minimum Lamport tick
|
|
302
|
+
* @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, startIdx: number, checkpointMaxLamport: number|null}>}
|
|
303
|
+
* @private
|
|
304
|
+
*/
|
|
305
|
+
async _resolveStart(allPatches, since) {
|
|
306
|
+
if (since > 0 && this._loadCheckpoint) {
|
|
307
|
+
const ck = /** @type {{ state: import('./JoinReducer.js').WarpStateV5, maxLamport: number } | null} */ (await this._loadCheckpoint());
|
|
308
|
+
if (ck && ck.state && ck.maxLamport <= since) {
|
|
309
|
+
const idx = allPatches.findIndex(
|
|
310
|
+
({ patch }) => patch.lamport > ck.maxLamport,
|
|
311
|
+
);
|
|
312
|
+
const startIdx = idx < 0 ? allPatches.length : idx;
|
|
313
|
+
// Replay mutates state in-place; isolate checkpoint provider caches from query runs.
|
|
314
|
+
return { state: cloneStateV5(ck.state), startIdx, checkpointMaxLamport: ck.maxLamport };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { state: createEmptyStateV5(), startIdx: 0, checkpointMaxLamport: null };
|
|
318
|
+
}
|
|
205
319
|
}
|
|
@@ -15,28 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
import { orsetElements, orsetContains } from '../crdt/ORSet.js';
|
|
17
17
|
import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
|
|
18
|
+
import { matchGlob } from '../utils/matchGlob.js';
|
|
18
19
|
|
|
19
20
|
/** @typedef {import('./JoinReducer.js').WarpStateV5} WarpStateV5 */
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
* Tests whether a string matches a glob-style pattern.
|
|
23
|
-
*
|
|
24
|
-
* @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
|
|
25
|
-
* @param {string} str - The string to test
|
|
26
|
-
* @returns {boolean} True if the string matches the pattern
|
|
27
|
-
*/
|
|
28
|
-
function matchGlob(pattern, str) {
|
|
29
|
-
if (pattern === '*') {
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
if (!pattern.includes('*')) {
|
|
33
|
-
return pattern === str;
|
|
34
|
-
}
|
|
35
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
-
const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
37
|
-
return regex.test(str);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
22
|
/**
|
|
41
23
|
* Computes the set of property keys visible under an observer config.
|
|
42
24
|
*
|
|
@@ -188,20 +170,22 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
|
|
|
188
170
|
* A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A) in general.
|
|
189
171
|
*
|
|
190
172
|
* @param {Object} configA - Observer configuration for A
|
|
191
|
-
* @param {string} configA.match - Glob pattern for visible nodes
|
|
173
|
+
* @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
|
|
192
174
|
* @param {string[]} [configA.expose] - Property keys to include
|
|
193
175
|
* @param {string[]} [configA.redact] - Property keys to exclude
|
|
194
176
|
* @param {Object} configB - Observer configuration for B
|
|
195
|
-
* @param {string} configB.match - Glob pattern for visible nodes
|
|
177
|
+
* @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
|
|
196
178
|
* @param {string[]} [configB.expose] - Property keys to include
|
|
197
179
|
* @param {string[]} [configB.redact] - Property keys to exclude
|
|
198
180
|
* @param {WarpStateV5} state - WarpStateV5 materialized state
|
|
199
181
|
* @returns {{ cost: number, breakdown: { nodeLoss: number, edgeLoss: number, propLoss: number } }}
|
|
200
182
|
*/
|
|
201
183
|
export function computeTranslationCost(configA, configB, state) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
184
|
+
/** @param {unknown} m */
|
|
185
|
+
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
186
|
+
if (!configA || !isValidMatch(configA.match) ||
|
|
187
|
+
!configB || !isValidMatch(configB.match)) {
|
|
188
|
+
throw new Error('configA.match and configB.match must be strings or arrays of strings');
|
|
205
189
|
}
|
|
206
190
|
const allNodes = [...orsetElements(state.nodeAlive)];
|
|
207
191
|
const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PatchDiff — captures alive-ness transitions during patch application.
|
|
3
|
+
*
|
|
4
|
+
* A diff entry is produced only when the alive-ness state of a node or edge
|
|
5
|
+
* actually changes, or when an LWW property winner changes. Redundant ops
|
|
6
|
+
* (e.g. NodeAdd on an already-alive node) produce no diff entries.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/types/PatchDiff
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} EdgeDiffEntry
|
|
13
|
+
* @property {string} from - Source node ID
|
|
14
|
+
* @property {string} to - Target node ID
|
|
15
|
+
* @property {string} label - Edge label
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} PropDiffEntry
|
|
20
|
+
* @property {string} nodeId - Node (or edge-prop owner) ID
|
|
21
|
+
* @property {string} key - Property key
|
|
22
|
+
* @property {unknown} value - New LWW winner value
|
|
23
|
+
* @property {unknown} prevValue - Previous LWW winner value (undefined if none)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} PatchDiff
|
|
28
|
+
* @property {string[]} nodesAdded - Nodes that transitioned not-alive → alive
|
|
29
|
+
* @property {string[]} nodesRemoved - Nodes that transitioned alive → not-alive
|
|
30
|
+
* @property {EdgeDiffEntry[]} edgesAdded - Edges that transitioned not-alive → alive
|
|
31
|
+
* @property {EdgeDiffEntry[]} edgesRemoved - Edges that transitioned alive → not-alive
|
|
32
|
+
* @property {PropDiffEntry[]} propsChanged - Properties whose LWW winner actually changed
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates an empty PatchDiff.
|
|
37
|
+
*
|
|
38
|
+
* @returns {PatchDiff}
|
|
39
|
+
*/
|
|
40
|
+
export function createEmptyDiff() {
|
|
41
|
+
return {
|
|
42
|
+
nodesAdded: [],
|
|
43
|
+
nodesRemoved: [],
|
|
44
|
+
edgesAdded: [],
|
|
45
|
+
edgesRemoved: [],
|
|
46
|
+
propsChanged: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Merges two PatchDiff objects into a net diff by cancelling out
|
|
52
|
+
* contradictory add/remove pairs.
|
|
53
|
+
*
|
|
54
|
+
* - A node that appears in `a.nodesAdded` and `b.nodesRemoved` (or vice-versa)
|
|
55
|
+
* is dropped from both lists (the transitions cancel out).
|
|
56
|
+
* - Same logic applies to edges (keyed by `from\0to\0label`).
|
|
57
|
+
* - For `propsChanged`, only the last entry per `(nodeId, key)` is kept.
|
|
58
|
+
*
|
|
59
|
+
* @param {PatchDiff} a
|
|
60
|
+
* @param {PatchDiff} b
|
|
61
|
+
* @returns {PatchDiff}
|
|
62
|
+
*/
|
|
63
|
+
export function mergeDiffs(a, b) {
|
|
64
|
+
const allAdded = a.nodesAdded.concat(b.nodesAdded);
|
|
65
|
+
const allRemoved = a.nodesRemoved.concat(b.nodesRemoved);
|
|
66
|
+
const removedSet = new Set(allRemoved);
|
|
67
|
+
const addedSet = new Set(allAdded);
|
|
68
|
+
const nodesAdded = allAdded.filter((id) => !removedSet.has(id));
|
|
69
|
+
const nodesRemoved = allRemoved.filter((id) => !addedSet.has(id));
|
|
70
|
+
|
|
71
|
+
/** @param {EdgeDiffEntry} e */
|
|
72
|
+
const edgeKey = (e) => `${e.from}\0${e.to}\0${e.label}`;
|
|
73
|
+
const allEdgesAdded = a.edgesAdded.concat(b.edgesAdded);
|
|
74
|
+
const allEdgesRemoved = a.edgesRemoved.concat(b.edgesRemoved);
|
|
75
|
+
const edgeRemovedSet = new Set(allEdgesRemoved.map(edgeKey));
|
|
76
|
+
const edgeAddedSet = new Set(allEdgesAdded.map(edgeKey));
|
|
77
|
+
const edgesAdded = allEdgesAdded.filter((e) => !edgeRemovedSet.has(edgeKey(e)));
|
|
78
|
+
const edgesRemoved = allEdgesRemoved.filter((e) => !edgeAddedSet.has(edgeKey(e)));
|
|
79
|
+
|
|
80
|
+
// For props, deduplicate by keeping the last entry per (nodeId, key).
|
|
81
|
+
const allProps = a.propsChanged.concat(b.propsChanged);
|
|
82
|
+
/** @type {Map<string, PropDiffEntry>} */
|
|
83
|
+
const propMap = new Map();
|
|
84
|
+
for (const entry of allProps) {
|
|
85
|
+
propMap.set(`${entry.nodeId}\0${entry.key}`, entry);
|
|
86
|
+
}
|
|
87
|
+
const propsChanged = [...propMap.values()];
|
|
88
|
+
|
|
89
|
+
return { nodesAdded, nodesRemoved, edgesAdded, edgesRemoved, propsChanged };
|
|
90
|
+
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* @typedef {Object} OpV2NodeRemove
|
|
51
51
|
* @property {'NodeRemove'} type - Operation type discriminator
|
|
52
52
|
* @property {NodeId} node - Node ID to remove
|
|
53
|
-
* @property {
|
|
53
|
+
* @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
* @property {NodeId} from - Source node ID
|
|
71
71
|
* @property {NodeId} to - Target node ID
|
|
72
72
|
* @property {string} label - Edge label/type
|
|
73
|
-
* @property {
|
|
73
|
+
* @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
|
|
74
74
|
*/
|
|
75
75
|
|
|
76
76
|
/**
|
|
@@ -121,7 +121,7 @@ export function createNodeAddV2(node, dot) {
|
|
|
121
121
|
/**
|
|
122
122
|
* Creates a NodeRemove operation with observed dots
|
|
123
123
|
* @param {NodeId} node - Node ID to remove
|
|
124
|
-
* @param {
|
|
124
|
+
* @param {string[]} observedDots - Encoded dot strings being removed
|
|
125
125
|
* @returns {OpV2NodeRemove} NodeRemove operation
|
|
126
126
|
*/
|
|
127
127
|
export function createNodeRemoveV2(node, observedDots) {
|
|
@@ -145,7 +145,7 @@ export function createEdgeAddV2(from, to, label, dot) {
|
|
|
145
145
|
* @param {NodeId} from - Source node ID
|
|
146
146
|
* @param {NodeId} to - Target node ID
|
|
147
147
|
* @param {string} label - Edge label
|
|
148
|
-
* @param {
|
|
148
|
+
* @param {string[]} observedDots - Encoded dot strings being removed
|
|
149
149
|
* @returns {OpV2EdgeRemove} EdgeRemove operation
|
|
150
150
|
*/
|
|
151
151
|
export function createEdgeRemoveV2(from, to, label, observedDots) {
|