@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.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writer - WARP writer abstraction for safe graph mutations.
|
|
3
|
+
*
|
|
4
|
+
* A Writer is the only way to mutate a WarpGraph state. It owns a writerId
|
|
5
|
+
* and maintains a single-writer chain under refs/warp/<graph>/writers/<writerId>.
|
|
6
|
+
*
|
|
7
|
+
* Key guarantees:
|
|
8
|
+
* - Single-writer chain per writerId
|
|
9
|
+
* - Ref-safe identity
|
|
10
|
+
* - CAS-based updates to prevent concurrent forks
|
|
11
|
+
* - Schema:2 only (PatchV2 ops with OR-Set semantics)
|
|
12
|
+
*
|
|
13
|
+
* @module domain/warp/Writer
|
|
14
|
+
* @see WARP Writer Spec v1
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import defaultCodec from '../utils/defaultCodec.js';
|
|
18
|
+
import { validateWriterId, buildWriterRef } from '../utils/RefLayout.js';
|
|
19
|
+
import { PatchSession } from './PatchSession.js';
|
|
20
|
+
import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
|
|
21
|
+
import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
|
|
22
|
+
import { vvClone } from '../crdt/VersionVector.js';
|
|
23
|
+
import WriterError from '../errors/WriterError.js';
|
|
24
|
+
|
|
25
|
+
// Re-export for backward compatibility — consumers importing from Writer.js
|
|
26
|
+
// should migrate to importing from '../errors/WriterError.js' directly.
|
|
27
|
+
export { WriterError };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Writer class for creating and committing patches to a WARP graph.
|
|
31
|
+
*
|
|
32
|
+
* @class Writer
|
|
33
|
+
*/
|
|
34
|
+
export class Writer {
|
|
35
|
+
/**
|
|
36
|
+
* Creates a new Writer instance.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} options
|
|
39
|
+
* @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
|
|
40
|
+
* @param {string} options.graphName - Graph namespace
|
|
41
|
+
* @param {string} options.writerId - This writer's ID
|
|
42
|
+
* @param {import('../crdt/VersionVector.js').VersionVector} options.versionVector - Current version vector
|
|
43
|
+
* @param {() => Promise<import('../services/JoinReducer.js').WarpStateV5>} options.getCurrentState - Async function returning the current materialized V5 state
|
|
44
|
+
* @param {(result: {patch: Object, sha: string}) => void | Promise<void>} [options.onCommitSuccess] - Callback invoked after successful commit with { patch, sha }
|
|
45
|
+
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
46
|
+
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
|
|
47
|
+
*/
|
|
48
|
+
constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) {
|
|
49
|
+
validateWriterId(writerId);
|
|
50
|
+
|
|
51
|
+
/** @type {import('../../ports/GraphPersistencePort.js').default} */
|
|
52
|
+
this._persistence = persistence;
|
|
53
|
+
|
|
54
|
+
/** @type {string} */
|
|
55
|
+
this._graphName = graphName;
|
|
56
|
+
|
|
57
|
+
/** @type {string} */
|
|
58
|
+
this._writerId = writerId;
|
|
59
|
+
|
|
60
|
+
/** @type {import('../crdt/VersionVector.js').VersionVector} */
|
|
61
|
+
this._versionVector = versionVector;
|
|
62
|
+
|
|
63
|
+
/** @type {Function} */
|
|
64
|
+
this._getCurrentState = getCurrentState;
|
|
65
|
+
|
|
66
|
+
/** @type {Function|undefined} */
|
|
67
|
+
this._onCommitSuccess = onCommitSuccess;
|
|
68
|
+
|
|
69
|
+
/** @type {'reject'|'cascade'|'warn'} */
|
|
70
|
+
this._onDeleteWithData = onDeleteWithData;
|
|
71
|
+
|
|
72
|
+
/** @type {import('../../ports/CodecPort.js').default|undefined} */
|
|
73
|
+
this._codec = codec || defaultCodec;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gets the writer ID.
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
get writerId() {
|
|
81
|
+
return this._writerId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gets the graph name.
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
get graphName() {
|
|
89
|
+
return this._graphName;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Gets the current writer head SHA.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<string|null>} The tip SHA or null if no commits yet
|
|
96
|
+
*/
|
|
97
|
+
async head() {
|
|
98
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
99
|
+
return await this._persistence.readRef(writerRef);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Begins a new patch session.
|
|
104
|
+
*
|
|
105
|
+
* Reads the current writer head and captures it as the expected parent
|
|
106
|
+
* for CAS-based commit.
|
|
107
|
+
*
|
|
108
|
+
* @returns {Promise<PatchSession>} A fluent patch session
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const writer = await graph.writer();
|
|
112
|
+
* const patch = await writer.beginPatch();
|
|
113
|
+
* patch.addNode('user:alice');
|
|
114
|
+
* patch.setProperty('user:alice', 'name', 'Alice');
|
|
115
|
+
* await patch.commit();
|
|
116
|
+
*/
|
|
117
|
+
async beginPatch() {
|
|
118
|
+
// Read current writer head and capture for CAS
|
|
119
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
120
|
+
const expectedOldHead = await this._persistence.readRef(writerRef);
|
|
121
|
+
|
|
122
|
+
// Calculate next lamport
|
|
123
|
+
let lamport = 1;
|
|
124
|
+
if (expectedOldHead) {
|
|
125
|
+
const commitMessage = await this._persistence.showNode(expectedOldHead);
|
|
126
|
+
const kind = detectMessageKind(commitMessage);
|
|
127
|
+
if (kind === 'patch') {
|
|
128
|
+
try {
|
|
129
|
+
const patchInfo = decodePatchMessage(commitMessage);
|
|
130
|
+
lamport = patchInfo.lamport + 1;
|
|
131
|
+
} catch {
|
|
132
|
+
// Malformed message, start at 1
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create internal PatchBuilderV2
|
|
138
|
+
const builder = new PatchBuilderV2({
|
|
139
|
+
persistence: this._persistence,
|
|
140
|
+
graphName: this._graphName,
|
|
141
|
+
writerId: this._writerId,
|
|
142
|
+
lamport,
|
|
143
|
+
versionVector: vvClone(this._versionVector),
|
|
144
|
+
getCurrentState: this._getCurrentState,
|
|
145
|
+
expectedParentSha: expectedOldHead,
|
|
146
|
+
onCommitSuccess: this._onCommitSuccess,
|
|
147
|
+
onDeleteWithData: this._onDeleteWithData,
|
|
148
|
+
codec: this._codec,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Return PatchSession wrapping the builder
|
|
152
|
+
return new PatchSession({
|
|
153
|
+
builder,
|
|
154
|
+
persistence: this._persistence,
|
|
155
|
+
graphName: this._graphName,
|
|
156
|
+
writerId: this._writerId,
|
|
157
|
+
expectedOldHead,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Convenience method to build and commit a patch in one call.
|
|
163
|
+
*
|
|
164
|
+
* @param {(p: PatchSession) => void | Promise<void>} build - Function to build the patch
|
|
165
|
+
* @returns {Promise<string>} The commit SHA of the new patch
|
|
166
|
+
* @throws {WriterError} EMPTY_PATCH if no operations were added
|
|
167
|
+
* @throws {WriterError} WRITER_REF_ADVANCED if CAS fails (ref moved since beginPatch)
|
|
168
|
+
* @throws {WriterError} PERSIST_WRITE_FAILED if git operations fail
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* const sha = await writer.commitPatch(p => {
|
|
172
|
+
* p.addNode('user:alice');
|
|
173
|
+
* p.setProperty('user:alice', 'name', 'Alice');
|
|
174
|
+
* });
|
|
175
|
+
*/
|
|
176
|
+
async commitPatch(build) {
|
|
177
|
+
const patch = await this.beginPatch();
|
|
178
|
+
await build(patch);
|
|
179
|
+
return await patch.commit();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# --- @git-stunts/git-warp post-merge hook __WARP_HOOK_VERSION__ ---
|
|
3
|
+
# warp-hook-version: __WARP_HOOK_VERSION__
|
|
4
|
+
#
|
|
5
|
+
# Post-merge hook: notify (or auto-materialize) when warp refs changed.
|
|
6
|
+
#
|
|
7
|
+
# Compares refs/warp/ before and after merge by maintaining
|
|
8
|
+
# a snapshot file at .git/warp-ref-snapshot. If any warp writer refs
|
|
9
|
+
# changed and warp.autoMaterialize is true, runs `git warp materialize`.
|
|
10
|
+
# Otherwise prints an informational message advising re-materialization.
|
|
11
|
+
# Always exits 0 — never blocks a merge.
|
|
12
|
+
|
|
13
|
+
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
14
|
+
SNAPSHOT="${GIT_DIR}/warp-ref-snapshot"
|
|
15
|
+
|
|
16
|
+
# Capture current warp refs (sorted for stable comparison)
|
|
17
|
+
CURRENT=$(git for-each-ref --format='%(refname) %(objectname)' --sort=refname refs/warp/ 2>/dev/null) || true
|
|
18
|
+
|
|
19
|
+
if [ -z "$CURRENT" ]; then
|
|
20
|
+
# No warp refs exist — clean up any stale snapshot and exit
|
|
21
|
+
rm -f "$SNAPSHOT"
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
CHANGED=0
|
|
26
|
+
|
|
27
|
+
if [ -f "$SNAPSHOT" ]; then
|
|
28
|
+
PREVIOUS=$(cat "$SNAPSHOT")
|
|
29
|
+
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
|
30
|
+
CHANGED=1
|
|
31
|
+
fi
|
|
32
|
+
else
|
|
33
|
+
# First encounter — refs exist but no snapshot yet
|
|
34
|
+
CHANGED=1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Save current state for next comparison
|
|
38
|
+
printf '%s\n' "$CURRENT" > "$SNAPSHOT"
|
|
39
|
+
|
|
40
|
+
if [ "$CHANGED" -eq 0 ]; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
AUTO_MAT=$(git config --bool warp.autoMaterialize 2>/dev/null) || true
|
|
45
|
+
|
|
46
|
+
if [ "$AUTO_MAT" = "true" ]; then
|
|
47
|
+
echo "[warp] Refs changed — auto-materializing..."
|
|
48
|
+
if command -v git-warp >/dev/null 2>&1; then
|
|
49
|
+
git-warp materialize || echo "[warp] Warning: auto-materialize failed."
|
|
50
|
+
elif command -v warp-graph >/dev/null 2>&1; then
|
|
51
|
+
warp-graph materialize || echo "[warp] Warning: auto-materialize failed."
|
|
52
|
+
else
|
|
53
|
+
echo "[warp] Warning: neither git-warp nor warp-graph found in PATH."
|
|
54
|
+
fi
|
|
55
|
+
else
|
|
56
|
+
echo "[warp] Writer refs changed during merge. Call materialize() to see updates."
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
60
|
+
# --- end @git-stunts/git-warp ---
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import HttpServerPort from '../../ports/HttpServerPort.js';
|
|
2
|
+
|
|
3
|
+
const ERROR_BODY = 'Internal Server Error';
|
|
4
|
+
const ERROR_BODY_BYTES = new TextEncoder().encode(ERROR_BODY);
|
|
5
|
+
const ERROR_BODY_LENGTH = String(ERROR_BODY_BYTES.byteLength);
|
|
6
|
+
|
|
7
|
+
const PAYLOAD_TOO_LARGE = 'Payload Too Large';
|
|
8
|
+
const PAYLOAD_TOO_LARGE_LENGTH = String(new TextEncoder().encode(PAYLOAD_TOO_LARGE).byteLength);
|
|
9
|
+
|
|
10
|
+
/** Absolute streaming body limit (10 MB) — matches NodeHttpAdapter. */
|
|
11
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads a ReadableStream body with a byte-count limit.
|
|
15
|
+
* Aborts immediately when the limit is exceeded, preventing full buffering.
|
|
16
|
+
*
|
|
17
|
+
* @param {ReadableStream} bodyStream
|
|
18
|
+
* @returns {Promise<Uint8Array|undefined>}
|
|
19
|
+
*/
|
|
20
|
+
async function readStreamBody(bodyStream) {
|
|
21
|
+
const reader = bodyStream.getReader();
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
total += value.byteLength;
|
|
30
|
+
if (total > MAX_BODY_BYTES) {
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
throw Object.assign(new Error('Payload Too Large'), { status: 413 });
|
|
33
|
+
}
|
|
34
|
+
chunks.push(value);
|
|
35
|
+
}
|
|
36
|
+
if (total === 0) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const body = new Uint8Array(total);
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
body.set(chunk, offset);
|
|
43
|
+
offset += chunk.byteLength;
|
|
44
|
+
}
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Converts a Bun Request into the plain-object format expected by
|
|
50
|
+
* HttpServerPort request handlers.
|
|
51
|
+
*
|
|
52
|
+
* @param {Request} request - Bun fetch Request
|
|
53
|
+
* @returns {Promise<{ method: string, url: string, headers: Object, body: Buffer|undefined }>}
|
|
54
|
+
*/
|
|
55
|
+
async function toPortRequest(request) {
|
|
56
|
+
const headers = {};
|
|
57
|
+
request.headers.forEach((value, key) => {
|
|
58
|
+
headers[key] = value;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let body;
|
|
62
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
63
|
+
const cl = headers['content-length'];
|
|
64
|
+
if (cl !== undefined && Number(cl) > MAX_BODY_BYTES) {
|
|
65
|
+
throw Object.assign(new Error('Payload Too Large'), { status: 413 });
|
|
66
|
+
}
|
|
67
|
+
if (request.body) {
|
|
68
|
+
body = await readStreamBody(request.body);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsedUrl = new URL(request.url);
|
|
73
|
+
return {
|
|
74
|
+
method: request.method,
|
|
75
|
+
url: parsedUrl.pathname + parsedUrl.search,
|
|
76
|
+
headers,
|
|
77
|
+
body,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Converts a plain-object port response into a Bun Response.
|
|
83
|
+
*
|
|
84
|
+
* @param {{ status?: number, headers?: Object, body?: string|Uint8Array }} portResponse
|
|
85
|
+
* @returns {Response}
|
|
86
|
+
*/
|
|
87
|
+
function toResponse(portResponse) {
|
|
88
|
+
return new Response(portResponse.body ?? null, {
|
|
89
|
+
status: portResponse.status || 200,
|
|
90
|
+
headers: portResponse.headers || {},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates the Bun fetch handler that bridges between Request/Response
|
|
96
|
+
* and the HttpServerPort plain-object contract.
|
|
97
|
+
*
|
|
98
|
+
* @param {Function} requestHandler - Port-style async handler
|
|
99
|
+
* @param {{ error: Function }} logger
|
|
100
|
+
* @returns {(request: Request) => Promise<Response>}
|
|
101
|
+
*/
|
|
102
|
+
function createFetchHandler(requestHandler, logger) {
|
|
103
|
+
return async (request) => {
|
|
104
|
+
try {
|
|
105
|
+
const portReq = await toPortRequest(request);
|
|
106
|
+
const portRes = await requestHandler(portReq);
|
|
107
|
+
return toResponse(portRes);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.status === 413) {
|
|
110
|
+
return new Response(PAYLOAD_TOO_LARGE, {
|
|
111
|
+
status: 413,
|
|
112
|
+
headers: { 'Content-Type': 'text/plain', 'Content-Length': PAYLOAD_TOO_LARGE_LENGTH },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
logger.error('BunHttpAdapter dispatch error', err);
|
|
116
|
+
return new Response(ERROR_BODY, {
|
|
117
|
+
status: 500,
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'text/plain',
|
|
120
|
+
'Content-Length': ERROR_BODY_LENGTH,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Starts a Bun server and invokes the callback with (null) on success
|
|
129
|
+
* or (err) on failure.
|
|
130
|
+
*
|
|
131
|
+
* Note: Bun.serve() is synchronous, so cb fires on the same tick
|
|
132
|
+
* (unlike Node's server.listen which defers via the event loop).
|
|
133
|
+
*
|
|
134
|
+
* @param {{ port: number, hostname?: string, fetch: Function }} serveOptions
|
|
135
|
+
* @param {Function|undefined} cb - Node-style callback
|
|
136
|
+
* @returns {Object} The Bun server instance
|
|
137
|
+
*/
|
|
138
|
+
function startServer(serveOptions, cb) {
|
|
139
|
+
const server = globalThis.Bun.serve(serveOptions);
|
|
140
|
+
if (cb) {
|
|
141
|
+
cb(null);
|
|
142
|
+
}
|
|
143
|
+
return server;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Safely stops a Bun server, forwarding errors to the callback.
|
|
148
|
+
*
|
|
149
|
+
* @param {{ server: Object|null }} state - Shared mutable state
|
|
150
|
+
* @param {Function} [callback]
|
|
151
|
+
*/
|
|
152
|
+
function stopServer(state, callback) {
|
|
153
|
+
try {
|
|
154
|
+
if (state.server) {
|
|
155
|
+
state.server.stop();
|
|
156
|
+
state.server = null;
|
|
157
|
+
}
|
|
158
|
+
if (callback) {
|
|
159
|
+
callback();
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (callback) {
|
|
163
|
+
callback(err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const noopLogger = { error() {} };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bun HTTP adapter implementing HttpServerPort.
|
|
172
|
+
*
|
|
173
|
+
* Uses `globalThis.Bun.serve()` so the module can be imported on any
|
|
174
|
+
* runtime (it will throw at call-time if Bun is not available).
|
|
175
|
+
*
|
|
176
|
+
* @extends HttpServerPort
|
|
177
|
+
*/
|
|
178
|
+
export default class BunHttpAdapter extends HttpServerPort {
|
|
179
|
+
/**
|
|
180
|
+
* @param {{ logger?: { error: Function } }} [options]
|
|
181
|
+
*/
|
|
182
|
+
constructor({ logger } = {}) {
|
|
183
|
+
super();
|
|
184
|
+
this._logger = logger || noopLogger;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @inheritdoc */
|
|
188
|
+
createServer(requestHandler) {
|
|
189
|
+
const fetchHandler = createFetchHandler(requestHandler, this._logger);
|
|
190
|
+
const state = { server: null };
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
listen(port, host, callback) {
|
|
194
|
+
const cb = typeof host === 'function' ? host : callback;
|
|
195
|
+
const bindHost = typeof host === 'string' ? host : undefined;
|
|
196
|
+
const serveOptions = { port, fetch: fetchHandler };
|
|
197
|
+
|
|
198
|
+
if (bindHost !== undefined) {
|
|
199
|
+
serveOptions.hostname = bindHost;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
state.server = startServer(serveOptions, cb);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (cb) {
|
|
206
|
+
cb(err);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
close: (callback) => stopServer(state, callback),
|
|
212
|
+
|
|
213
|
+
address() {
|
|
214
|
+
if (!state.server) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
address: state.server.hostname,
|
|
219
|
+
port: state.server.port,
|
|
220
|
+
family: state.server.hostname.includes(':') ? 'IPv6' : 'IPv4',
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { performance as nodePerformance } from 'node:perf_hooks';
|
|
2
|
+
import ClockPort from '../../ports/ClockPort.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unified clock adapter supporting both Node.js and global performance APIs.
|
|
6
|
+
*
|
|
7
|
+
* Accepts an optional `performanceImpl` in the constructor, defaulting to
|
|
8
|
+
* `globalThis.performance`. Use the static factory methods for common cases:
|
|
9
|
+
*
|
|
10
|
+
* - `ClockAdapter.node()` — Node.js `perf_hooks.performance`
|
|
11
|
+
* - `ClockAdapter.global()` — `globalThis.performance` (Bun/Deno/browsers)
|
|
12
|
+
*
|
|
13
|
+
* @extends ClockPort
|
|
14
|
+
*/
|
|
15
|
+
export default class ClockAdapter extends ClockPort {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {Performance} [options.performanceImpl] - Performance API implementation.
|
|
19
|
+
* Defaults to `globalThis.performance`.
|
|
20
|
+
*/
|
|
21
|
+
constructor({ performanceImpl } = {}) {
|
|
22
|
+
super();
|
|
23
|
+
this._performance = performanceImpl || globalThis.performance;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a ClockAdapter using Node.js `perf_hooks.performance`.
|
|
28
|
+
* @returns {ClockAdapter}
|
|
29
|
+
*/
|
|
30
|
+
static node() {
|
|
31
|
+
return new ClockAdapter({ performanceImpl: nodePerformance });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a ClockAdapter using `globalThis.performance`.
|
|
36
|
+
* @returns {ClockAdapter}
|
|
37
|
+
*/
|
|
38
|
+
static global() {
|
|
39
|
+
return new ClockAdapter({ performanceImpl: globalThis.performance });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns a high-resolution timestamp in milliseconds.
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
now() {
|
|
47
|
+
return this._performance.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns the current wall-clock time as an ISO string.
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
timestamp() {
|
|
55
|
+
return new Date().toISOString();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import LoggerPort from '../../ports/LoggerPort.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Log levels in order of severity.
|
|
6
|
+
* @readonly
|
|
7
|
+
* @enum {number}
|
|
8
|
+
*/
|
|
9
|
+
export const LogLevel = Object.freeze({
|
|
10
|
+
DEBUG: 0,
|
|
11
|
+
INFO: 1,
|
|
12
|
+
WARN: 2,
|
|
13
|
+
ERROR: 3,
|
|
14
|
+
SILENT: 4,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map of level names to LogLevel values.
|
|
19
|
+
* @type {Record<string, number>}
|
|
20
|
+
*/
|
|
21
|
+
const LEVEL_NAMES = Object.freeze({
|
|
22
|
+
debug: LogLevel.DEBUG,
|
|
23
|
+
info: LogLevel.INFO,
|
|
24
|
+
warn: LogLevel.WARN,
|
|
25
|
+
error: LogLevel.ERROR,
|
|
26
|
+
silent: LogLevel.SILENT,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Console logger adapter with structured JSON output.
|
|
31
|
+
*
|
|
32
|
+
* Provides a production-ready implementation of LoggerPort that outputs
|
|
33
|
+
* structured JSON logs to the console. Supports log level filtering,
|
|
34
|
+
* timestamps, and child loggers with inherited context.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const logger = new ConsoleLogger({ level: LogLevel.INFO });
|
|
38
|
+
* logger.info('Server started', { port: 3000 });
|
|
39
|
+
* // Output: {"timestamp":"...","level":"INFO","message":"Server started","port":3000}
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const childLogger = logger.child({ requestId: 'abc-123' });
|
|
43
|
+
* childLogger.info('Request received');
|
|
44
|
+
* // Output: {"timestamp":"...","level":"INFO","message":"Request received","requestId":"abc-123"}
|
|
45
|
+
*/
|
|
46
|
+
export default class ConsoleLogger extends LoggerPort {
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new ConsoleLogger instance.
|
|
49
|
+
* @param {Object} [options] - Logger options
|
|
50
|
+
* @param {number} [options.level=LogLevel.INFO] - Minimum log level to output
|
|
51
|
+
* @param {Record<string, unknown>} [options.context={}] - Base context for all log entries
|
|
52
|
+
* @param {function(): string} [options.timestampFn] - Custom timestamp function (defaults to ISO string)
|
|
53
|
+
*/
|
|
54
|
+
constructor({ level = LogLevel.INFO, context = {}, timestampFn } = {}) {
|
|
55
|
+
super();
|
|
56
|
+
this._level = typeof level === 'string' ? (LEVEL_NAMES[level] ?? LogLevel.INFO) : level;
|
|
57
|
+
this._context = Object.freeze({ ...context });
|
|
58
|
+
this._timestampFn = timestampFn || (() => new Date().toISOString());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Log a debug-level message.
|
|
63
|
+
* @param {string} message - The log message
|
|
64
|
+
* @param {Record<string, unknown>} [context] - Additional structured metadata
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
debug(message, context) {
|
|
68
|
+
this._log({ level: LogLevel.DEBUG, levelName: 'DEBUG', message, context });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Log an info-level message.
|
|
73
|
+
* @param {string} message - The log message
|
|
74
|
+
* @param {Record<string, unknown>} [context] - Additional structured metadata
|
|
75
|
+
* @returns {void}
|
|
76
|
+
*/
|
|
77
|
+
info(message, context) {
|
|
78
|
+
this._log({ level: LogLevel.INFO, levelName: 'INFO', message, context });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Log a warning-level message.
|
|
83
|
+
* @param {string} message - The log message
|
|
84
|
+
* @param {Record<string, unknown>} [context] - Additional structured metadata
|
|
85
|
+
* @returns {void}
|
|
86
|
+
*/
|
|
87
|
+
warn(message, context) {
|
|
88
|
+
this._log({ level: LogLevel.WARN, levelName: 'WARN', message, context });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Log an error-level message.
|
|
93
|
+
* @param {string} message - The log message
|
|
94
|
+
* @param {Record<string, unknown>} [context] - Additional structured metadata
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
error(message, context) {
|
|
98
|
+
this._log({ level: LogLevel.ERROR, levelName: 'ERROR', message, context });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a child logger with additional base context.
|
|
103
|
+
* Child loggers inherit parent context and merge with their own.
|
|
104
|
+
* @param {Record<string, unknown>} context - Additional base context for the child
|
|
105
|
+
* @returns {ConsoleLogger} A new logger instance with merged context
|
|
106
|
+
*/
|
|
107
|
+
child(context) {
|
|
108
|
+
return new ConsoleLogger({
|
|
109
|
+
level: this._level,
|
|
110
|
+
context: { ...this._context, ...context },
|
|
111
|
+
timestampFn: this._timestampFn,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Internal logging implementation.
|
|
117
|
+
* @param {Object} opts - Log options
|
|
118
|
+
* @param {number} opts.level - Numeric log level
|
|
119
|
+
* @param {string} opts.levelName - String representation of level
|
|
120
|
+
* @param {string} opts.message - Log message
|
|
121
|
+
* @param {Record<string, unknown>} [opts.context] - Additional context
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
_log({ level, levelName, message, context }) {
|
|
125
|
+
if (level < this._level) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const entry = {
|
|
130
|
+
timestamp: this._timestampFn(),
|
|
131
|
+
level: levelName,
|
|
132
|
+
message,
|
|
133
|
+
...this._context,
|
|
134
|
+
...context,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const output = JSON.stringify(entry);
|
|
138
|
+
|
|
139
|
+
switch (level) {
|
|
140
|
+
case LogLevel.ERROR:
|
|
141
|
+
console.error(output);
|
|
142
|
+
break;
|
|
143
|
+
case LogLevel.WARN:
|
|
144
|
+
console.warn(output);
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
console.log(output);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|