@git-stunts/git-warp 10.8.0 → 11.3.3
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 +80 -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/patch.js +142 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +235 -0
- package/bin/cli/commands/registry.js +32 -0
- package/bin/cli/commands/seek.js +598 -0
- package/bin/cli/commands/tree.js +230 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +114 -0
- package/bin/cli/commands/view.js +46 -0
- package/bin/cli/infrastructure.js +350 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +96 -0
- package/bin/presenters/index.js +41 -9
- package/bin/presenters/json.js +14 -12
- package/bin/presenters/text.js +286 -28
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +111 -21
- package/index.js +2 -0
- package/package.json +10 -8
- package/src/domain/WarpGraph.js +109 -3252
- package/src/domain/crdt/ORSet.js +8 -8
- package/src/domain/errors/EmptyMessageError.js +2 -2
- package/src/domain/errors/ForkError.js +1 -1
- package/src/domain/errors/IndexError.js +1 -1
- package/src/domain/errors/OperationAbortedError.js +1 -1
- package/src/domain/errors/QueryError.js +3 -3
- package/src/domain/errors/SchemaUnsupportedError.js +1 -1
- package/src/domain/errors/ShardCorruptionError.js +2 -2
- package/src/domain/errors/ShardLoadError.js +2 -2
- package/src/domain/errors/ShardValidationError.js +4 -4
- package/src/domain/errors/StorageError.js +2 -2
- package/src/domain/errors/SyncError.js +1 -1
- package/src/domain/errors/TraversalError.js +1 -1
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/WarpError.js +2 -2
- package/src/domain/errors/WormholeError.js +1 -1
- 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 +707 -0
- package/src/domain/services/BitmapIndexBuilder.js +3 -3
- package/src/domain/services/BitmapIndexReader.js +28 -19
- package/src/domain/services/BoundaryTransitionRecord.js +18 -17
- package/src/domain/services/CheckpointSerializerV5.js +17 -16
- package/src/domain/services/CheckpointService.js +2 -2
- package/src/domain/services/CommitDagTraversalService.js +13 -13
- package/src/domain/services/DagPathFinding.js +7 -7
- package/src/domain/services/DagTopology.js +1 -1
- package/src/domain/services/DagTraversal.js +1 -1
- package/src/domain/services/HealthCheckService.js +1 -1
- package/src/domain/services/HookInstaller.js +1 -1
- package/src/domain/services/HttpSyncServer.js +120 -55
- package/src/domain/services/IndexRebuildService.js +7 -7
- package/src/domain/services/IndexStalenessChecker.js +4 -3
- package/src/domain/services/JoinReducer.js +11 -11
- package/src/domain/services/LogicalTraversal.js +1 -1
- package/src/domain/services/MessageCodecInternal.js +4 -1
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/MigrationService.js +1 -1
- package/src/domain/services/ObserverView.js +8 -8
- package/src/domain/services/PatchBuilderV2.js +42 -26
- package/src/domain/services/ProvenanceIndex.js +1 -1
- package/src/domain/services/ProvenancePayload.js +1 -1
- package/src/domain/services/QueryBuilder.js +3 -3
- package/src/domain/services/StateDiff.js +14 -11
- package/src/domain/services/StateSerializerV5.js +2 -2
- package/src/domain/services/StreamingBitmapIndexBuilder.js +26 -24
- package/src/domain/services/SyncAuthService.js +71 -4
- package/src/domain/services/SyncProtocol.js +25 -11
- package/src/domain/services/TemporalQuery.js +9 -6
- package/src/domain/services/TranslationCost.js +7 -5
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/services/WormholeService.js +16 -7
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +195 -0
- package/src/domain/trust/TrustRecordService.js +281 -0
- package/src/domain/trust/TrustStateBuilder.js +222 -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/TickReceipt.js +1 -1
- package/src/domain/types/WarpErrors.js +45 -0
- package/src/domain/types/WarpOptions.js +29 -0
- package/src/domain/types/WarpPersistence.js +41 -0
- package/src/domain/types/WarpTypes.js +2 -2
- package/src/domain/types/WarpTypesV2.js +2 -2
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/MinHeap.js +6 -5
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/utils/canonicalStringify.js +5 -4
- package/src/domain/utils/roaring.js +31 -5
- package/src/domain/warp/PatchSession.js +26 -17
- 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 +254 -0
- package/src/domain/warp/checkpoint.methods.js +401 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +238 -0
- package/src/domain/warp/materializeAdvanced.methods.js +350 -0
- package/src/domain/warp/patch.methods.js +554 -0
- package/src/domain/warp/provenance.methods.js +286 -0
- package/src/domain/warp/query.methods.js +280 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +554 -0
- package/src/globals.d.ts +64 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +14 -9
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +9 -4
- package/src/infrastructure/adapters/DenoHttpAdapter.js +5 -6
- package/src/infrastructure/adapters/GitGraphAdapter.js +79 -11
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +2 -2
- package/src/infrastructure/adapters/WebCryptoAdapter.js +2 -2
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/visualization/layouts/converters.js +2 -2
- package/src/visualization/layouts/elkAdapter.js +1 -1
- package/src/visualization/layouts/elkLayout.js +10 -7
- package/src/visualization/layouts/index.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +16 -6
- package/src/visualization/renderers/svg/index.js +1 -1
- package/src/hooks/post-merge.sh +0 -60
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
createPatchV2,
|
|
25
25
|
} from '../types/WarpTypesV2.js';
|
|
26
26
|
import { encodeEdgeKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
|
|
27
|
-
import { encodePatchMessage, decodePatchMessage } from './WarpMessageCodec.js';
|
|
27
|
+
import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
|
|
28
28
|
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
29
29
|
import WriterError from '../errors/WriterError.js';
|
|
30
30
|
|
|
@@ -86,7 +86,7 @@ export class PatchBuilderV2 {
|
|
|
86
86
|
*/
|
|
87
87
|
constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
|
|
88
88
|
/** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */
|
|
89
|
-
this._persistence = /** @type {
|
|
89
|
+
this._persistence = /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */ (persistence);
|
|
90
90
|
|
|
91
91
|
/** @type {string} */
|
|
92
92
|
this._graphName = graphName;
|
|
@@ -346,7 +346,7 @@ export class PatchBuilderV2 {
|
|
|
346
346
|
*
|
|
347
347
|
* @param {string} nodeId - The node ID to set the property on
|
|
348
348
|
* @param {string} key - Property key (should not contain null bytes)
|
|
349
|
-
* @param {
|
|
349
|
+
* @param {unknown} value - Property value. Must be JSON-serializable (strings,
|
|
350
350
|
* numbers, booleans, arrays, plain objects, or null). Use `null` to
|
|
351
351
|
* effectively delete a property (LWW semantics).
|
|
352
352
|
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
@@ -389,7 +389,7 @@ export class PatchBuilderV2 {
|
|
|
389
389
|
* @param {string} to - Target node ID (edge destination)
|
|
390
390
|
* @param {string} label - Edge label/type identifying which edge to modify
|
|
391
391
|
* @param {string} key - Property key (should not contain null bytes)
|
|
392
|
-
* @param {
|
|
392
|
+
* @param {unknown} value - Property value. Must be JSON-serializable (strings,
|
|
393
393
|
* numbers, booleans, arrays, plain objects, or null). Use `null` to
|
|
394
394
|
* effectively delete a property (LWW semantics).
|
|
395
395
|
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
@@ -454,7 +454,7 @@ export class PatchBuilderV2 {
|
|
|
454
454
|
schema,
|
|
455
455
|
writer: this._writerId,
|
|
456
456
|
lamport: this._lamport,
|
|
457
|
-
context:
|
|
457
|
+
context: vvSerialize(this._vv),
|
|
458
458
|
ops: this._ops,
|
|
459
459
|
reads: [...this._reads].sort(),
|
|
460
460
|
writes: [...this._writes].sort(),
|
|
@@ -468,11 +468,12 @@ export class PatchBuilderV2 {
|
|
|
468
468
|
* 1. Validates the patch is non-empty
|
|
469
469
|
* 2. Checks for concurrent modifications (compare-and-swap on writer ref)
|
|
470
470
|
* 3. Calculates the next lamport timestamp from the parent commit
|
|
471
|
-
* 4.
|
|
472
|
-
* 5.
|
|
473
|
-
* 6. Creates a
|
|
474
|
-
* 7.
|
|
475
|
-
* 8.
|
|
471
|
+
* 4. Builds the PatchV2 structure with the resolved lamport
|
|
472
|
+
* 5. Encodes the patch as CBOR and writes it as a Git blob
|
|
473
|
+
* 6. Creates a Git tree containing the patch blob
|
|
474
|
+
* 7. Creates a commit with proper trailers linking to the parent
|
|
475
|
+
* 8. Updates the writer ref to point to the new commit
|
|
476
|
+
* 9. Invokes the success callback if provided (for eager re-materialization)
|
|
476
477
|
*
|
|
477
478
|
* The commit is written to the writer's patch chain at:
|
|
478
479
|
* `refs/warp/<graphName>/writers/<writerId>`
|
|
@@ -524,19 +525,39 @@ export class PatchBuilderV2 {
|
|
|
524
525
|
throw err;
|
|
525
526
|
}
|
|
526
527
|
|
|
527
|
-
// 3. Calculate lamport and parent from current ref state
|
|
528
|
-
|
|
528
|
+
// 3. Calculate lamport and parent from current ref state.
|
|
529
|
+
// Start from this._lamport (set by _nextLamport() in createPatch()), which already
|
|
530
|
+
// incorporates the globally-observed max Lamport tick via _maxObservedLamport.
|
|
531
|
+
// This ensures a first-time writer whose own chain is empty still commits at a tick
|
|
532
|
+
// above any previously-observed writer, winning LWW tiebreakers correctly.
|
|
533
|
+
let lamport = this._lamport;
|
|
529
534
|
let parentCommit = null;
|
|
530
535
|
|
|
531
536
|
if (currentRefSha) {
|
|
532
|
-
// Read the current patch commit to get its lamport timestamp
|
|
533
|
-
const commitMessage = await this._persistence.showNode(currentRefSha);
|
|
534
|
-
const patchInfo = decodePatchMessage(commitMessage);
|
|
535
|
-
lamport = patchInfo.lamport + 1;
|
|
536
537
|
parentCommit = currentRefSha;
|
|
538
|
+
// Read the current patch commit to get its lamport timestamp and take the max,
|
|
539
|
+
// so the chain stays monotonic even if the ref advanced since createPatch().
|
|
540
|
+
const commitMessage = await this._persistence.showNode(currentRefSha);
|
|
541
|
+
const kind = detectMessageKind(commitMessage);
|
|
542
|
+
|
|
543
|
+
if (kind === 'patch') {
|
|
544
|
+
let patchInfo;
|
|
545
|
+
try {
|
|
546
|
+
patchInfo = decodePatchMessage(commitMessage);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`Failed to parse lamport from writer ref ${writerRef}: ` +
|
|
550
|
+
`commit ${currentRefSha} has invalid patch message format`,
|
|
551
|
+
{ cause: err }
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
lamport = Math.max(this._lamport, patchInfo.lamport + 1);
|
|
555
|
+
}
|
|
556
|
+
// Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
|
|
557
|
+
// (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
|
|
537
558
|
}
|
|
538
559
|
|
|
539
|
-
//
|
|
560
|
+
// 4. Build PatchV2 structure with correct lamport
|
|
540
561
|
// Note: Dots were assigned using constructor lamport, but commit lamport may differ.
|
|
541
562
|
// For now, we use the calculated lamport for the patch metadata.
|
|
542
563
|
// The dots themselves are independent of patch lamport (they use VV counters).
|
|
@@ -552,10 +573,8 @@ export class PatchBuilderV2 {
|
|
|
552
573
|
writes: [...this._writes].sort(),
|
|
553
574
|
});
|
|
554
575
|
|
|
555
|
-
//
|
|
576
|
+
// 5. Encode patch as CBOR and write as a Git blob
|
|
556
577
|
const patchCbor = this._codec.encode(patch);
|
|
557
|
-
|
|
558
|
-
// 5. Write patch.cbor blob
|
|
559
578
|
const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
|
|
560
579
|
|
|
561
580
|
// 6. Create tree with the blob
|
|
@@ -563,7 +582,7 @@ export class PatchBuilderV2 {
|
|
|
563
582
|
const treeEntry = `100644 blob ${patchBlobOid}\tpatch.cbor`;
|
|
564
583
|
const treeOid = await this._persistence.writeTree([treeEntry]);
|
|
565
584
|
|
|
566
|
-
// 7. Create
|
|
585
|
+
// 7. Create commit with proper trailers linking to the parent
|
|
567
586
|
const commitMessage = encodePatchMessage({
|
|
568
587
|
graph: this._graphName,
|
|
569
588
|
writer: this._writerId,
|
|
@@ -571,8 +590,6 @@ export class PatchBuilderV2 {
|
|
|
571
590
|
patchOid: patchBlobOid,
|
|
572
591
|
schema,
|
|
573
592
|
});
|
|
574
|
-
|
|
575
|
-
// 8. Create commit with tree, linking to previous patch as parent if exists
|
|
576
593
|
const parents = parentCommit ? [parentCommit] : [];
|
|
577
594
|
const newCommitSha = await this._persistence.commitNodeWithTree({
|
|
578
595
|
treeOid,
|
|
@@ -580,10 +597,10 @@ export class PatchBuilderV2 {
|
|
|
580
597
|
message: commitMessage,
|
|
581
598
|
});
|
|
582
599
|
|
|
583
|
-
//
|
|
600
|
+
// 8. Update writer ref to point to new commit
|
|
584
601
|
await this._persistence.updateRef(writerRef, newCommitSha);
|
|
585
602
|
|
|
586
|
-
//
|
|
603
|
+
// 9. Notify success callback (updates graph's version vector + eager re-materialize)
|
|
587
604
|
if (this._onCommitSuccess) {
|
|
588
605
|
try {
|
|
589
606
|
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
@@ -593,7 +610,6 @@ export class PatchBuilderV2 {
|
|
|
593
610
|
}
|
|
594
611
|
}
|
|
595
612
|
|
|
596
|
-
// 11. Return the new commit SHA
|
|
597
613
|
return newCommitSha;
|
|
598
614
|
}
|
|
599
615
|
|
|
@@ -277,7 +277,7 @@ class ProvenanceIndex {
|
|
|
277
277
|
static deserialize(buffer, { codec } = {}) {
|
|
278
278
|
const c = codec || defaultCodec;
|
|
279
279
|
/** @type {{ version?: number, entries?: Array<[string, string[]]> }} */
|
|
280
|
-
const obj = /** @type {
|
|
280
|
+
const obj = /** @type {{ version?: number, entries?: Array<[string, string[]]> }} */ (c.decode(buffer));
|
|
281
281
|
|
|
282
282
|
if (obj.version !== 1) {
|
|
283
283
|
throw new Error(`Unsupported ProvenanceIndex version: ${obj.version}`);
|
|
@@ -172,7 +172,7 @@ class ProvenancePayload {
|
|
|
172
172
|
// Use JoinReducer's reduceV5 for deterministic materialization.
|
|
173
173
|
// Note: reduceV5 returns { state, receipts } when options.receipts is truthy,
|
|
174
174
|
// but returns bare WarpStateV5 when no options passed (as here).
|
|
175
|
-
return /** @type {import('./JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {
|
|
175
|
+
return /** @type {import('./JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ ([...this.#patches]), initialState));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/**
|
|
@@ -675,7 +675,7 @@ export default class QueryBuilder {
|
|
|
675
675
|
* @throws {QueryError} If an unknown select field is specified (code: E_QUERY_SELECT_FIELD)
|
|
676
676
|
*/
|
|
677
677
|
async run() {
|
|
678
|
-
const materialized = await /** @type {
|
|
678
|
+
const materialized = await /** @type {{ _materializeGraph: () => Promise<{adjacency: AdjacencyMaps, stateHash: string}> }} */ (this._graph)._materializeGraph();
|
|
679
679
|
const { adjacency, stateHash } = materialized;
|
|
680
680
|
const allNodes = sortIds(await this._graph.getNodes());
|
|
681
681
|
|
|
@@ -805,11 +805,11 @@ export default class QueryBuilder {
|
|
|
805
805
|
for (const nodeId of workingSet) {
|
|
806
806
|
const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
|
|
807
807
|
for (const { segments, values } of propsByAgg.values()) {
|
|
808
|
-
/** @type {
|
|
808
|
+
/** @type {unknown} */
|
|
809
809
|
let value = propsMap.get(segments[0]);
|
|
810
810
|
for (let i = 1; i < segments.length; i++) {
|
|
811
811
|
if (value && typeof value === 'object') {
|
|
812
|
-
value = value[segments[i]];
|
|
812
|
+
value = /** @type {Record<string, unknown>} */ (value)[segments[i]];
|
|
813
813
|
} else {
|
|
814
814
|
value = undefined;
|
|
815
815
|
break;
|
|
@@ -24,8 +24,8 @@ import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
|
|
|
24
24
|
* @property {string} key - Encoded property key
|
|
25
25
|
* @property {string} nodeId - Node ID (for node props)
|
|
26
26
|
* @property {string} propKey - Property name
|
|
27
|
-
* @property {
|
|
28
|
-
* @property {
|
|
27
|
+
* @property {unknown} oldValue - Previous value (undefined if new)
|
|
28
|
+
* @property {unknown} newValue - New value
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -33,7 +33,7 @@ import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
|
|
|
33
33
|
* @property {string} key - Encoded property key
|
|
34
34
|
* @property {string} nodeId - Node ID (for node props)
|
|
35
35
|
* @property {string} propKey - Property name
|
|
36
|
-
* @property {
|
|
36
|
+
* @property {unknown} oldValue - Previous value
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -86,8 +86,8 @@ function compareProps(a, b) {
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Checks if two arrays are deeply equal.
|
|
89
|
-
* @param {Array
|
|
90
|
-
* @param {Array
|
|
89
|
+
* @param {Array<unknown>} a
|
|
90
|
+
* @param {Array<unknown>} b
|
|
91
91
|
* @returns {boolean}
|
|
92
92
|
*/
|
|
93
93
|
function arraysEqual(a, b) {
|
|
@@ -104,8 +104,8 @@ function arraysEqual(a, b) {
|
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* Checks if two objects are deeply equal.
|
|
107
|
-
* @param {Record<string,
|
|
108
|
-
* @param {Record<string,
|
|
107
|
+
* @param {Record<string, unknown>} a
|
|
108
|
+
* @param {Record<string, unknown>} b
|
|
109
109
|
* @returns {boolean}
|
|
110
110
|
*/
|
|
111
111
|
function objectsEqual(a, b) {
|
|
@@ -127,8 +127,8 @@ function objectsEqual(a, b) {
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* Checks if two values are deeply equal (for property comparison).
|
|
130
|
-
* @param {
|
|
131
|
-
* @param {
|
|
130
|
+
* @param {unknown} a
|
|
131
|
+
* @param {unknown} b
|
|
132
132
|
* @returns {boolean}
|
|
133
133
|
*/
|
|
134
134
|
function deepEqual(a, b) {
|
|
@@ -148,9 +148,12 @@ function deepEqual(a, b) {
|
|
|
148
148
|
return false;
|
|
149
149
|
}
|
|
150
150
|
if (Array.isArray(a)) {
|
|
151
|
-
return arraysEqual(a, b);
|
|
151
|
+
return arraysEqual(a, /** @type {unknown[]} */ (b));
|
|
152
152
|
}
|
|
153
|
-
return objectsEqual(
|
|
153
|
+
return objectsEqual(
|
|
154
|
+
/** @type {Record<string, unknown>} */ (a),
|
|
155
|
+
/** @type {Record<string, unknown>} */ (b),
|
|
156
|
+
);
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
/**
|
|
@@ -139,11 +139,11 @@ export async function computeStateHashV5(state, { crypto, codec } = /** @type {{
|
|
|
139
139
|
* @param {Buffer} buffer
|
|
140
140
|
* @param {Object} [options]
|
|
141
141
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
|
|
142
|
-
* @returns {{nodes: string[], edges: Array<{from: string, to: string, label: string}>, props: Array<{node: string, key: string, value:
|
|
142
|
+
* @returns {{nodes: string[], edges: Array<{from: string, to: string, label: string}>, props: Array<{node: string, key: string, value: unknown}>}}
|
|
143
143
|
*/
|
|
144
144
|
export function deserializeStateV5(buffer, { codec } = {}) {
|
|
145
145
|
const c = codec || defaultCodec;
|
|
146
|
-
return /** @type {{nodes: string[], edges: Array<{from: string, to: string, label: string}>, props: Array<{node: string, key: string, value:
|
|
146
|
+
return /** @type {{nodes: string[], edges: Array<{from: string, to: string, label: string}>, props: Array<{node: string, key: string, value: unknown}>}} */ (c.decode(buffer));
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// ============================================================================
|
|
@@ -8,6 +8,8 @@ import { getRoaringBitmap32 } from '../utils/roaring.js';
|
|
|
8
8
|
import { canonicalStringify } from '../utils/canonicalStringify.js';
|
|
9
9
|
import { SHARD_VERSION } from '../utils/shardVersion.js';
|
|
10
10
|
|
|
11
|
+
/** @typedef {import('../types/WarpPersistence.js').IndexStorage} IndexStorage */
|
|
12
|
+
|
|
11
13
|
// Re-export for backwards compatibility
|
|
12
14
|
export { SHARD_VERSION };
|
|
13
15
|
|
|
@@ -81,7 +83,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
81
83
|
* Creates a new StreamingBitmapIndexBuilder instance.
|
|
82
84
|
*
|
|
83
85
|
* @param {Object} options - Configuration options
|
|
84
|
-
* @param {
|
|
86
|
+
* @param {import('../../ports/IndexStoragePort.js').default} options.storage - Storage adapter implementing IndexStoragePort.
|
|
85
87
|
* Required methods: writeBlob, writeTree, readBlob
|
|
86
88
|
* @param {number} [options.maxMemoryBytes=52428800] - Maximum bitmap memory before flush (default 50MB).
|
|
87
89
|
* Note: SHA→ID mappings are not counted against this limit as they must remain in memory.
|
|
@@ -106,8 +108,8 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
106
108
|
/** @type {import('../../ports/CodecPort.js').default} */
|
|
107
109
|
this._codec = codec || defaultCodec;
|
|
108
110
|
|
|
109
|
-
/** @type {
|
|
110
|
-
this.storage = storage;
|
|
111
|
+
/** @type {IndexStorage} */
|
|
112
|
+
this.storage = /** @type {IndexStorage} */ (storage);
|
|
111
113
|
|
|
112
114
|
/** @type {number} */
|
|
113
115
|
this.maxMemoryBytes = maxMemoryBytes;
|
|
@@ -124,7 +126,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
124
126
|
/** @type {string[]} ID → SHA reverse mapping (kept in memory) */
|
|
125
127
|
this.idToSha = [];
|
|
126
128
|
|
|
127
|
-
/** @type {Map<string,
|
|
129
|
+
/** @type {Map<string, import('../utils/roaring.js').RoaringBitmapSubset>} Current in-memory bitmaps */
|
|
128
130
|
this.bitmaps = new Map();
|
|
129
131
|
|
|
130
132
|
/** @type {number} Estimated bytes used by current bitmaps */
|
|
@@ -139,8 +141,8 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
139
141
|
/** @type {number} Number of flush operations performed */
|
|
140
142
|
this.flushCount = 0;
|
|
141
143
|
|
|
142
|
-
/** @type {
|
|
143
|
-
this._RoaringBitmap32 = getRoaringBitmap32();
|
|
144
|
+
/** @type {typeof import('roaring').RoaringBitmap32} Cached Roaring bitmap constructor */
|
|
145
|
+
this._RoaringBitmap32 = getRoaringBitmap32();
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
/**
|
|
@@ -206,7 +208,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
206
208
|
if (!bitmapShards[type][prefix]) {
|
|
207
209
|
bitmapShards[type][prefix] = {};
|
|
208
210
|
}
|
|
209
|
-
bitmapShards[type][prefix][sha] = bitmap.serialize(true).toString('base64');
|
|
211
|
+
bitmapShards[type][prefix][sha] = Buffer.from(bitmap.serialize(true)).toString('base64');
|
|
210
212
|
}
|
|
211
213
|
return bitmapShards;
|
|
212
214
|
}
|
|
@@ -238,7 +240,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
238
240
|
data: shardData,
|
|
239
241
|
};
|
|
240
242
|
const buffer = Buffer.from(JSON.stringify(envelope));
|
|
241
|
-
const oid = await
|
|
243
|
+
const oid = await this.storage.writeBlob(buffer);
|
|
242
244
|
if (!this.flushedChunks.has(path)) {
|
|
243
245
|
this.flushedChunks.set(path, []);
|
|
244
246
|
}
|
|
@@ -348,7 +350,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
348
350
|
data: map,
|
|
349
351
|
};
|
|
350
352
|
const buffer = Buffer.from(JSON.stringify(envelope));
|
|
351
|
-
const oid = await
|
|
353
|
+
const oid = await this.storage.writeBlob(buffer);
|
|
352
354
|
return `100644 blob ${oid}\t${path}`;
|
|
353
355
|
})
|
|
354
356
|
);
|
|
@@ -410,8 +412,8 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
410
412
|
* @param {Object} [options] - Finalization options
|
|
411
413
|
* @param {AbortSignal} [options.signal] - Optional AbortSignal for cancellation.
|
|
412
414
|
* If aborted, throws an error with code 'ABORT_ERR'.
|
|
413
|
-
* @param {Map<string,
|
|
414
|
-
* (writerId →
|
|
415
|
+
* @param {Map<string, string>} [options.frontier] - Optional writer frontier
|
|
416
|
+
* (writerId → tip SHA) for staleness detection. If provided, frontier.cbor and
|
|
415
417
|
* frontier.json files are included in the tree.
|
|
416
418
|
* @returns {Promise<string>} OID of the created Git tree containing the complete index
|
|
417
419
|
* @throws {Error} If the operation is aborted via signal
|
|
@@ -440,19 +442,19 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
440
442
|
|
|
441
443
|
// Store frontier metadata for staleness detection
|
|
442
444
|
if (frontier) {
|
|
443
|
-
/** @type {Record<string,
|
|
445
|
+
/** @type {Record<string, string|undefined>} */
|
|
444
446
|
const sorted = {};
|
|
445
447
|
for (const key of Array.from(frontier.keys()).sort()) {
|
|
446
448
|
sorted[key] = frontier.get(key);
|
|
447
449
|
}
|
|
448
450
|
const envelope = { version: 1, writerCount: frontier.size, frontier: sorted };
|
|
449
|
-
const cborOid = await
|
|
451
|
+
const cborOid = await this.storage.writeBlob(Buffer.from(this._codec.encode(envelope)));
|
|
450
452
|
flatEntries.push(`100644 blob ${cborOid}\tfrontier.cbor`);
|
|
451
|
-
const jsonOid = await
|
|
453
|
+
const jsonOid = await this.storage.writeBlob(Buffer.from(canonicalStringify(envelope)));
|
|
452
454
|
flatEntries.push(`100644 blob ${jsonOid}\tfrontier.json`);
|
|
453
455
|
}
|
|
454
456
|
|
|
455
|
-
const treeOid = await
|
|
457
|
+
const treeOid = await this.storage.writeTree(flatEntries);
|
|
456
458
|
|
|
457
459
|
this.logger.debug('Index finalized', {
|
|
458
460
|
operation: 'finalize',
|
|
@@ -539,7 +541,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
539
541
|
this.estimatedBitmapBytes += BITMAP_BASE_OVERHEAD;
|
|
540
542
|
}
|
|
541
543
|
|
|
542
|
-
const bitmap = this.bitmaps.get(key);
|
|
544
|
+
const bitmap = /** @type {import('../utils/roaring.js').RoaringBitmapSubset} */ (this.bitmaps.get(key));
|
|
543
545
|
const sizeBefore = bitmap.size;
|
|
544
546
|
bitmap.add(id);
|
|
545
547
|
const sizeAfter = bitmap.size;
|
|
@@ -569,7 +571,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
569
571
|
* @private
|
|
570
572
|
*/
|
|
571
573
|
async _loadAndValidateChunk(oid) {
|
|
572
|
-
const buffer = await
|
|
574
|
+
const buffer = await this.storage.readBlob(oid);
|
|
573
575
|
let envelope;
|
|
574
576
|
try {
|
|
575
577
|
envelope = JSON.parse(buffer.toString('utf-8'));
|
|
@@ -577,7 +579,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
577
579
|
throw new ShardCorruptionError('Failed to parse shard JSON', {
|
|
578
580
|
oid,
|
|
579
581
|
reason: 'invalid_format',
|
|
580
|
-
context: { originalError:
|
|
582
|
+
context: { originalError: err instanceof Error ? err.message : String(err) },
|
|
581
583
|
});
|
|
582
584
|
}
|
|
583
585
|
|
|
@@ -614,7 +616,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
614
616
|
* it using `orInPlace` to combine edge sets.
|
|
615
617
|
*
|
|
616
618
|
* @param {Object} opts - Options object
|
|
617
|
-
* @param {Record<string,
|
|
619
|
+
* @param {Record<string, import('../utils/roaring.js').RoaringBitmapSubset>} opts.merged - Object mapping SHA to
|
|
618
620
|
* RoaringBitmap32 instances (mutated in place)
|
|
619
621
|
* @param {string} opts.sha - The SHA key for this bitmap (40-character hex string)
|
|
620
622
|
* @param {string} opts.base64Bitmap - Base64-encoded serialized RoaringBitmap32 data
|
|
@@ -631,7 +633,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
631
633
|
throw new ShardCorruptionError('Failed to deserialize bitmap', {
|
|
632
634
|
oid,
|
|
633
635
|
reason: 'invalid_bitmap',
|
|
634
|
-
context: { originalError:
|
|
636
|
+
context: { originalError: err instanceof Error ? err.message : String(err) },
|
|
635
637
|
});
|
|
636
638
|
}
|
|
637
639
|
|
|
@@ -675,7 +677,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
675
677
|
*/
|
|
676
678
|
async _mergeChunks(oids, { signal } = {}) {
|
|
677
679
|
// Load all chunks and merge bitmaps by SHA
|
|
678
|
-
/** @type {Record<string,
|
|
680
|
+
/** @type {Record<string, import('../utils/roaring.js').RoaringBitmapSubset>} */
|
|
679
681
|
const merged = {};
|
|
680
682
|
|
|
681
683
|
for (const oid of oids) {
|
|
@@ -691,7 +693,7 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
691
693
|
/** @type {Record<string, string>} */
|
|
692
694
|
const result = {};
|
|
693
695
|
for (const [sha, bitmap] of Object.entries(merged)) {
|
|
694
|
-
result[sha] = bitmap.serialize(true).toString('base64');
|
|
696
|
+
result[sha] = Buffer.from(bitmap.serialize(true)).toString('base64');
|
|
695
697
|
}
|
|
696
698
|
|
|
697
699
|
// Wrap merged result in envelope with version and checksum
|
|
@@ -707,9 +709,9 @@ export default class StreamingBitmapIndexBuilder {
|
|
|
707
709
|
} catch (err) {
|
|
708
710
|
throw new ShardCorruptionError('Failed to serialize merged shard', {
|
|
709
711
|
reason: 'serialization_error',
|
|
710
|
-
context: { originalError:
|
|
712
|
+
context: { originalError: err instanceof Error ? err.message : String(err) },
|
|
711
713
|
});
|
|
712
714
|
}
|
|
713
|
-
return
|
|
715
|
+
return await this.storage.writeBlob(serialized);
|
|
714
716
|
}
|
|
715
717
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import LRUCache from '../utils/LRUCache.js';
|
|
13
13
|
import defaultCrypto from '../utils/defaultCrypto.js';
|
|
14
14
|
import nullLogger from '../utils/nullLogger.js';
|
|
15
|
+
import { validateWriterId } from '../utils/RefLayout.js';
|
|
15
16
|
|
|
16
17
|
const SIG_VERSION = '1';
|
|
17
18
|
const SIG_PREFIX = 'warp-v1';
|
|
@@ -103,7 +104,7 @@ function fail(reason, status) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
107
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number, forbiddenWriterRejects: number }}
|
|
107
108
|
*/
|
|
108
109
|
function _freshMetrics() {
|
|
109
110
|
return {
|
|
@@ -113,6 +114,7 @@ function _freshMetrics() {
|
|
|
113
114
|
clockSkewRejects: 0,
|
|
114
115
|
malformedRejects: 0,
|
|
115
116
|
logOnlyPassthroughs: 0,
|
|
117
|
+
forbiddenWriterRejects: 0,
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -142,6 +144,7 @@ function _checkHeaderFormats(timestamp, nonce, signature) {
|
|
|
142
144
|
|
|
143
145
|
/**
|
|
144
146
|
* @param {Record<string, string>|undefined} keys
|
|
147
|
+
* @returns {asserts keys is Record<string, string>}
|
|
145
148
|
*/
|
|
146
149
|
function _validateKeys(keys) {
|
|
147
150
|
if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) {
|
|
@@ -149,6 +152,23 @@ function _validateKeys(keys) {
|
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/**
|
|
156
|
+
* @param {string[]|undefined} allowedWriters
|
|
157
|
+
* @returns {Set<string>|null}
|
|
158
|
+
*/
|
|
159
|
+
function _validateAllowedWriters(allowedWriters) {
|
|
160
|
+
if (!allowedWriters) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (allowedWriters.length === 0) {
|
|
164
|
+
throw new Error('allowedWriters must be a non-empty array when provided');
|
|
165
|
+
}
|
|
166
|
+
for (const w of allowedWriters) {
|
|
167
|
+
validateWriterId(w);
|
|
168
|
+
}
|
|
169
|
+
return new Set(allowedWriters);
|
|
170
|
+
}
|
|
171
|
+
|
|
152
172
|
export default class SyncAuthService {
|
|
153
173
|
/**
|
|
154
174
|
* @param {Object} options
|
|
@@ -159,8 +179,9 @@ export default class SyncAuthService {
|
|
|
159
179
|
* @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
|
|
160
180
|
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
161
181
|
* @param {() => number} [options.wallClockMs] - Wall clock function
|
|
182
|
+
* @param {string[]} [options.allowedWriters] - Optional whitelist of allowed writer IDs. If set, sync requests with unlisted writers are rejected with 403.
|
|
162
183
|
*/
|
|
163
|
-
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {
|
|
184
|
+
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs, allowedWriters } = /** @type {{ keys: Record<string, string> }} */ ({})) {
|
|
164
185
|
_validateKeys(keys);
|
|
165
186
|
this._keys = keys;
|
|
166
187
|
this._mode = mode;
|
|
@@ -170,6 +191,7 @@ export default class SyncAuthService {
|
|
|
170
191
|
this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
|
|
171
192
|
this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
|
|
172
193
|
this._metrics = _freshMetrics();
|
|
194
|
+
this._allowedWriters = _validateAllowedWriters(allowedWriters);
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
/** @returns {'enforce'|'log-only'} */
|
|
@@ -364,10 +386,55 @@ export default class SyncAuthService {
|
|
|
364
386
|
return { ok: true };
|
|
365
387
|
}
|
|
366
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Validates that all writer IDs are in the allowed set.
|
|
391
|
+
* Call after verify() succeeds.
|
|
392
|
+
*
|
|
393
|
+
* This method is a pure validator — it always returns `{ ok: false }` for
|
|
394
|
+
* forbidden writers regardless of `this._mode`. Mode enforcement (enforce
|
|
395
|
+
* vs log-only) is the caller's responsibility, matching the same pattern
|
|
396
|
+
* used by `verify()` and `HttpSyncServer._checkAuth()`.
|
|
397
|
+
*
|
|
398
|
+
* @param {string[]} writerIds - Writer IDs from the sync request
|
|
399
|
+
* @returns {{ ok: true } | { ok: false, reason: string, status: number }}
|
|
400
|
+
*/
|
|
401
|
+
verifyWriters(writerIds) {
|
|
402
|
+
if (!this._allowedWriters) {
|
|
403
|
+
return { ok: true };
|
|
404
|
+
}
|
|
405
|
+
const forbidden = writerIds.filter(id => !/** @type {Set<string>} */ (this._allowedWriters).has(id));
|
|
406
|
+
if (forbidden.length > 0) {
|
|
407
|
+
this._metrics.forbiddenWriterRejects += 1;
|
|
408
|
+
this._logger.warn('sync auth: forbidden writers rejected', { forbidden });
|
|
409
|
+
return fail('FORBIDDEN_WRITER', 403);
|
|
410
|
+
}
|
|
411
|
+
return { ok: true };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Mode-aware convenience wrapper around `verifyWriters()`.
|
|
416
|
+
*
|
|
417
|
+
* In `enforce` mode, returns the failure result from `verifyWriters()`.
|
|
418
|
+
* In `log-only` mode, records a passthrough and returns `{ ok: true }`.
|
|
419
|
+
* Callers that want simple single-call authorization can use this instead
|
|
420
|
+
* of calling `verifyWriters()` + checking mode manually.
|
|
421
|
+
*
|
|
422
|
+
* @param {string[]} writerIds - Writer IDs from the sync request
|
|
423
|
+
* @returns {{ ok: true } | { ok: false, reason: string, status: number }}
|
|
424
|
+
*/
|
|
425
|
+
enforceWriters(writerIds) {
|
|
426
|
+
const result = this.verifyWriters(writerIds);
|
|
427
|
+
if (!result.ok && this._mode !== 'enforce') {
|
|
428
|
+
this._metrics.logOnlyPassthroughs += 1;
|
|
429
|
+
return { ok: true };
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
|
|
367
434
|
/**
|
|
368
435
|
* Records an auth failure and returns the result.
|
|
369
436
|
* @param {string} message
|
|
370
|
-
* @param {Record<string,
|
|
437
|
+
* @param {Record<string, unknown>} context
|
|
371
438
|
* @param {{ ok: false, reason: string, status: number }} result
|
|
372
439
|
* @returns {{ ok: false, reason: string, status: number }}
|
|
373
440
|
* @private
|
|
@@ -388,7 +455,7 @@ export default class SyncAuthService {
|
|
|
388
455
|
/**
|
|
389
456
|
* Returns a snapshot of auth metrics.
|
|
390
457
|
*
|
|
391
|
-
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
458
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number, forbiddenWriterRejects: number }}
|
|
392
459
|
*/
|
|
393
460
|
getMetrics() {
|
|
394
461
|
return { ...this._metrics };
|