@git-stunts/git-warp 10.4.2 → 10.7.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/SECURITY.md +89 -1
- package/bin/warp-graph.js +205 -69
- package/index.d.ts +24 -0
- package/package.json +1 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
package/SECURITY.md
CHANGED
|
@@ -25,6 +25,94 @@ The `GitGraphAdapter` validates all ref arguments to prevent injection attacks:
|
|
|
25
25
|
- **Bitmap Indexing**: Sharded Roaring Bitmap indexes enable O(1) lookups without loading entire graphs
|
|
26
26
|
- **Delimiter Safety**: Uses ASCII Record Separator (`\x1E`) to prevent message collision
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Sync Authentication (SHIELD)
|
|
29
|
+
|
|
30
|
+
### Overview
|
|
31
|
+
|
|
32
|
+
The HTTP sync protocol supports optional HMAC-SHA256 request signing with replay protection. When enabled, every sync request must carry a valid signature computed over a canonical payload that includes the request body, timestamp, and a unique nonce.
|
|
33
|
+
|
|
34
|
+
### Threat Model
|
|
35
|
+
|
|
36
|
+
**Protected against:**
|
|
37
|
+
- Unauthorized sync requests from unknown peers
|
|
38
|
+
- Replay attacks (nonce-based, with 5-minute TTL window)
|
|
39
|
+
- Request body tampering (HMAC covers body SHA-256)
|
|
40
|
+
- Timing attacks on signature comparison (`timingSafeEqual`)
|
|
41
|
+
|
|
42
|
+
**Not protected against:**
|
|
43
|
+
- Compromised shared secrets (rotate keys immediately if leaked)
|
|
44
|
+
- Denial-of-service (body size limits provide basic protection, but no rate limiting)
|
|
45
|
+
- Man-in-the-middle without TLS (use HTTPS in production)
|
|
46
|
+
|
|
47
|
+
### Authentication Flow
|
|
48
|
+
|
|
49
|
+
1. Client computes SHA-256 of request body
|
|
50
|
+
2. Client builds canonical payload: `warp-v1|KEY_ID|METHOD|PATH|TIMESTAMP|NONCE|CONTENT_TYPE|BODY_SHA256`
|
|
51
|
+
3. Client computes HMAC-SHA256 of canonical payload using shared secret
|
|
52
|
+
4. Client sends 5 auth headers: `x-warp-sig-version`, `x-warp-key-id`, `x-warp-timestamp`, `x-warp-nonce`, `x-warp-signature`
|
|
53
|
+
5. Server validates header formats (cheap checks first)
|
|
54
|
+
6. Server checks clock skew (default: 5 minutes)
|
|
55
|
+
7. Server reserves nonce atomically (prevents replay)
|
|
56
|
+
8. Server resolves key by key-id
|
|
57
|
+
9. Server recomputes HMAC and compares with constant-time equality
|
|
58
|
+
|
|
59
|
+
### Enforcement Modes
|
|
60
|
+
|
|
61
|
+
- **`enforce`** (default): Rejects requests that fail authentication with appropriate HTTP status codes (400/401/403). No request details leak in error responses.
|
|
62
|
+
- **`log-only`**: Logs authentication failures but allows requests through. Use during rollout to identify issues before enforcing.
|
|
63
|
+
|
|
64
|
+
### Error Response Hygiene
|
|
65
|
+
|
|
66
|
+
External error responses use coarse status codes and generic reason strings:
|
|
67
|
+
- `400` — Malformed headers (version, timestamp, nonce, signature format)
|
|
68
|
+
- `401` — Missing auth headers, unknown key-id, invalid signature
|
|
69
|
+
- `403` — Expired timestamp, replayed nonce
|
|
70
|
+
|
|
71
|
+
Detailed diagnostics (exact failure reason, key-id, peer info) are sent to the structured logger only.
|
|
72
|
+
|
|
73
|
+
### Nonce Cache and Restart Semantics
|
|
74
|
+
|
|
75
|
+
The nonce cache is an in-memory LRU (default capacity: 100,000 entries). On server restart, the cache is empty. This means:
|
|
76
|
+
- Nonces from before the restart can be replayed within the 5-minute clock skew window
|
|
77
|
+
- This is an accepted trade-off for simplicity; persistent nonce storage is not implemented in v1
|
|
78
|
+
- For higher security, keep the clock skew window small and use TLS
|
|
79
|
+
|
|
80
|
+
### Key Rotation
|
|
81
|
+
|
|
82
|
+
Key management uses a key-id system for zero-downtime rotation:
|
|
83
|
+
|
|
84
|
+
1. Add the new key-id and secret to the server's `keys` map
|
|
85
|
+
2. Deploy the server
|
|
86
|
+
3. Update clients to use the new key-id
|
|
87
|
+
4. Remove the old key-id from the server's `keys` map
|
|
88
|
+
5. Deploy again
|
|
89
|
+
|
|
90
|
+
Multiple key-ids can coexist indefinitely.
|
|
91
|
+
|
|
92
|
+
### Configuration
|
|
93
|
+
|
|
94
|
+
**Server (`serve()`):**
|
|
95
|
+
```js
|
|
96
|
+
await graph.serve({
|
|
97
|
+
port: 3000,
|
|
98
|
+
httpPort: new NodeHttpAdapter(),
|
|
99
|
+
auth: {
|
|
100
|
+
keys: { default: 'your-shared-secret' },
|
|
101
|
+
mode: 'enforce', // or 'log-only'
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Client (`syncWith()`):**
|
|
107
|
+
```js
|
|
108
|
+
await graph.syncWith('http://peer:3000', {
|
|
109
|
+
auth: {
|
|
110
|
+
secret: 'your-shared-secret',
|
|
111
|
+
keyId: 'default',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Reporting a Vulnerability
|
|
29
117
|
|
|
30
118
|
If you discover a security vulnerability, please send an e-mail to [james@flyingrobots.dev](mailto:james@flyingrobots.dev).
|
package/bin/warp-graph.js
CHANGED
|
@@ -31,7 +31,8 @@ import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/
|
|
|
31
31
|
import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
|
|
32
32
|
import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
|
|
33
33
|
import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
|
|
34
|
-
import {
|
|
34
|
+
import { diffStates } from '../src/domain/services/StateDiff.js';
|
|
35
|
+
import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
|
|
35
36
|
import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
|
|
36
37
|
import { renderSvg } from '../src/visualization/renderers/svg/index.js';
|
|
37
38
|
import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
|
|
@@ -63,6 +64,7 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
|
|
|
63
64
|
* @property {() => Promise<Map<string, any>>} getFrontier
|
|
64
65
|
* @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
|
|
65
66
|
* @property {() => Promise<number>} getPropertyCount
|
|
67
|
+
* @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
|
|
66
68
|
* @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
|
|
67
69
|
* @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
|
|
68
70
|
* @property {(cache: any) => void} setSeekCache
|
|
@@ -113,6 +115,8 @@ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../s
|
|
|
113
115
|
* @property {string|null} tickValue
|
|
114
116
|
* @property {string|null} name
|
|
115
117
|
* @property {boolean} noPersistentCache
|
|
118
|
+
* @property {boolean} diff
|
|
119
|
+
* @property {number} diffLimit
|
|
116
120
|
*/
|
|
117
121
|
|
|
118
122
|
const EXIT_CODES = {
|
|
@@ -171,6 +175,8 @@ Seek options:
|
|
|
171
175
|
--load <name> Restore a saved cursor
|
|
172
176
|
--list List all saved cursors
|
|
173
177
|
--drop <name> Delete a saved cursor
|
|
178
|
+
--diff Show structural diff (added/removed nodes, edges, props)
|
|
179
|
+
--diff-limit <N> Max diff entries (default 2000)
|
|
174
180
|
`;
|
|
175
181
|
|
|
176
182
|
/**
|
|
@@ -1953,9 +1959,64 @@ function handleSeekBooleanFlag(arg, spec) {
|
|
|
1953
1959
|
spec.action = 'clear-cache';
|
|
1954
1960
|
} else if (arg === '--no-persistent-cache') {
|
|
1955
1961
|
spec.noPersistentCache = true;
|
|
1962
|
+
} else if (arg === '--diff') {
|
|
1963
|
+
spec.diff = true;
|
|
1956
1964
|
}
|
|
1957
1965
|
}
|
|
1958
1966
|
|
|
1967
|
+
/**
|
|
1968
|
+
* Parses --diff-limit / --diff-limit=N into the seek spec.
|
|
1969
|
+
* @param {string} arg
|
|
1970
|
+
* @param {string[]} args
|
|
1971
|
+
* @param {number} i
|
|
1972
|
+
* @param {SeekSpec} spec
|
|
1973
|
+
*/
|
|
1974
|
+
function handleDiffLimitFlag(arg, args, i, spec) {
|
|
1975
|
+
let raw;
|
|
1976
|
+
if (arg.startsWith('--diff-limit=')) {
|
|
1977
|
+
raw = arg.slice('--diff-limit='.length);
|
|
1978
|
+
} else {
|
|
1979
|
+
raw = args[i + 1];
|
|
1980
|
+
if (raw === undefined) {
|
|
1981
|
+
throw usageError('Missing value for --diff-limit');
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
const n = Number(raw);
|
|
1985
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
|
1986
|
+
throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
|
|
1987
|
+
}
|
|
1988
|
+
spec.diffLimit = n;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Parses a named action flag (--save, --load, --drop) with its value.
|
|
1993
|
+
* @param {string} flagName - e.g. 'save'
|
|
1994
|
+
* @param {string} arg - Current arg token
|
|
1995
|
+
* @param {string[]} args - All args
|
|
1996
|
+
* @param {number} i - Current index
|
|
1997
|
+
* @param {SeekSpec} spec
|
|
1998
|
+
* @returns {number} Number of extra args consumed (0 or 1)
|
|
1999
|
+
*/
|
|
2000
|
+
function parseSeekNamedAction(flagName, arg, args, i, spec) {
|
|
2001
|
+
if (spec.action !== 'status') {
|
|
2002
|
+
throw usageError(`--${flagName} cannot be combined with other seek flags`);
|
|
2003
|
+
}
|
|
2004
|
+
spec.action = flagName;
|
|
2005
|
+
if (arg === `--${flagName}`) {
|
|
2006
|
+
const val = args[i + 1];
|
|
2007
|
+
if (val === undefined || val.startsWith('-')) {
|
|
2008
|
+
throw usageError(`Missing name for --${flagName}`);
|
|
2009
|
+
}
|
|
2010
|
+
spec.name = val;
|
|
2011
|
+
return 1;
|
|
2012
|
+
}
|
|
2013
|
+
spec.name = arg.slice(`--${flagName}=`.length);
|
|
2014
|
+
if (!spec.name) {
|
|
2015
|
+
throw usageError(`Missing name for --${flagName}`);
|
|
2016
|
+
}
|
|
2017
|
+
return 0;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1959
2020
|
/**
|
|
1960
2021
|
* Parses CLI arguments for the `seek` command into a structured spec.
|
|
1961
2022
|
* @param {string[]} args - Raw CLI arguments following the `seek` subcommand
|
|
@@ -1968,7 +2029,10 @@ function parseSeekArgs(args) {
|
|
|
1968
2029
|
tickValue: null,
|
|
1969
2030
|
name: null,
|
|
1970
2031
|
noPersistentCache: false,
|
|
2032
|
+
diff: false,
|
|
2033
|
+
diffLimit: 2000,
|
|
1971
2034
|
};
|
|
2035
|
+
let diffLimitProvided = false;
|
|
1972
2036
|
|
|
1973
2037
|
for (let i = 0; i < args.length; i++) {
|
|
1974
2038
|
const arg = args[i];
|
|
@@ -1995,78 +2059,39 @@ function parseSeekArgs(args) {
|
|
|
1995
2059
|
throw usageError('--latest cannot be combined with other seek flags');
|
|
1996
2060
|
}
|
|
1997
2061
|
spec.action = 'latest';
|
|
1998
|
-
} else if (arg === '--save') {
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
spec.action = 'save';
|
|
2003
|
-
const val = args[i + 1];
|
|
2004
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2005
|
-
throw usageError('Missing name for --save');
|
|
2006
|
-
}
|
|
2007
|
-
spec.name = val;
|
|
2008
|
-
i += 1;
|
|
2009
|
-
} else if (arg.startsWith('--save=')) {
|
|
2010
|
-
if (spec.action !== 'status') {
|
|
2011
|
-
throw usageError('--save cannot be combined with other seek flags');
|
|
2012
|
-
}
|
|
2013
|
-
spec.action = 'save';
|
|
2014
|
-
spec.name = arg.slice('--save='.length);
|
|
2015
|
-
if (!spec.name) {
|
|
2016
|
-
throw usageError('Missing name for --save');
|
|
2017
|
-
}
|
|
2018
|
-
} else if (arg === '--load') {
|
|
2019
|
-
if (spec.action !== 'status') {
|
|
2020
|
-
throw usageError('--load cannot be combined with other seek flags');
|
|
2021
|
-
}
|
|
2022
|
-
spec.action = 'load';
|
|
2023
|
-
const val = args[i + 1];
|
|
2024
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2025
|
-
throw usageError('Missing name for --load');
|
|
2026
|
-
}
|
|
2027
|
-
spec.name = val;
|
|
2028
|
-
i += 1;
|
|
2029
|
-
} else if (arg.startsWith('--load=')) {
|
|
2030
|
-
if (spec.action !== 'status') {
|
|
2031
|
-
throw usageError('--load cannot be combined with other seek flags');
|
|
2032
|
-
}
|
|
2033
|
-
spec.action = 'load';
|
|
2034
|
-
spec.name = arg.slice('--load='.length);
|
|
2035
|
-
if (!spec.name) {
|
|
2036
|
-
throw usageError('Missing name for --load');
|
|
2037
|
-
}
|
|
2062
|
+
} else if (arg === '--save' || arg.startsWith('--save=')) {
|
|
2063
|
+
i += parseSeekNamedAction('save', arg, args, i, spec);
|
|
2064
|
+
} else if (arg === '--load' || arg.startsWith('--load=')) {
|
|
2065
|
+
i += parseSeekNamedAction('load', arg, args, i, spec);
|
|
2038
2066
|
} else if (arg === '--list') {
|
|
2039
2067
|
if (spec.action !== 'status') {
|
|
2040
2068
|
throw usageError('--list cannot be combined with other seek flags');
|
|
2041
2069
|
}
|
|
2042
2070
|
spec.action = 'list';
|
|
2043
|
-
} else if (arg === '--drop') {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
}
|
|
2047
|
-
spec.action = 'drop';
|
|
2048
|
-
const val = args[i + 1];
|
|
2049
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2050
|
-
throw usageError('Missing name for --drop');
|
|
2051
|
-
}
|
|
2052
|
-
spec.name = val;
|
|
2053
|
-
i += 1;
|
|
2054
|
-
} else if (arg.startsWith('--drop=')) {
|
|
2055
|
-
if (spec.action !== 'status') {
|
|
2056
|
-
throw usageError('--drop cannot be combined with other seek flags');
|
|
2057
|
-
}
|
|
2058
|
-
spec.action = 'drop';
|
|
2059
|
-
spec.name = arg.slice('--drop='.length);
|
|
2060
|
-
if (!spec.name) {
|
|
2061
|
-
throw usageError('Missing name for --drop');
|
|
2062
|
-
}
|
|
2063
|
-
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache') {
|
|
2071
|
+
} else if (arg === '--drop' || arg.startsWith('--drop=')) {
|
|
2072
|
+
i += parseSeekNamedAction('drop', arg, args, i, spec);
|
|
2073
|
+
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
|
|
2064
2074
|
handleSeekBooleanFlag(arg, spec);
|
|
2075
|
+
} else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
|
|
2076
|
+
handleDiffLimitFlag(arg, args, i, spec);
|
|
2077
|
+
diffLimitProvided = true;
|
|
2078
|
+
if (arg === '--diff-limit') {
|
|
2079
|
+
i += 1;
|
|
2080
|
+
}
|
|
2065
2081
|
} else if (arg.startsWith('-')) {
|
|
2066
2082
|
throw usageError(`Unknown seek option: ${arg}`);
|
|
2067
2083
|
}
|
|
2068
2084
|
}
|
|
2069
2085
|
|
|
2086
|
+
// --diff is only meaningful for actions that navigate to a tick
|
|
2087
|
+
const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
|
|
2088
|
+
if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
|
|
2089
|
+
throw usageError(`--diff cannot be used with --${spec.action}`);
|
|
2090
|
+
}
|
|
2091
|
+
if (diffLimitProvided && !spec.diff) {
|
|
2092
|
+
throw usageError('--diff-limit requires --diff');
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2070
2095
|
return spec;
|
|
2071
2096
|
}
|
|
2072
2097
|
|
|
@@ -2189,8 +2214,16 @@ async function handleSeek({ options, args }) {
|
|
|
2189
2214
|
};
|
|
2190
2215
|
}
|
|
2191
2216
|
if (seekSpec.action === 'latest') {
|
|
2217
|
+
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
2218
|
+
let sdResult = null;
|
|
2219
|
+
if (seekSpec.diff) {
|
|
2220
|
+
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
|
|
2221
|
+
}
|
|
2192
2222
|
await clearActiveCursor(persistence, graphName);
|
|
2193
|
-
|
|
2223
|
+
// When --diff already materialized at maxTick, skip redundant re-materialize
|
|
2224
|
+
if (!sdResult) {
|
|
2225
|
+
await graph.materialize({ ceiling: maxTick });
|
|
2226
|
+
}
|
|
2194
2227
|
const nodes = await graph.getNodes();
|
|
2195
2228
|
const edges = await graph.getEdges();
|
|
2196
2229
|
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
@@ -2209,6 +2242,7 @@ async function handleSeek({ options, args }) {
|
|
|
2209
2242
|
diff,
|
|
2210
2243
|
tickReceipt,
|
|
2211
2244
|
cursor: { active: false },
|
|
2245
|
+
...sdResult,
|
|
2212
2246
|
},
|
|
2213
2247
|
exitCode: EXIT_CODES.OK,
|
|
2214
2248
|
};
|
|
@@ -2234,7 +2268,15 @@ async function handleSeek({ options, args }) {
|
|
|
2234
2268
|
if (!saved) {
|
|
2235
2269
|
throw notFoundError(`Saved cursor not found: ${loadName}`);
|
|
2236
2270
|
}
|
|
2237
|
-
|
|
2271
|
+
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
2272
|
+
let sdResult = null;
|
|
2273
|
+
if (seekSpec.diff) {
|
|
2274
|
+
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
|
|
2275
|
+
}
|
|
2276
|
+
// When --diff already materialized at saved.tick, skip redundant call
|
|
2277
|
+
if (!sdResult) {
|
|
2278
|
+
await graph.materialize({ ceiling: saved.tick });
|
|
2279
|
+
}
|
|
2238
2280
|
const nodes = await graph.getNodes();
|
|
2239
2281
|
const edges = await graph.getEdges();
|
|
2240
2282
|
await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
@@ -2255,6 +2297,7 @@ async function handleSeek({ options, args }) {
|
|
|
2255
2297
|
diff,
|
|
2256
2298
|
tickReceipt,
|
|
2257
2299
|
cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
|
|
2300
|
+
...sdResult,
|
|
2258
2301
|
},
|
|
2259
2302
|
exitCode: EXIT_CODES.OK,
|
|
2260
2303
|
};
|
|
@@ -2262,7 +2305,14 @@ async function handleSeek({ options, args }) {
|
|
|
2262
2305
|
if (seekSpec.action === 'tick') {
|
|
2263
2306
|
const currentTick = activeCursor ? activeCursor.tick : null;
|
|
2264
2307
|
const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
|
|
2265
|
-
|
|
2308
|
+
let sdResult = null;
|
|
2309
|
+
if (seekSpec.diff) {
|
|
2310
|
+
sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
|
|
2311
|
+
}
|
|
2312
|
+
// When --diff already materialized at resolvedTick, skip redundant call
|
|
2313
|
+
if (!sdResult) {
|
|
2314
|
+
await graph.materialize({ ceiling: resolvedTick });
|
|
2315
|
+
}
|
|
2266
2316
|
const nodes = await graph.getNodes();
|
|
2267
2317
|
const edges = await graph.getEdges();
|
|
2268
2318
|
await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
@@ -2282,12 +2332,22 @@ async function handleSeek({ options, args }) {
|
|
|
2282
2332
|
diff,
|
|
2283
2333
|
tickReceipt,
|
|
2284
2334
|
cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
|
|
2335
|
+
...sdResult,
|
|
2285
2336
|
},
|
|
2286
2337
|
exitCode: EXIT_CODES.OK,
|
|
2287
2338
|
};
|
|
2288
2339
|
}
|
|
2289
2340
|
|
|
2290
2341
|
// status (bare seek)
|
|
2342
|
+
return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Handles the `status` sub-action of `seek` (bare seek with no action flag).
|
|
2347
|
+
* @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
|
|
2348
|
+
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2349
|
+
*/
|
|
2350
|
+
async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
|
|
2291
2351
|
if (activeCursor) {
|
|
2292
2352
|
await graph.materialize({ ceiling: activeCursor.tick });
|
|
2293
2353
|
const nodes = await graph.getNodes();
|
|
@@ -2468,6 +2528,79 @@ async function buildTickReceipt({ tick, perWriter, graph }) {
|
|
|
2468
2528
|
return Object.keys(receipt).length > 0 ? receipt : null;
|
|
2469
2529
|
}
|
|
2470
2530
|
|
|
2531
|
+
/**
|
|
2532
|
+
* Computes a structural diff between the state at a previous tick and
|
|
2533
|
+
* the state at the current tick.
|
|
2534
|
+
*
|
|
2535
|
+
* Materializes the baseline tick first, snapshots the state, then
|
|
2536
|
+
* materializes the target tick and calls diffStates() between the two.
|
|
2537
|
+
* Applies diffLimit truncation when the total change count exceeds the cap.
|
|
2538
|
+
*
|
|
2539
|
+
* @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
|
|
2540
|
+
* @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
|
|
2541
|
+
*/
|
|
2542
|
+
async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
|
|
2543
|
+
let beforeState = null;
|
|
2544
|
+
let diffBaseline = 'empty';
|
|
2545
|
+
let baselineTick = null;
|
|
2546
|
+
|
|
2547
|
+
// Short-circuit: same tick produces an empty diff
|
|
2548
|
+
if (prevTick !== null && prevTick === currentTick) {
|
|
2549
|
+
const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
|
|
2550
|
+
return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
if (prevTick !== null && prevTick > 0) {
|
|
2554
|
+
await graph.materialize({ ceiling: prevTick });
|
|
2555
|
+
beforeState = await graph.getStateSnapshot();
|
|
2556
|
+
diffBaseline = 'tick';
|
|
2557
|
+
baselineTick = prevTick;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
await graph.materialize({ ceiling: currentTick });
|
|
2561
|
+
const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
|
|
2562
|
+
const diff = diffStates(beforeState, afterState);
|
|
2563
|
+
|
|
2564
|
+
return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* Applies truncation limits to a structural diff result.
|
|
2569
|
+
*
|
|
2570
|
+
* @param {*} diff
|
|
2571
|
+
* @param {string} diffBaseline
|
|
2572
|
+
* @param {number|null} baselineTick
|
|
2573
|
+
* @param {number} diffLimit
|
|
2574
|
+
* @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
|
|
2575
|
+
*/
|
|
2576
|
+
function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
|
|
2577
|
+
const totalChanges =
|
|
2578
|
+
diff.nodes.added.length + diff.nodes.removed.length +
|
|
2579
|
+
diff.edges.added.length + diff.edges.removed.length +
|
|
2580
|
+
diff.props.set.length + diff.props.removed.length;
|
|
2581
|
+
|
|
2582
|
+
if (totalChanges <= diffLimit) {
|
|
2583
|
+
return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Truncate sequentially (nodes → edges → props), keeping sort order within each category
|
|
2587
|
+
let remaining = diffLimit;
|
|
2588
|
+
const cap = (/** @type {any[]} */ arr) => {
|
|
2589
|
+
const take = Math.min(arr.length, remaining);
|
|
2590
|
+
remaining -= take;
|
|
2591
|
+
return arr.slice(0, take);
|
|
2592
|
+
};
|
|
2593
|
+
|
|
2594
|
+
const capped = {
|
|
2595
|
+
nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
|
|
2596
|
+
edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
|
|
2597
|
+
props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
const shownChanges = diffLimit - remaining;
|
|
2601
|
+
return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2471
2604
|
/**
|
|
2472
2605
|
* Renders a seek command payload as a human-readable string for terminal output.
|
|
2473
2606
|
*
|
|
@@ -2567,26 +2700,29 @@ function renderSeek(payload) {
|
|
|
2567
2700
|
|
|
2568
2701
|
if (payload.action === 'latest') {
|
|
2569
2702
|
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2570
|
-
|
|
2703
|
+
const base = appendReceiptSummary(
|
|
2571
2704
|
`${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
|
|
2572
2705
|
);
|
|
2706
|
+
return base + formatStructuralDiff(payload);
|
|
2573
2707
|
}
|
|
2574
2708
|
|
|
2575
2709
|
if (payload.action === 'load') {
|
|
2576
2710
|
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2577
|
-
|
|
2711
|
+
const base = appendReceiptSummary(
|
|
2578
2712
|
`${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
|
|
2579
2713
|
);
|
|
2714
|
+
return base + formatStructuralDiff(payload);
|
|
2580
2715
|
}
|
|
2581
2716
|
|
|
2582
2717
|
if (payload.action === 'tick') {
|
|
2583
2718
|
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2584
|
-
|
|
2719
|
+
const base = appendReceiptSummary(
|
|
2585
2720
|
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2586
2721
|
);
|
|
2722
|
+
return base + formatStructuralDiff(payload);
|
|
2587
2723
|
}
|
|
2588
2724
|
|
|
2589
|
-
// status
|
|
2725
|
+
// status (structuralDiff is never populated here; no formatStructuralDiff call)
|
|
2590
2726
|
if (payload.cursor && payload.cursor.active) {
|
|
2591
2727
|
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2592
2728
|
return appendReceiptSummary(
|
package/index.d.ts
CHANGED
|
@@ -1413,6 +1413,22 @@ export interface ApplySyncResult {
|
|
|
1413
1413
|
applied: number;
|
|
1414
1414
|
}
|
|
1415
1415
|
|
|
1416
|
+
/**
|
|
1417
|
+
* Server-side auth configuration for serve().
|
|
1418
|
+
*/
|
|
1419
|
+
export interface SyncAuthServerOptions {
|
|
1420
|
+
keys: Record<string, string>;
|
|
1421
|
+
mode?: 'enforce' | 'log-only';
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Client-side auth credentials for syncWith().
|
|
1426
|
+
*/
|
|
1427
|
+
export interface SyncAuthClientOptions {
|
|
1428
|
+
secret: string;
|
|
1429
|
+
keyId?: string;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1416
1432
|
// ============================================================================
|
|
1417
1433
|
// Status snapshot
|
|
1418
1434
|
// ============================================================================
|
|
@@ -1526,6 +1542,12 @@ export default class WarpGraph {
|
|
|
1526
1542
|
*/
|
|
1527
1543
|
getPropertyCount(): Promise<number>;
|
|
1528
1544
|
|
|
1545
|
+
/**
|
|
1546
|
+
* Returns a defensive copy of the current materialized state,
|
|
1547
|
+
* or null if no state has been materialized yet.
|
|
1548
|
+
*/
|
|
1549
|
+
getStateSnapshot(): Promise<WarpStateV5 | null>;
|
|
1550
|
+
|
|
1529
1551
|
/**
|
|
1530
1552
|
* Gets all properties for an edge from the materialized state.
|
|
1531
1553
|
* Returns null if the edge does not exist or is tombstoned.
|
|
@@ -1615,6 +1637,7 @@ export default class WarpGraph {
|
|
|
1615
1637
|
path?: string;
|
|
1616
1638
|
maxRequestBytes?: number;
|
|
1617
1639
|
httpPort: HttpServerPort;
|
|
1640
|
+
auth?: SyncAuthServerOptions;
|
|
1618
1641
|
}): Promise<{ close(): Promise<void>; url: string }>;
|
|
1619
1642
|
|
|
1620
1643
|
/**
|
|
@@ -1634,6 +1657,7 @@ export default class WarpGraph {
|
|
|
1634
1657
|
status?: number;
|
|
1635
1658
|
error?: Error;
|
|
1636
1659
|
}) => void;
|
|
1660
|
+
auth?: SyncAuthClientOptions;
|
|
1637
1661
|
}): Promise<{ applied: number; attempts: number }>;
|
|
1638
1662
|
|
|
1639
1663
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git-stunts/git-warp",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.7.0",
|
|
4
4
|
"description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -74,7 +74,6 @@
|
|
|
74
74
|
"demo:bench-streaming": "cd examples && docker compose up -d && docker compose exec demo node /app/examples/streaming-benchmark.js",
|
|
75
75
|
"demo:bench-traversal": "cd examples && docker compose up -d && docker compose exec demo node /app/examples/traversal-benchmark.js",
|
|
76
76
|
"demo:down": "cd examples && docker compose down -v",
|
|
77
|
-
"roadmap": "node scripts/roadmap.js",
|
|
78
77
|
"setup:hooks": "node scripts/setup-hooks.js",
|
|
79
78
|
"prepare": "patch-package && node scripts/setup-hooks.js",
|
|
80
79
|
"prepack": "npm run lint && npm run test:local",
|