@git-stunts/git-warp 10.8.0 → 11.2.1
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 +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +6 -0
- package/bin/presenters/text.js +136 -0
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +8 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork and wormhole methods for WarpGraph, plus backfill-rejection helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every function uses `this` bound to a WarpGraph instance at runtime
|
|
5
|
+
* via wireWarpMethods().
|
|
6
|
+
*
|
|
7
|
+
* @module domain/warp/fork.methods
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ForkError, DEFAULT_ADJACENCY_CACHE_SIZE } from './_internal.js';
|
|
11
|
+
import { validateGraphName, validateWriterId, buildWriterRef, buildWritersPrefix } from '../utils/RefLayout.js';
|
|
12
|
+
import { generateWriterId } from '../utils/WriterId.js';
|
|
13
|
+
import { createWormhole as createWormholeImpl } from '../services/WormholeService.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Fork API
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a fork of this graph at a specific point in a writer's history.
|
|
21
|
+
*
|
|
22
|
+
* A fork creates a new WarpGraph instance that shares history up to the
|
|
23
|
+
* specified patch SHA. Due to Git's content-addressed storage, the shared
|
|
24
|
+
* history is automatically deduplicated. The fork gets a new writer ID and
|
|
25
|
+
* operates independently from the original graph.
|
|
26
|
+
*
|
|
27
|
+
* **Key Properties:**
|
|
28
|
+
* - Fork materializes the same state as the original at the fork point
|
|
29
|
+
* - Writes to the fork don't appear in the original
|
|
30
|
+
* - Writes to the original after fork don't appear in the fork
|
|
31
|
+
* - History up to the fork point is shared (content-addressed dedup)
|
|
32
|
+
*
|
|
33
|
+
* @this {import('../WarpGraph.js').default}
|
|
34
|
+
* @param {Object} options - Fork configuration
|
|
35
|
+
* @param {string} options.from - Writer ID whose chain to fork from
|
|
36
|
+
* @param {string} options.at - Patch SHA to fork at (must be in the writer's chain)
|
|
37
|
+
* @param {string} [options.forkName] - Name for the forked graph. Defaults to `<graphName>-fork-<timestamp>`
|
|
38
|
+
* @param {string} [options.forkWriterId] - Writer ID for the fork. Defaults to a new canonical ID.
|
|
39
|
+
* @returns {Promise<import('../WarpGraph.js').default>} A new WarpGraph instance for the fork
|
|
40
|
+
* @throws {ForkError} If `from` writer does not exist (code: `E_FORK_WRITER_NOT_FOUND`)
|
|
41
|
+
* @throws {ForkError} If `at` SHA does not exist (code: `E_FORK_PATCH_NOT_FOUND`)
|
|
42
|
+
* @throws {ForkError} If `at` SHA is not in the writer's chain (code: `E_FORK_PATCH_NOT_IN_CHAIN`)
|
|
43
|
+
* @throws {ForkError} If fork graph name is invalid (code: `E_FORK_NAME_INVALID`)
|
|
44
|
+
* @throws {ForkError} If a graph with the fork name already has refs (code: `E_FORK_ALREADY_EXISTS`)
|
|
45
|
+
* @throws {ForkError} If required parameters are missing or invalid (code: `E_FORK_INVALID_ARGS`)
|
|
46
|
+
* @throws {ForkError} If forkWriterId is invalid (code: `E_FORK_WRITER_ID_INVALID`)
|
|
47
|
+
*/
|
|
48
|
+
export async function fork({ from, at, forkName, forkWriterId }) {
|
|
49
|
+
const t0 = this._clock.now();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Validate required parameters
|
|
53
|
+
if (!from || typeof from !== 'string') {
|
|
54
|
+
throw new ForkError("Required parameter 'from' is missing or not a string", {
|
|
55
|
+
code: 'E_FORK_INVALID_ARGS',
|
|
56
|
+
context: { from },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!at || typeof at !== 'string') {
|
|
61
|
+
throw new ForkError("Required parameter 'at' is missing or not a string", {
|
|
62
|
+
code: 'E_FORK_INVALID_ARGS',
|
|
63
|
+
context: { at },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 1. Validate that the `from` writer exists
|
|
68
|
+
const writers = await this.discoverWriters();
|
|
69
|
+
if (!writers.includes(from)) {
|
|
70
|
+
throw new ForkError(`Writer '${from}' does not exist in graph '${this._graphName}'`, {
|
|
71
|
+
code: 'E_FORK_WRITER_NOT_FOUND',
|
|
72
|
+
context: { writerId: from, graphName: this._graphName, existingWriters: writers },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Validate that `at` SHA exists in the repository
|
|
77
|
+
const nodeExists = await this._persistence.nodeExists(at);
|
|
78
|
+
if (!nodeExists) {
|
|
79
|
+
throw new ForkError(`Patch SHA '${at}' does not exist`, {
|
|
80
|
+
code: 'E_FORK_PATCH_NOT_FOUND',
|
|
81
|
+
context: { patchSha: at, writerId: from },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Validate that `at` SHA is in the writer's chain
|
|
86
|
+
const writerRef = buildWriterRef(this._graphName, from);
|
|
87
|
+
const tipSha = await this._persistence.readRef(writerRef);
|
|
88
|
+
|
|
89
|
+
if (!tipSha) {
|
|
90
|
+
throw new ForkError(`Writer '${from}' has no commits`, {
|
|
91
|
+
code: 'E_FORK_WRITER_NOT_FOUND',
|
|
92
|
+
context: { writerId: from },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Walk the chain to verify `at` is reachable from the tip
|
|
97
|
+
const isInChain = await this._isAncestor(at, tipSha);
|
|
98
|
+
if (!isInChain) {
|
|
99
|
+
throw new ForkError(`Patch SHA '${at}' is not in writer '${from}' chain`, {
|
|
100
|
+
code: 'E_FORK_PATCH_NOT_IN_CHAIN',
|
|
101
|
+
context: { patchSha: at, writerId: from, tipSha },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4. Generate or validate fork name (add random suffix to prevent collisions)
|
|
106
|
+
const resolvedForkName =
|
|
107
|
+
forkName ?? `${this._graphName}-fork-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
108
|
+
try {
|
|
109
|
+
validateGraphName(resolvedForkName);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new ForkError(`Invalid fork name: ${/** @type {Error} */ (err).message}`, {
|
|
112
|
+
code: 'E_FORK_NAME_INVALID',
|
|
113
|
+
context: { forkName: resolvedForkName, originalError: /** @type {Error} */ (err).message },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. Check that the fork graph doesn't already exist (has any refs)
|
|
118
|
+
const forkWritersPrefix = buildWritersPrefix(resolvedForkName);
|
|
119
|
+
const existingForkRefs = await this._persistence.listRefs(forkWritersPrefix);
|
|
120
|
+
if (existingForkRefs.length > 0) {
|
|
121
|
+
throw new ForkError(`Graph '${resolvedForkName}' already exists`, {
|
|
122
|
+
code: 'E_FORK_ALREADY_EXISTS',
|
|
123
|
+
context: { forkName: resolvedForkName, existingRefs: existingForkRefs },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Generate or validate fork writer ID
|
|
128
|
+
const resolvedForkWriterId = forkWriterId || generateWriterId();
|
|
129
|
+
try {
|
|
130
|
+
validateWriterId(resolvedForkWriterId);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new ForkError(`Invalid fork writer ID: ${/** @type {Error} */ (err).message}`, {
|
|
133
|
+
code: 'E_FORK_WRITER_ID_INVALID',
|
|
134
|
+
context: { forkWriterId: resolvedForkWriterId, originalError: /** @type {Error} */ (err).message },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 7. Create the fork's writer ref pointing to the `at` commit
|
|
139
|
+
const forkWriterRef = buildWriterRef(resolvedForkName, resolvedForkWriterId);
|
|
140
|
+
await this._persistence.updateRef(forkWriterRef, at);
|
|
141
|
+
|
|
142
|
+
// 8. Open and return a new WarpGraph instance for the fork
|
|
143
|
+
// Dynamic import to avoid circular dependency (WarpGraph -> fork.methods -> WarpGraph)
|
|
144
|
+
const { default: WarpGraph } = await import('../WarpGraph.js');
|
|
145
|
+
|
|
146
|
+
const forkGraph = await WarpGraph.open({
|
|
147
|
+
persistence: this._persistence,
|
|
148
|
+
graphName: resolvedForkName,
|
|
149
|
+
writerId: resolvedForkWriterId,
|
|
150
|
+
gcPolicy: this._gcPolicy,
|
|
151
|
+
adjacencyCacheSize: this._adjacencyCache?.maxSize ?? DEFAULT_ADJACENCY_CACHE_SIZE,
|
|
152
|
+
checkpointPolicy: this._checkpointPolicy || undefined,
|
|
153
|
+
autoMaterialize: this._autoMaterialize,
|
|
154
|
+
onDeleteWithData: this._onDeleteWithData,
|
|
155
|
+
logger: this._logger || undefined,
|
|
156
|
+
clock: this._clock,
|
|
157
|
+
crypto: this._crypto,
|
|
158
|
+
codec: this._codec,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this._logTiming('fork', t0, {
|
|
162
|
+
metrics: `from=${from} at=${at.slice(0, 7)} name=${resolvedForkName}`,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return forkGraph;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this._logTiming('fork', t0, { error: /** @type {Error} */ (err) });
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Wormhole API (HOLOGRAM)
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Creates a wormhole compressing a range of patches.
|
|
178
|
+
*
|
|
179
|
+
* A wormhole is a compressed representation of a contiguous range of patches
|
|
180
|
+
* from a single writer. It preserves provenance by storing the original
|
|
181
|
+
* patches as a ProvenancePayload that can be replayed during materialization.
|
|
182
|
+
*
|
|
183
|
+
* **Key Properties:**
|
|
184
|
+
* - **Provenance Preservation**: The wormhole contains the full sub-payload,
|
|
185
|
+
* allowing exact replay of the compressed segment.
|
|
186
|
+
* - **Monoid Composition**: Two consecutive wormholes can be composed by
|
|
187
|
+
* concatenating their sub-payloads (use `WormholeService.composeWormholes`).
|
|
188
|
+
* - **Materialization Equivalence**: A wormhole + remaining patches produces
|
|
189
|
+
* the same state as materializing all patches.
|
|
190
|
+
*
|
|
191
|
+
* @this {import('../WarpGraph.js').default}
|
|
192
|
+
* @param {string} fromSha - SHA of the first (oldest) patch commit in the range
|
|
193
|
+
* @param {string} toSha - SHA of the last (newest) patch commit in the range
|
|
194
|
+
* @returns {Promise<{fromSha: string, toSha: string, writerId: string, payload: import('../services/ProvenancePayload.js').default, patchCount: number}>} The created wormhole edge
|
|
195
|
+
* @throws {import('../errors/WormholeError.js').default} If fromSha or toSha doesn't exist (E_WORMHOLE_SHA_NOT_FOUND)
|
|
196
|
+
* @throws {import('../errors/WormholeError.js').default} If fromSha is not an ancestor of toSha (E_WORMHOLE_INVALID_RANGE)
|
|
197
|
+
* @throws {import('../errors/WormholeError.js').default} If commits span multiple writers (E_WORMHOLE_MULTI_WRITER)
|
|
198
|
+
* @throws {import('../errors/WormholeError.js').default} If a commit is not a patch commit (E_WORMHOLE_NOT_PATCH)
|
|
199
|
+
*/
|
|
200
|
+
export async function createWormhole(fromSha, toSha) {
|
|
201
|
+
const t0 = this._clock.now();
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const wormhole = await createWormholeImpl(/** @type {any} */ ({ // TODO(ts-cleanup): needs options type
|
|
205
|
+
persistence: this._persistence,
|
|
206
|
+
graphName: this._graphName,
|
|
207
|
+
fromSha,
|
|
208
|
+
toSha,
|
|
209
|
+
codec: this._codec,
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
this._logTiming('createWormhole', t0, {
|
|
213
|
+
metrics: `${wormhole.patchCount} patches from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return wormhole;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
this._logTiming('createWormhole', t0, { error: /** @type {Error} */ (err) });
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Backfill Rejection and Divergence Detection
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Checks if ancestorSha is an ancestor of descendantSha.
|
|
229
|
+
* Walks the commit graph (linear per-writer chain assumption).
|
|
230
|
+
*
|
|
231
|
+
* @this {import('../WarpGraph.js').default}
|
|
232
|
+
* @param {string} ancestorSha - The potential ancestor commit SHA
|
|
233
|
+
* @param {string} descendantSha - The potential descendant commit SHA
|
|
234
|
+
* @returns {Promise<boolean>} True if ancestorSha is an ancestor of descendantSha
|
|
235
|
+
* @private
|
|
236
|
+
*/
|
|
237
|
+
export async function _isAncestor(ancestorSha, descendantSha) {
|
|
238
|
+
if (!ancestorSha || !descendantSha) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (ancestorSha === descendantSha) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let cur = descendantSha;
|
|
246
|
+
const MAX_WALK = 100_000;
|
|
247
|
+
let steps = 0;
|
|
248
|
+
while (cur) {
|
|
249
|
+
if (++steps > MAX_WALK) {
|
|
250
|
+
throw new Error(`_isAncestor: exceeded ${MAX_WALK} steps — possible cycle`);
|
|
251
|
+
}
|
|
252
|
+
const nodeInfo = await this._persistence.getNodeInfo(cur);
|
|
253
|
+
const parent = nodeInfo.parents?.[0] ?? null;
|
|
254
|
+
if (parent === ancestorSha) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
cur = parent;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Determines relationship between incoming patch and checkpoint head.
|
|
264
|
+
*
|
|
265
|
+
* @this {import('../WarpGraph.js').default}
|
|
266
|
+
* @param {string} ckHead - The checkpoint head SHA for this writer
|
|
267
|
+
* @param {string} incomingSha - The incoming patch commit SHA
|
|
268
|
+
* @returns {Promise<'same' | 'ahead' | 'behind' | 'diverged'>} The relationship
|
|
269
|
+
* @private
|
|
270
|
+
*/
|
|
271
|
+
export async function _relationToCheckpointHead(ckHead, incomingSha) {
|
|
272
|
+
if (incomingSha === ckHead) {
|
|
273
|
+
return 'same';
|
|
274
|
+
}
|
|
275
|
+
if (await this._isAncestor(ckHead, incomingSha)) {
|
|
276
|
+
return 'ahead';
|
|
277
|
+
}
|
|
278
|
+
if (await this._isAncestor(incomingSha, ckHead)) {
|
|
279
|
+
return 'behind';
|
|
280
|
+
}
|
|
281
|
+
return 'diverged';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validates an incoming patch against checkpoint frontier.
|
|
286
|
+
* Uses graph reachability, NOT lamport timestamps.
|
|
287
|
+
*
|
|
288
|
+
* @this {import('../WarpGraph.js').default}
|
|
289
|
+
* @param {string} writerId - The writer ID for this patch
|
|
290
|
+
* @param {string} incomingSha - The incoming patch commit SHA
|
|
291
|
+
* @param {{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, stateHash: string, schema: number}} checkpoint - The checkpoint to validate against
|
|
292
|
+
* @returns {Promise<void>}
|
|
293
|
+
* @throws {Error} If patch is behind/same as checkpoint frontier (backfill rejected)
|
|
294
|
+
* @throws {Error} If patch does not extend checkpoint head (writer fork detected)
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
export async function _validatePatchAgainstCheckpoint(writerId, incomingSha, checkpoint) {
|
|
298
|
+
if (!checkpoint || (checkpoint.schema !== 2 && checkpoint.schema !== 3)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const ckHead = checkpoint.frontier?.get(writerId);
|
|
303
|
+
if (!ckHead) {
|
|
304
|
+
return; // Checkpoint didn't include this writer
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const relation = await this._relationToCheckpointHead(ckHead, incomingSha);
|
|
308
|
+
|
|
309
|
+
if (relation === 'same' || relation === 'behind') {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Backfill rejected for writer ${writerId}: ` +
|
|
312
|
+
`incoming patch is ${relation} checkpoint frontier`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (relation === 'diverged') {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Writer fork detected for ${writerId}: ` +
|
|
319
|
+
`incoming patch does not extend checkpoint head`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
// relation === 'ahead' => OK
|
|
323
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted materialize methods for WarpGraph.
|
|
3
|
+
*
|
|
4
|
+
* Each function is designed to be bound to a WarpGraph instance at runtime.
|
|
5
|
+
*
|
|
6
|
+
* @module domain/warp/materialize.methods
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { reduceV5, createEmptyStateV5, cloneStateV5 } from '../services/JoinReducer.js';
|
|
10
|
+
import { ProvenanceIndex } from '../services/ProvenanceIndex.js';
|
|
11
|
+
import { diffStates, isEmptyDiff } from '../services/StateDiff.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Materializes the current graph state.
|
|
15
|
+
*
|
|
16
|
+
* Discovers all writers, collects all patches from each writer's ref chain,
|
|
17
|
+
* and reduces them to produce the current state.
|
|
18
|
+
*
|
|
19
|
+
* Checks if a checkpoint exists and uses incremental materialization if so.
|
|
20
|
+
*
|
|
21
|
+
* When `options.receipts` is true, returns `{ state, receipts }` where
|
|
22
|
+
* receipts is an array of TickReceipt objects (one per applied patch).
|
|
23
|
+
* When false or omitted (default), returns just the state for backward
|
|
24
|
+
* compatibility with zero receipt overhead.
|
|
25
|
+
*
|
|
26
|
+
* When a Lamport ceiling is active (via `options.ceiling` or the
|
|
27
|
+
* instance-level `_seekCeiling`), delegates to a ceiling-aware path
|
|
28
|
+
* that replays only patches with `lamport <= ceiling`, bypassing
|
|
29
|
+
* checkpoints, auto-checkpoint, and GC.
|
|
30
|
+
*
|
|
31
|
+
* Side effects: Updates internal cached state, version vector, last frontier,
|
|
32
|
+
* and patches-since-checkpoint counter. May trigger auto-checkpoint and GC
|
|
33
|
+
* based on configured policies. Notifies subscribers if state changed.
|
|
34
|
+
*
|
|
35
|
+
* @this {import('../WarpGraph.js').default}
|
|
36
|
+
* @param {{receipts?: boolean, ceiling?: number|null}} [options] - Optional configuration
|
|
37
|
+
* @returns {Promise<import('../services/JoinReducer.js').WarpStateV5|{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}>} The materialized graph state, or { state, receipts } when receipts enabled
|
|
38
|
+
* @throws {Error} If checkpoint loading fails or patch decoding fails
|
|
39
|
+
* @throws {Error} If writer ref access or patch blob reading fails
|
|
40
|
+
*/
|
|
41
|
+
export async function materialize(options) {
|
|
42
|
+
const t0 = this._clock.now();
|
|
43
|
+
// ZERO-COST: only resolve receipts flag when options provided
|
|
44
|
+
const collectReceipts = options && options.receipts;
|
|
45
|
+
// Resolve ceiling: explicit option > instance-level seek ceiling > null (latest)
|
|
46
|
+
const ceiling = this._resolveCeiling(options);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// When ceiling is active, delegate to ceiling-aware path (with its own cache)
|
|
50
|
+
if (ceiling !== null) {
|
|
51
|
+
return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check for checkpoint
|
|
55
|
+
const checkpoint = await this._loadLatestCheckpoint();
|
|
56
|
+
|
|
57
|
+
/** @type {import('../services/JoinReducer.js').WarpStateV5|undefined} */
|
|
58
|
+
let state;
|
|
59
|
+
/** @type {import('../types/TickReceipt.js').TickReceipt[]|undefined} */
|
|
60
|
+
let receipts;
|
|
61
|
+
let patchCount = 0;
|
|
62
|
+
|
|
63
|
+
// If checkpoint exists, use incremental materialization
|
|
64
|
+
if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
|
|
65
|
+
const patches = await this._loadPatchesSince(checkpoint);
|
|
66
|
+
if (collectReceipts) {
|
|
67
|
+
const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (checkpoint.state), { receipts: true })); // TODO(ts-cleanup): type patch array
|
|
68
|
+
state = result.state;
|
|
69
|
+
receipts = result.receipts;
|
|
70
|
+
} else {
|
|
71
|
+
state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (checkpoint.state))); // TODO(ts-cleanup): type patch array
|
|
72
|
+
}
|
|
73
|
+
patchCount = patches.length;
|
|
74
|
+
|
|
75
|
+
// Build provenance index: start from checkpoint index if present, then add new patches
|
|
76
|
+
const ckPI = /** @type {any} */ (checkpoint).provenanceIndex; // TODO(ts-cleanup): type checkpoint cast
|
|
77
|
+
this._provenanceIndex = ckPI
|
|
78
|
+
? ckPI.clone()
|
|
79
|
+
: new ProvenanceIndex();
|
|
80
|
+
for (const { patch, sha } of patches) {
|
|
81
|
+
/** @type {import('../services/ProvenanceIndex.js').ProvenanceIndex} */ (this._provenanceIndex).addPatch(sha, patch.reads, patch.writes);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// 1. Discover all writers
|
|
85
|
+
const writerIds = await this.discoverWriters();
|
|
86
|
+
|
|
87
|
+
// 2. If no writers, return empty state
|
|
88
|
+
if (writerIds.length === 0) {
|
|
89
|
+
state = createEmptyStateV5();
|
|
90
|
+
this._provenanceIndex = new ProvenanceIndex();
|
|
91
|
+
if (collectReceipts) {
|
|
92
|
+
receipts = [];
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// 3. For each writer, collect all patches
|
|
96
|
+
const allPatches = [];
|
|
97
|
+
for (const writerId of writerIds) {
|
|
98
|
+
const writerPatches = await this._loadWriterPatches(writerId);
|
|
99
|
+
for (const p of writerPatches) {
|
|
100
|
+
allPatches.push(p);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 4. If no patches, return empty state
|
|
105
|
+
if (allPatches.length === 0) {
|
|
106
|
+
state = createEmptyStateV5();
|
|
107
|
+
this._provenanceIndex = new ProvenanceIndex();
|
|
108
|
+
if (collectReceipts) {
|
|
109
|
+
receipts = [];
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// 5. Reduce all patches to state
|
|
113
|
+
if (collectReceipts) {
|
|
114
|
+
const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (allPatches), undefined, { receipts: true })); // TODO(ts-cleanup): type patch array
|
|
115
|
+
state = result.state;
|
|
116
|
+
receipts = result.receipts;
|
|
117
|
+
} else {
|
|
118
|
+
state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (allPatches))); // TODO(ts-cleanup): type patch array
|
|
119
|
+
}
|
|
120
|
+
patchCount = allPatches.length;
|
|
121
|
+
|
|
122
|
+
// Build provenance index from all patches
|
|
123
|
+
this._provenanceIndex = new ProvenanceIndex();
|
|
124
|
+
for (const { patch, sha } of allPatches) {
|
|
125
|
+
this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await this._setMaterializedState(state);
|
|
132
|
+
this._provenanceDegraded = false;
|
|
133
|
+
this._cachedCeiling = null;
|
|
134
|
+
this._cachedFrontier = null;
|
|
135
|
+
this._lastFrontier = await this.getFrontier();
|
|
136
|
+
this._patchesSinceCheckpoint = patchCount;
|
|
137
|
+
|
|
138
|
+
// Auto-checkpoint if policy is set and threshold exceeded.
|
|
139
|
+
// Guard prevents recursion: createCheckpoint() calls materialize() internally.
|
|
140
|
+
if (this._checkpointPolicy && !this._checkpointing && patchCount >= this._checkpointPolicy.every) {
|
|
141
|
+
try {
|
|
142
|
+
await this.createCheckpoint();
|
|
143
|
+
this._patchesSinceCheckpoint = 0;
|
|
144
|
+
} catch {
|
|
145
|
+
// Checkpoint failure does not break materialize — continue silently
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this._maybeRunGC(state);
|
|
150
|
+
|
|
151
|
+
// Notify subscribers if state changed since last notification
|
|
152
|
+
// Also handles deferred replay for subscribers added with replay: true before cached state
|
|
153
|
+
if (this._subscribers.length > 0) {
|
|
154
|
+
const hasPendingReplay = this._subscribers.some(s => s.pendingReplay);
|
|
155
|
+
const diff = diffStates(this._lastNotifiedState, state);
|
|
156
|
+
if (!isEmptyDiff(diff) || hasPendingReplay) {
|
|
157
|
+
this._notifySubscribers(diff, state);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Clone state to prevent eager path mutations from affecting the baseline
|
|
161
|
+
this._lastNotifiedState = cloneStateV5(state);
|
|
162
|
+
|
|
163
|
+
this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
|
|
164
|
+
|
|
165
|
+
if (collectReceipts) {
|
|
166
|
+
return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Materializes the graph and returns the materialized graph details.
|
|
177
|
+
*
|
|
178
|
+
* @this {import('../WarpGraph.js').default}
|
|
179
|
+
* @returns {Promise<object>}
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
export async function _materializeGraph() {
|
|
183
|
+
const state = await this.materialize();
|
|
184
|
+
if (!this._materializedGraph || this._materializedGraph.state !== state) {
|
|
185
|
+
await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
|
|
186
|
+
}
|
|
187
|
+
return /** @type {object} */ (this._materializedGraph);
|
|
188
|
+
}
|