@git-stunts/git-warp 10.1.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.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * HookInstaller — Installs and manages the post-merge Git hook.
3
+ *
4
+ * Follows hexagonal architecture: all I/O is injected via constructor.
5
+ * The service executes a strategy decided by the caller (CLI handler).
6
+ *
7
+ * @module domain/services/HookInstaller
8
+ */
9
+
10
+ const DELIMITER_START_PREFIX = '# --- @git-stunts/git-warp post-merge hook';
11
+ const DELIMITER_END = '# --- end @git-stunts/git-warp ---';
12
+ const VERSION_MARKER_PREFIX = '# warp-hook-version:';
13
+ const VERSION_PLACEHOLDER = '__WARP_HOOK_VERSION__';
14
+
15
+ /**
16
+ * Classifies an existing hook file's content.
17
+ *
18
+ * Determines whether the hook is absent, ours (with version), or foreign (third-party).
19
+ *
20
+ * @param {string|null} content - File content or null if missing
21
+ * @returns {{ kind: 'none'|'ours'|'foreign', version?: string, appended?: boolean }}
22
+ */
23
+ export function classifyExistingHook(content) {
24
+ if (!content || content.trim() === '') {
25
+ return { kind: 'none' };
26
+ }
27
+
28
+ const versionMatch = extractVersion(content);
29
+ if (versionMatch) {
30
+ const appended = content.includes(DELIMITER_START_PREFIX);
31
+ return { kind: 'ours', version: versionMatch, appended };
32
+ }
33
+
34
+ return { kind: 'foreign' };
35
+ }
36
+
37
+ /**
38
+ * Extracts the warp hook version from a hook file's content.
39
+ *
40
+ * @param {string} content - File content to search
41
+ * @returns {string|null} The version string, or null if not found
42
+ * @private
43
+ */
44
+ function extractVersion(content) {
45
+ for (const line of content.split('\n')) {
46
+ const trimmed = line.trim();
47
+ if (trimmed.startsWith(VERSION_MARKER_PREFIX)) {
48
+ const version = trimmed.slice(VERSION_MARKER_PREFIX.length).trim();
49
+ if (version && version !== VERSION_PLACEHOLDER) {
50
+ return version;
51
+ }
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export class HookInstaller {
58
+ /**
59
+ * Creates a new HookInstaller.
60
+ *
61
+ * @param {Object} deps - Injected dependencies
62
+ * @param {Object} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync
63
+ * @param {(repoPath: string, key: string) => string|null} deps.execGitConfig - Function to read git config values
64
+ * @param {string} deps.version - Package version
65
+ * @param {string} deps.templateDir - Directory containing hook templates
66
+ * @param {{ join: (...segments: string[]) => string, resolve: (...segments: string[]) => string }} deps.path - Path utilities (join and resolve)
67
+ */
68
+ constructor({ fs, execGitConfig, version, templateDir, path } = {}) {
69
+ this._fs = fs;
70
+ this._execGitConfig = execGitConfig;
71
+ this._templateDir = templateDir;
72
+ this._version = version;
73
+ this._path = path;
74
+ }
75
+
76
+ /**
77
+ * Get the current hook status for a repo.
78
+ *
79
+ * @param {string} repoPath - Path to git repo
80
+ * @returns {{ installed: boolean, version?: string, current?: boolean, foreign?: boolean, hookPath: string }}
81
+ */
82
+ getHookStatus(repoPath) {
83
+ const hookPath = this._resolveHookPath(repoPath);
84
+ const content = this._readFile(hookPath);
85
+ const classification = classifyExistingHook(content);
86
+
87
+ if (classification.kind === 'none') {
88
+ return { installed: false, hookPath };
89
+ }
90
+
91
+ if (classification.kind === 'foreign') {
92
+ return { installed: false, foreign: true, hookPath };
93
+ }
94
+
95
+ const current = classification.version === this._version;
96
+ return {
97
+ installed: true,
98
+ version: classification.version,
99
+ current,
100
+ hookPath,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Installs the post-merge hook.
106
+ *
107
+ * @param {string} repoPath - Path to git repo
108
+ * @param {Object} opts - Install options
109
+ * @param {'install'|'upgrade'|'append'|'replace'} opts.strategy - Installation strategy
110
+ * @returns {{ action: string, hookPath: string, version: string, backupPath?: string }}
111
+ * @throws {Error} If the strategy is unknown
112
+ */
113
+ install(repoPath, { strategy }) {
114
+ const hooksDir = this._resolveHooksDir(repoPath);
115
+ const hookPath = this._path.join(hooksDir, 'post-merge');
116
+ const template = this._loadTemplate();
117
+ const stamped = this._stampVersion(template);
118
+
119
+ this._ensureDir(hooksDir);
120
+
121
+ if (strategy === 'install') {
122
+ return this._freshInstall(hookPath, stamped);
123
+ }
124
+ if (strategy === 'upgrade') {
125
+ return this._upgradeInstall(hookPath, stamped);
126
+ }
127
+ if (strategy === 'append') {
128
+ return this._appendInstall(hookPath, stamped);
129
+ }
130
+ if (strategy === 'replace') {
131
+ return this._replaceInstall(hookPath, stamped);
132
+ }
133
+
134
+ throw new Error(`Unknown install strategy: ${strategy}`);
135
+ }
136
+
137
+ /** @private */
138
+ _freshInstall(hookPath, content) {
139
+ this._fs.writeFileSync(hookPath, content, { mode: 0o755 });
140
+ this._fs.chmodSync(hookPath, 0o755);
141
+ return {
142
+ action: 'installed',
143
+ hookPath,
144
+ version: this._version,
145
+ };
146
+ }
147
+
148
+ /** @private */
149
+ _upgradeInstall(hookPath, stamped) {
150
+ const existing = this._readFile(hookPath);
151
+ const classification = classifyExistingHook(existing);
152
+
153
+ if (classification.appended) {
154
+ const updated = replaceDelimitedSection(existing, stamped);
155
+ // If delimiters were corrupted, replaceDelimitedSection returns unchanged content — fall back to overwrite
156
+ if (updated === existing) {
157
+ this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
158
+ } else {
159
+ this._fs.writeFileSync(hookPath, updated, { mode: 0o755 });
160
+ }
161
+ } else {
162
+ this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
163
+ }
164
+ this._fs.chmodSync(hookPath, 0o755);
165
+
166
+ return {
167
+ action: 'upgraded',
168
+ hookPath,
169
+ version: this._version,
170
+ };
171
+ }
172
+
173
+ /** @private */
174
+ _appendInstall(hookPath, stamped) {
175
+ const existing = this._readFile(hookPath) || '';
176
+ const body = stripShebang(stamped);
177
+ const appended = buildAppendedContent(existing, body);
178
+ this._fs.writeFileSync(hookPath, appended, { mode: 0o755 });
179
+ this._fs.chmodSync(hookPath, 0o755);
180
+ return {
181
+ action: 'appended',
182
+ hookPath,
183
+ version: this._version,
184
+ };
185
+ }
186
+
187
+ /** @private */
188
+ _replaceInstall(hookPath, stamped) {
189
+ const existing = this._readFile(hookPath);
190
+ let backupPath;
191
+ if (existing) {
192
+ backupPath = `${hookPath}.backup`;
193
+ this._fs.writeFileSync(backupPath, existing);
194
+ this._fs.chmodSync(backupPath, 0o755);
195
+ }
196
+
197
+ this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
198
+ this._fs.chmodSync(hookPath, 0o755);
199
+ return {
200
+ action: 'replaced',
201
+ hookPath,
202
+ version: this._version,
203
+ backupPath,
204
+ };
205
+ }
206
+
207
+ /** @private */
208
+ _loadTemplate() {
209
+ const templatePath = this._path.join(this._templateDir, 'post-merge.sh');
210
+ return this._fs.readFileSync(templatePath, 'utf8');
211
+ }
212
+
213
+ /** @private */
214
+ _stampVersion(template) {
215
+ return template.replaceAll(VERSION_PLACEHOLDER, this._version);
216
+ }
217
+
218
+ /** @private */
219
+ _resolveHooksDir(repoPath) {
220
+ const customPath = this._execGitConfig(repoPath, 'core.hooksPath');
221
+ if (customPath) {
222
+ return resolveHooksPath(customPath, repoPath, this._path);
223
+ }
224
+
225
+ const gitDir = this._execGitConfig(repoPath, '--git-dir');
226
+ if (gitDir) {
227
+ return this._path.join(this._path.resolve(repoPath, gitDir), 'hooks');
228
+ }
229
+
230
+ return this._path.join(repoPath, '.git', 'hooks');
231
+ }
232
+
233
+ /** @private */
234
+ _resolveHookPath(repoPath) {
235
+ return this._path.join(this._resolveHooksDir(repoPath), 'post-merge');
236
+ }
237
+
238
+ /** @private */
239
+ _readFile(filePath) {
240
+ try {
241
+ return this._fs.readFileSync(filePath, 'utf8');
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /** @private */
248
+ _ensureDir(dirPath) {
249
+ if (!this._fs.existsSync(dirPath)) {
250
+ this._fs.mkdirSync(dirPath, { recursive: true });
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Resolves a hooks path, handling both absolute and relative paths.
257
+ *
258
+ * @param {string} customPath - The custom hooks path from git config
259
+ * @param {string} repoPath - The repo root path for resolving relative paths
260
+ * @param {{ resolve: (...segments: string[]) => string }} pathUtils - Path utilities
261
+ * @returns {string} Resolved absolute hooks directory path
262
+ * @private
263
+ */
264
+ function resolveHooksPath(customPath, repoPath, pathUtils) {
265
+ if (customPath.startsWith('/')) {
266
+ return customPath;
267
+ }
268
+ return pathUtils.resolve(repoPath, customPath);
269
+ }
270
+
271
+ /**
272
+ * Strips the shebang line from hook content.
273
+ *
274
+ * @param {string} content - Hook file content
275
+ * @returns {string} Content without the shebang line
276
+ * @private
277
+ */
278
+ function stripShebang(content) {
279
+ const lines = content.split('\n');
280
+ if (lines[0] && lines[0].startsWith('#!')) {
281
+ return lines.slice(1).join('\n');
282
+ }
283
+ return content;
284
+ }
285
+
286
+ /**
287
+ * Builds content by appending the warp hook body to existing hook content.
288
+ *
289
+ * @param {string} existing - Existing hook file content
290
+ * @param {string} body - Warp hook body to append (without shebang)
291
+ * @returns {string} Combined hook content
292
+ * @private
293
+ */
294
+ function buildAppendedContent(existing, body) {
295
+ const trimmed = existing.trimEnd();
296
+ return `${trimmed}\n\n${body}`;
297
+ }
298
+
299
+ /**
300
+ * Replaces the delimited warp section in an existing hook file.
301
+ *
302
+ * Returns the original content unchanged if delimiters are not found.
303
+ *
304
+ * @param {string} existing - Existing hook file content
305
+ * @param {string} stamped - New version-stamped hook content
306
+ * @returns {string} Updated content with replaced section, or original if delimiters missing
307
+ * @private
308
+ */
309
+ function replaceDelimitedSection(existing, stamped) {
310
+ const body = stripShebang(stamped);
311
+ const startIdx = existing.indexOf(DELIMITER_START_PREFIX);
312
+ const endIdx = existing.indexOf(DELIMITER_END);
313
+
314
+ if (startIdx === -1 || endIdx === -1) {
315
+ return existing;
316
+ }
317
+
318
+ const endOfEnd = endIdx + DELIMITER_END.length;
319
+ const before = existing.slice(0, startIdx).trimEnd();
320
+ const after = existing.slice(endOfEnd).trimStart();
321
+ const parts = [before, '', body];
322
+ if (after) {
323
+ parts.push(after);
324
+ }
325
+ return parts.join('\n');
326
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * HTTP sync server extracted from WarpGraph.serve().
3
+ *
4
+ * Handles request routing, JSON parsing, validation, and error responses
5
+ * for the sync protocol. All HTTP I/O flows through an HttpServerPort
6
+ * so the domain never touches node:http directly.
7
+ *
8
+ * @module domain/services/HttpSyncServer
9
+ */
10
+
11
+ const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
12
+
13
+ /**
14
+ * Recursively sorts object keys for deterministic JSON output.
15
+ *
16
+ * @param {*} value - Any JSON-serializable value
17
+ * @returns {*} The canonicalized value with sorted object keys
18
+ * @private
19
+ */
20
+ function canonicalizeJson(value) {
21
+ if (Array.isArray(value)) {
22
+ return value.map(canonicalizeJson);
23
+ }
24
+ if (value && typeof value === 'object') {
25
+ const sorted = {};
26
+ for (const key of Object.keys(value).sort()) {
27
+ sorted[key] = canonicalizeJson(value[key]);
28
+ }
29
+ return sorted;
30
+ }
31
+ return value;
32
+ }
33
+
34
+ /**
35
+ * Produces a canonical JSON string with sorted keys.
36
+ *
37
+ * @param {*} value - Any JSON-serializable value
38
+ * @returns {string} Canonical JSON string
39
+ * @private
40
+ */
41
+ function canonicalStringify(value) {
42
+ return JSON.stringify(canonicalizeJson(value));
43
+ }
44
+
45
+ /**
46
+ * Builds a JSON error response.
47
+ *
48
+ * @param {number} status - HTTP status code
49
+ * @param {string} message - Error message
50
+ * @returns {{ status: number, headers: Object, body: string }}
51
+ * @private
52
+ */
53
+ function errorResponse(status, message) {
54
+ return {
55
+ status,
56
+ headers: { 'content-type': 'application/json' },
57
+ body: canonicalStringify({ error: message }),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Builds a JSON success response with canonical key ordering.
63
+ *
64
+ * @param {*} data - Response payload
65
+ * @returns {{ status: number, headers: Object, body: string }}
66
+ * @private
67
+ */
68
+ function jsonResponse(data) {
69
+ return {
70
+ status: 200,
71
+ headers: { 'content-type': 'application/json' },
72
+ body: canonicalStringify(data),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Validates that a sync request object has the expected shape.
78
+ *
79
+ * @param {*} parsed - Parsed JSON body
80
+ * @returns {boolean} True if valid
81
+ * @private
82
+ */
83
+ function isValidSyncRequest(parsed) {
84
+ if (!parsed || typeof parsed !== 'object') {
85
+ return false;
86
+ }
87
+ if (parsed.type !== 'sync-request') {
88
+ return false;
89
+ }
90
+ if (!parsed.frontier || typeof parsed.frontier !== 'object' || Array.isArray(parsed.frontier)) {
91
+ return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Checks the content-type header. Returns an error response if the
98
+ * content type is present but not application/json, otherwise null.
99
+ *
100
+ * @param {Object} headers - Request headers
101
+ * @returns {{ status: number, headers: Object, body: string }|null}
102
+ * @private
103
+ */
104
+ function checkContentType(headers) {
105
+ const contentType = ((headers && headers['content-type']) || '').toLowerCase();
106
+ if (contentType && !contentType.startsWith('application/json')) {
107
+ return errorResponse(400, 'Expected application/json');
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Parses the request URL and validates the path and method.
114
+ * Returns an error response on failure, or null if valid.
115
+ *
116
+ * @param {{ method: string, url: string, headers: Object }} request
117
+ * @param {string} expectedPath
118
+ * @param {string} defaultHost
119
+ * @returns {{ status: number, headers: Object, body: string }|null}
120
+ * @private
121
+ */
122
+ function validateRoute(request, expectedPath, defaultHost) {
123
+ let requestUrl;
124
+ try {
125
+ requestUrl = new URL(request.url || '/', `http://${(request.headers && request.headers.host) || defaultHost}`);
126
+ } catch {
127
+ return errorResponse(400, 'Invalid URL');
128
+ }
129
+
130
+ if (requestUrl.pathname !== expectedPath) {
131
+ return errorResponse(404, 'Not Found');
132
+ }
133
+
134
+ if (request.method !== 'POST') {
135
+ return errorResponse(405, 'Method Not Allowed');
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Parses and validates the request body as a sync request.
143
+ *
144
+ * @param {Buffer|undefined} body
145
+ * @param {number} maxBytes
146
+ * @returns {{ error: { status: number, headers: Object, body: string }, parsed: null } | { error: null, parsed: Object }}
147
+ * @private
148
+ */
149
+ function parseBody(body, maxBytes) {
150
+ if (body && body.length > maxBytes) {
151
+ return { error: errorResponse(413, 'Request too large'), parsed: null };
152
+ }
153
+
154
+ const bodyStr = body ? body.toString('utf-8') : '';
155
+
156
+ let parsed;
157
+ try {
158
+ parsed = bodyStr ? JSON.parse(bodyStr) : null;
159
+ } catch {
160
+ return { error: errorResponse(400, 'Invalid JSON'), parsed: null };
161
+ }
162
+
163
+ if (!isValidSyncRequest(parsed)) {
164
+ return { error: errorResponse(400, 'Invalid sync request'), parsed: null };
165
+ }
166
+
167
+ return { error: null, parsed };
168
+ }
169
+
170
+ export default class HttpSyncServer {
171
+ /**
172
+ * @param {Object} options
173
+ * @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server port abstraction
174
+ * @param {Object} options.graph - WarpGraph instance (must expose processSyncRequest)
175
+ * @param {string} [options.path='/sync'] - URL path to handle sync requests on
176
+ * @param {string} [options.host='127.0.0.1'] - Host to bind
177
+ * @param {number} [options.maxRequestBytes=4194304] - Maximum request body size in bytes
178
+ */
179
+ constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = {}) {
180
+ this._httpPort = httpPort;
181
+ this._graph = graph;
182
+ this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`;
183
+ this._host = host;
184
+ this._maxRequestBytes = maxRequestBytes;
185
+ this._server = null;
186
+ }
187
+
188
+ /**
189
+ * Handles an incoming HTTP request through the port abstraction.
190
+ *
191
+ * @param {{ method: string, url: string, headers: Object, body: Buffer|undefined }} request
192
+ * @returns {Promise<{ status: number, headers: Object, body: string }>}
193
+ * @private
194
+ */
195
+ async _handleRequest(request) {
196
+ const contentTypeError = checkContentType(request.headers);
197
+ if (contentTypeError) {
198
+ return contentTypeError;
199
+ }
200
+
201
+ const routeError = validateRoute(request, this._path, this._host);
202
+ if (routeError) {
203
+ return routeError;
204
+ }
205
+
206
+ const { error, parsed } = parseBody(request.body, this._maxRequestBytes);
207
+ if (error) {
208
+ return error;
209
+ }
210
+
211
+ try {
212
+ const response = await this._graph.processSyncRequest(parsed);
213
+ return jsonResponse(response);
214
+ } catch (err) {
215
+ return errorResponse(500, err?.message || 'Sync failed');
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Starts the HTTP sync server.
221
+ *
222
+ * @param {number} port - Port to listen on (0 for ephemeral)
223
+ * @returns {Promise<{ url: string, close: () => Promise<void> }>}
224
+ * @throws {Error} If port is not a number
225
+ */
226
+ async listen(port) {
227
+ if (typeof port !== 'number') {
228
+ throw new Error('listen() requires a numeric port');
229
+ }
230
+
231
+ const server = this._httpPort.createServer((request) => this._handleRequest(request));
232
+ this._server = server;
233
+
234
+ await new Promise((resolve, reject) => {
235
+ server.listen(port, this._host, (err) => {
236
+ if (err) {
237
+ reject(err);
238
+ } else {
239
+ resolve();
240
+ }
241
+ });
242
+ });
243
+
244
+ const address = server.address();
245
+ const actualPort = typeof address === 'object' && address ? address.port : port;
246
+ const url = `http://${this._host}:${actualPort}${this._path}`;
247
+
248
+ return {
249
+ url,
250
+ close: () =>
251
+ new Promise((resolve, reject) => {
252
+ server.close((err) => {
253
+ if (err) {
254
+ reject(err);
255
+ } else {
256
+ resolve();
257
+ }
258
+ });
259
+ }),
260
+ };
261
+ }
262
+ }