@hearthlog/runtime 0.0.0-stage → 0.1.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/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- package/README.md +0 -3
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { StoredEvent } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const LEDGER_SOCKET_PROTOCOL = 'ledger.v1';
|
|
4
|
+
|
|
5
|
+
export type SocketUrlProvider = (afterVersion: number) => string;
|
|
6
|
+
|
|
7
|
+
export type SocketResyncReason = 'gap_too_large' | 'cursor_ahead';
|
|
8
|
+
|
|
9
|
+
export type SocketFrame =
|
|
10
|
+
| { type: 'event'; event: StoredEvent }
|
|
11
|
+
| { type: 'ready'; headVersion: number }
|
|
12
|
+
| { type: 'resync_required'; headVersion: number; reason: SocketResyncReason }
|
|
13
|
+
| { type: 'error'; code: string };
|
|
14
|
+
|
|
15
|
+
export enum SocketState {
|
|
16
|
+
CLOSED = 'CLOSED',
|
|
17
|
+
CONNECTING = 'CONNECTING',
|
|
18
|
+
OPEN = 'OPEN',
|
|
19
|
+
READY = 'READY',
|
|
20
|
+
RECONNECTING = 'RECONNECTING',
|
|
21
|
+
PAUSED = 'PAUSED',
|
|
22
|
+
ERROR = 'ERROR',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SocketProtocolError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'SocketProtocolError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EventSocketConfig {
|
|
33
|
+
afterVersion?: number;
|
|
34
|
+
urlProvider: SocketUrlProvider;
|
|
35
|
+
autoReconnect?: boolean;
|
|
36
|
+
webSocketFactory?: (url: string, protocol: string) => WebSocket;
|
|
37
|
+
random?: () => number;
|
|
38
|
+
isOnline?: () => boolean;
|
|
39
|
+
isVisible?: () => boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type HandlerMap = {
|
|
43
|
+
event: (event: StoredEvent) => void;
|
|
44
|
+
ready: (headVersion: number) => void;
|
|
45
|
+
resync_required: (headVersion: number, reason: SocketResyncReason) => void;
|
|
46
|
+
error: (error: Error) => void;
|
|
47
|
+
close: (event?: CloseEvent) => void;
|
|
48
|
+
open: () => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RECONNECT_DELAYS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000] as const;
|
|
52
|
+
|
|
53
|
+
export class EventSocket {
|
|
54
|
+
private readonly config: Required<
|
|
55
|
+
Pick<EventSocketConfig, 'autoReconnect' | 'webSocketFactory' | 'random' | 'isOnline' | 'isVisible'>
|
|
56
|
+
> & EventSocketConfig;
|
|
57
|
+
private readonly handlers: { [K in keyof HandlerMap]: Set<HandlerMap[K]> } = {
|
|
58
|
+
event: new Set(),
|
|
59
|
+
ready: new Set(),
|
|
60
|
+
resync_required: new Set(),
|
|
61
|
+
error: new Set(),
|
|
62
|
+
close: new Set(),
|
|
63
|
+
open: new Set(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
private socket: WebSocket | null = null;
|
|
67
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
private connectionGeneration = 0;
|
|
69
|
+
private reconnectAttempt = 0;
|
|
70
|
+
private state = SocketState.CLOSED;
|
|
71
|
+
private intentionallyStopped = true;
|
|
72
|
+
private paused = false;
|
|
73
|
+
private waitingForResync = false;
|
|
74
|
+
private receivedVersion: number;
|
|
75
|
+
private acknowledgedVersion: number;
|
|
76
|
+
|
|
77
|
+
constructor(config: EventSocketConfig) {
|
|
78
|
+
const afterVersion = assertVersion(config.afterVersion ?? 0, 'afterVersion');
|
|
79
|
+
this.receivedVersion = afterVersion;
|
|
80
|
+
this.acknowledgedVersion = afterVersion;
|
|
81
|
+
this.config = {
|
|
82
|
+
...config,
|
|
83
|
+
autoReconnect: config.autoReconnect ?? true,
|
|
84
|
+
webSocketFactory:
|
|
85
|
+
config.webSocketFactory ?? ((url, protocol) => new WebSocket(url, protocol)),
|
|
86
|
+
random: config.random ?? Math.random,
|
|
87
|
+
isOnline:
|
|
88
|
+
config.isOnline ??
|
|
89
|
+
(() => typeof navigator === 'undefined' || navigator.onLine !== false),
|
|
90
|
+
isVisible:
|
|
91
|
+
config.isVisible ??
|
|
92
|
+
(() => typeof document === 'undefined' || document.visibilityState !== 'hidden'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
on<K extends keyof HandlerMap>(event: K, handler: HandlerMap[K]): this {
|
|
97
|
+
this.handlers[event].add(handler);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
off<K extends keyof HandlerMap>(event: K, handler: HandlerMap[K]): this {
|
|
102
|
+
this.handlers[event].delete(handler);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async open(): Promise<void> {
|
|
107
|
+
this.intentionallyStopped = false;
|
|
108
|
+
this.paused = false;
|
|
109
|
+
if (this.waitingForResync || this.socket) return;
|
|
110
|
+
if (!this.canConnect()) {
|
|
111
|
+
this.state = SocketState.PAUSED;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await this.connect();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
close(): void {
|
|
118
|
+
this.intentionallyStopped = true;
|
|
119
|
+
this.paused = false;
|
|
120
|
+
this.waitingForResync = false;
|
|
121
|
+
this.connectionGeneration++;
|
|
122
|
+
this.cancelReconnect();
|
|
123
|
+
const socket = this.socket;
|
|
124
|
+
this.socket = null;
|
|
125
|
+
if (socket && socket.readyState < 2) {
|
|
126
|
+
socket.close(1000, 'client shutdown');
|
|
127
|
+
}
|
|
128
|
+
this.state = SocketState.CLOSED;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pause(): void {
|
|
132
|
+
this.paused = true;
|
|
133
|
+
this.connectionGeneration++;
|
|
134
|
+
this.cancelReconnect();
|
|
135
|
+
const socket = this.socket;
|
|
136
|
+
this.socket = null;
|
|
137
|
+
if (socket && socket.readyState < 2) {
|
|
138
|
+
socket.close(1000, 'client paused');
|
|
139
|
+
}
|
|
140
|
+
this.state = SocketState.PAUSED;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async resume(): Promise<void> {
|
|
144
|
+
if (this.intentionallyStopped || this.waitingForResync) return;
|
|
145
|
+
this.paused = false;
|
|
146
|
+
await this.open();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
isReady(): boolean {
|
|
150
|
+
return this.state === SocketState.READY;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
isOpen(): boolean {
|
|
154
|
+
return this.state === SocketState.OPEN || this.state === SocketState.READY;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getState(): SocketState {
|
|
158
|
+
return this.state;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getAcknowledgedVersion(): number {
|
|
162
|
+
return this.acknowledgedVersion;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
acknowledge(version: number): void {
|
|
166
|
+
const next = assertVersion(version, 'acknowledged version');
|
|
167
|
+
if (next > this.acknowledgedVersion) this.acknowledgedVersion = next;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
resetAfterVersion(version: number): void {
|
|
171
|
+
const next = assertVersion(version, 'reset version');
|
|
172
|
+
this.acknowledgedVersion = next;
|
|
173
|
+
this.receivedVersion = next;
|
|
174
|
+
this.waitingForResync = false;
|
|
175
|
+
this.reconnectAttempt = 0;
|
|
176
|
+
this.state = SocketState.CLOSED;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private connect(): void {
|
|
180
|
+
const generation = ++this.connectionGeneration;
|
|
181
|
+
try {
|
|
182
|
+
const url = this.config.urlProvider(this.acknowledgedVersion);
|
|
183
|
+
validateSocketUrl(url);
|
|
184
|
+
const socket = this.config.webSocketFactory(url, LEDGER_SOCKET_PROTOCOL);
|
|
185
|
+
this.socket = socket;
|
|
186
|
+
this.receivedVersion = this.acknowledgedVersion;
|
|
187
|
+
this.state = SocketState.CONNECTING;
|
|
188
|
+
socket.onopen = () => this.handleOpen(generation, socket);
|
|
189
|
+
socket.onmessage = (event) => this.handleMessage(generation, socket, event.data);
|
|
190
|
+
socket.onerror = () =>
|
|
191
|
+
this.emit('error', new Error('Ledger WebSocket transport error'));
|
|
192
|
+
socket.onclose = (event) => this.handleClose(generation, socket, event);
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
if (!this.isCurrent(generation)) return;
|
|
195
|
+
this.state = SocketState.ERROR;
|
|
196
|
+
this.emit('error', asError(cause));
|
|
197
|
+
this.scheduleReconnect();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private handleOpen(generation: number, socket: WebSocket): void {
|
|
202
|
+
if (!this.isSocketCurrent(generation, socket)) return;
|
|
203
|
+
if (socket.protocol !== LEDGER_SOCKET_PROTOCOL) {
|
|
204
|
+
this.protocolFailure(socket, `Expected ${LEDGER_SOCKET_PROTOCOL} subprotocol`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.state = SocketState.OPEN;
|
|
208
|
+
this.emit('open');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private handleMessage(generation: number, socket: WebSocket, data: unknown): void {
|
|
212
|
+
if (!this.isSocketCurrent(generation, socket)) return;
|
|
213
|
+
try {
|
|
214
|
+
if (typeof data !== 'string') throw new SocketProtocolError('Binary frames are unsupported');
|
|
215
|
+
let parsed: unknown;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(data) as unknown;
|
|
218
|
+
} catch {
|
|
219
|
+
throw new SocketProtocolError('Invalid JSON frame');
|
|
220
|
+
}
|
|
221
|
+
const frame = validateFrame(parsed);
|
|
222
|
+
switch (frame.type) {
|
|
223
|
+
case 'event':
|
|
224
|
+
if (frame.event.version > this.receivedVersion + 1) {
|
|
225
|
+
throw new SocketProtocolError(
|
|
226
|
+
`Non-contiguous event: expected ${this.receivedVersion + 1}, received ${frame.event.version}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (frame.event.version > this.receivedVersion) {
|
|
230
|
+
this.receivedVersion = frame.event.version;
|
|
231
|
+
}
|
|
232
|
+
this.emit('event', frame.event);
|
|
233
|
+
break;
|
|
234
|
+
case 'ready':
|
|
235
|
+
if (frame.headVersion < this.receivedVersion) {
|
|
236
|
+
throw new SocketProtocolError('ready head is behind received events');
|
|
237
|
+
}
|
|
238
|
+
this.reconnectAttempt = 0;
|
|
239
|
+
this.state = SocketState.READY;
|
|
240
|
+
this.emit('ready', frame.headVersion);
|
|
241
|
+
break;
|
|
242
|
+
case 'resync_required':
|
|
243
|
+
this.waitingForResync = true;
|
|
244
|
+
this.state = SocketState.PAUSED;
|
|
245
|
+
this.emit('resync_required', frame.headVersion, frame.reason);
|
|
246
|
+
socket.close(1000, 'HTTP resynchronization');
|
|
247
|
+
break;
|
|
248
|
+
case 'error':
|
|
249
|
+
throw new SocketProtocolError(`Server protocol error: ${frame.code}`);
|
|
250
|
+
}
|
|
251
|
+
} catch (cause) {
|
|
252
|
+
this.protocolFailure(socket, asError(cause));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private protocolFailure(socket: WebSocket, cause: string | Error): void {
|
|
257
|
+
this.state = SocketState.ERROR;
|
|
258
|
+
this.emit('error', typeof cause === 'string' ? new SocketProtocolError(cause) : cause);
|
|
259
|
+
socket.close(1002, 'protocol error');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private handleClose(generation: number, socket: WebSocket, event: CloseEvent): void {
|
|
263
|
+
if (!this.isSocketCurrent(generation, socket)) return;
|
|
264
|
+
this.socket = null;
|
|
265
|
+
if (!this.waitingForResync) this.state = SocketState.CLOSED;
|
|
266
|
+
this.emit('close', event);
|
|
267
|
+
this.scheduleReconnect();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private scheduleReconnect(): void {
|
|
271
|
+
if (
|
|
272
|
+
!this.config.autoReconnect ||
|
|
273
|
+
this.intentionallyStopped ||
|
|
274
|
+
this.paused ||
|
|
275
|
+
this.waitingForResync ||
|
|
276
|
+
!this.canConnect() ||
|
|
277
|
+
this.reconnectTimer
|
|
278
|
+
) {
|
|
279
|
+
if (!this.canConnect() && !this.intentionallyStopped) this.state = SocketState.PAUSED;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const base = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
283
|
+
this.reconnectAttempt++;
|
|
284
|
+
const jitter = 0.8 + this.config.random() * 0.4;
|
|
285
|
+
const delay = Math.min(30_000, Math.round(base * jitter));
|
|
286
|
+
this.state = SocketState.RECONNECTING;
|
|
287
|
+
this.reconnectTimer = setTimeout(() => {
|
|
288
|
+
this.reconnectTimer = null;
|
|
289
|
+
if (this.canConnect()) this.connect();
|
|
290
|
+
else this.state = SocketState.PAUSED;
|
|
291
|
+
}, delay);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private canConnect(): boolean {
|
|
295
|
+
return this.config.isOnline() && this.config.isVisible();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private isCurrent(generation: number): boolean {
|
|
299
|
+
return generation === this.connectionGeneration && !this.intentionallyStopped;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private isSocketCurrent(generation: number, socket: WebSocket): boolean {
|
|
303
|
+
return this.isCurrent(generation) && this.socket === socket;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private cancelReconnect(): void {
|
|
307
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
308
|
+
this.reconnectTimer = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private emit<K extends keyof HandlerMap>(
|
|
312
|
+
event: K,
|
|
313
|
+
...args: Parameters<HandlerMap[K]>
|
|
314
|
+
): void {
|
|
315
|
+
for (const handler of this.handlers[event]) {
|
|
316
|
+
try {
|
|
317
|
+
(handler as (...values: Parameters<HandlerMap[K]>) => void)(...args);
|
|
318
|
+
} catch (cause) {
|
|
319
|
+
console.error(`[EventSocket] ${event} handler failed`, cause);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function validateSocketUrl(value: string): void {
|
|
326
|
+
if (
|
|
327
|
+
typeof value !== 'string' ||
|
|
328
|
+
(!value.startsWith('wss:') && !value.startsWith('ws:'))
|
|
329
|
+
) {
|
|
330
|
+
throw new SocketProtocolError('Invalid Ledger WebSocket URL');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateFrame(value: unknown): SocketFrame {
|
|
335
|
+
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
336
|
+
throw new SocketProtocolError('Invalid frame envelope');
|
|
337
|
+
}
|
|
338
|
+
switch (value.type) {
|
|
339
|
+
case 'event':
|
|
340
|
+
if (!isStoredEvent(value.event)) throw new SocketProtocolError('Invalid event frame');
|
|
341
|
+
return { type: 'event', event: value.event };
|
|
342
|
+
case 'ready':
|
|
343
|
+
return { type: 'ready', headVersion: assertVersion(value.headVersion, 'headVersion') };
|
|
344
|
+
case 'resync_required':
|
|
345
|
+
if (value.reason !== 'gap_too_large' && value.reason !== 'cursor_ahead') {
|
|
346
|
+
throw new SocketProtocolError('Invalid resynchronization reason');
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
type: 'resync_required',
|
|
350
|
+
headVersion: assertVersion(value.headVersion, 'headVersion'),
|
|
351
|
+
reason: value.reason,
|
|
352
|
+
};
|
|
353
|
+
case 'error':
|
|
354
|
+
if (typeof value.code !== 'string' || value.code.length === 0) {
|
|
355
|
+
throw new SocketProtocolError('Invalid error frame');
|
|
356
|
+
}
|
|
357
|
+
return { type: 'error', code: value.code };
|
|
358
|
+
default:
|
|
359
|
+
throw new SocketProtocolError(`Unknown frame type: ${value.type}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isStoredEvent(value: unknown): value is StoredEvent {
|
|
364
|
+
return (
|
|
365
|
+
isRecord(value) &&
|
|
366
|
+
isVersion(value.version) &&
|
|
367
|
+
value.version > 0 &&
|
|
368
|
+
typeof value.id === 'string' &&
|
|
369
|
+
value.id.length > 0 &&
|
|
370
|
+
typeof value.type === 'string' &&
|
|
371
|
+
value.type.length > 0 &&
|
|
372
|
+
typeof value.entityId === 'string' &&
|
|
373
|
+
value.entityId.length > 0 &&
|
|
374
|
+
isVersion(value.entityVersion) &&
|
|
375
|
+
value.entityVersion > 0 &&
|
|
376
|
+
isVersion(value.timestamp) &&
|
|
377
|
+
isRecord(value.payload)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
382
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isVersion(value: unknown): value is number {
|
|
386
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function assertVersion(value: unknown, name: string): number {
|
|
390
|
+
if (!isVersion(value)) throw new SocketProtocolError(`${name} must be a nonnegative safe integer`);
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function asError(value: unknown): Error {
|
|
395
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function createEventSocket(config: EventSocketConfig): EventSocket {
|
|
399
|
+
return new EventSocket(config);
|
|
400
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript type definitions matching the Event Store Service OpenAPI specification v0.4.0
|
|
3
|
+
* These types represent the API contract between the client and the Ledger Service.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Log Management Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Summary information about a log
|
|
12
|
+
*/
|
|
13
|
+
export interface LogSummary {
|
|
14
|
+
/** Server-generated log identifier */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Human-readable log name */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Timestamp when the log was created. */
|
|
19
|
+
createdAt?: string;
|
|
20
|
+
/** Current highest version in the log */
|
|
21
|
+
headVersion: number;
|
|
22
|
+
/** Timestamp of the most recent update (ISO 8601 format) */
|
|
23
|
+
lastUpdatedAt: string | null;
|
|
24
|
+
/** Provenance for a materialized fork. */
|
|
25
|
+
sourceLogId?: string | null;
|
|
26
|
+
sourceLogName?: string | null;
|
|
27
|
+
sourceVersion?: number | null;
|
|
28
|
+
/** Timestamp of the selected source event when a version exists. */
|
|
29
|
+
sourceTimestamp?: string | null;
|
|
30
|
+
status?: 'ready' | 'preparing';
|
|
31
|
+
/** When the log was hidden from the log tree; missing from older Ledger responses. */
|
|
32
|
+
hiddenAt?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ForkLogRequest {
|
|
36
|
+
name: string;
|
|
37
|
+
targetVersion: number;
|
|
38
|
+
expectedSourceHeadVersion: number;
|
|
39
|
+
requestId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Response containing a list of logs
|
|
44
|
+
*/
|
|
45
|
+
export interface LogListResponse {
|
|
46
|
+
logs: LogSummary[];
|
|
47
|
+
/** Server-ordered fork relationships for the log tree. */
|
|
48
|
+
lineage?: LogLineageIndex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LogLineageIndex {
|
|
52
|
+
rootIds: string[];
|
|
53
|
+
childIdsByLogId: Record<string, string[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Request to create a new log
|
|
58
|
+
*/
|
|
59
|
+
export interface CreateLogRequest {
|
|
60
|
+
/** Optional display name for the log */
|
|
61
|
+
name?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Response after creating a new log
|
|
66
|
+
*/
|
|
67
|
+
export interface CreateLogResponse {
|
|
68
|
+
/** Newly created log identifier */
|
|
69
|
+
id: string;
|
|
70
|
+
/** Effective log name (may be generated when none provided) */
|
|
71
|
+
name: string;
|
|
72
|
+
/** Initial head version (typically 0 for new logs) */
|
|
73
|
+
headVersion: number;
|
|
74
|
+
/** Timestamp when the log was created (ISO 8601 format) */
|
|
75
|
+
createdAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detailed metadata about a log (same as LogSummary)
|
|
80
|
+
*/
|
|
81
|
+
export type LogMetadata = LogSummary;
|
|
82
|
+
|
|
83
|
+
export interface SessionResponse {
|
|
84
|
+
capabilities?: string[];
|
|
85
|
+
appId: string;
|
|
86
|
+
principalId: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ErrorResponse {
|
|
90
|
+
error: string;
|
|
91
|
+
code: string;
|
|
92
|
+
message?: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Event Types
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Event being sent to the server for appending
|
|
101
|
+
*/
|
|
102
|
+
export interface IncomingEvent {
|
|
103
|
+
/** Optional event identifier supplied by the client (UUID format) */
|
|
104
|
+
id?: string;
|
|
105
|
+
/** Domain event type (e.g., 'CALENDAR_EVENT_ADDED') */
|
|
106
|
+
type: string;
|
|
107
|
+
/** Identifier of the entity affected by the event */
|
|
108
|
+
entityId: string;
|
|
109
|
+
/**
|
|
110
|
+
* Expected entity version for optimistic locking.
|
|
111
|
+
* Required when appending to an existing entity.
|
|
112
|
+
* Omit only when creating a new entity (first event for that entityId).
|
|
113
|
+
*/
|
|
114
|
+
baseEntityVersion?: number;
|
|
115
|
+
/** Optional client timestamp in milliseconds since Unix epoch */
|
|
116
|
+
timestamp?: number;
|
|
117
|
+
/** Event payload to persist as-is */
|
|
118
|
+
payload: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Event as stored and returned by the server
|
|
123
|
+
*/
|
|
124
|
+
export interface StoredEvent {
|
|
125
|
+
/** Complete accepted action; absent for legacy events. */
|
|
126
|
+
action?: { id: string; index: number; size: number };
|
|
127
|
+
/** Monotonic sequence number within the log (global log version) */
|
|
128
|
+
version: number;
|
|
129
|
+
/** Event identifier (typically a UUID) */
|
|
130
|
+
id: string;
|
|
131
|
+
/** Domain event type (e.g., 'CALENDAR_EVENT_ADDED') */
|
|
132
|
+
type: string;
|
|
133
|
+
/** Identifier of the entity associated with the event */
|
|
134
|
+
entityId: string;
|
|
135
|
+
/** Monotonic sequence number specific to this entity (starts at 1) */
|
|
136
|
+
entityVersion: number;
|
|
137
|
+
/** Milliseconds since Unix epoch when the event was committed */
|
|
138
|
+
timestamp: number;
|
|
139
|
+
/** Event-specific data */
|
|
140
|
+
payload: Record<string, unknown>;
|
|
141
|
+
/** Identity the gateway verified when the event was accepted; absent for older events. */
|
|
142
|
+
actor?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Batch of events returned by query endpoints
|
|
147
|
+
*/
|
|
148
|
+
export interface EventBatch {
|
|
149
|
+
events: StoredEvent[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Event delivered via realtime transport (same shape as StoredEvent)
|
|
154
|
+
*/
|
|
155
|
+
export type RealtimeEvent = StoredEvent;
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Event Append Types
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Request to append events to a log
|
|
163
|
+
*/
|
|
164
|
+
export interface AppendEventsRequest {
|
|
165
|
+
expectedLogVersion?: number;
|
|
166
|
+
/** Stable identity of a complete user action, when supported by the server. */
|
|
167
|
+
actionId?: string;
|
|
168
|
+
/** Client-generated UUID used for idempotency */
|
|
169
|
+
requestId: string;
|
|
170
|
+
/** Events to append (in desired order) */
|
|
171
|
+
events: IncomingEvent[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Response after successfully appending events
|
|
176
|
+
*/
|
|
177
|
+
export interface AppendEventsResponse {
|
|
178
|
+
/** Version assigned to the last persisted event */
|
|
179
|
+
newVersion: number;
|
|
180
|
+
/** Events actually stored (with server-assigned ids/timestamps/versions) */
|
|
181
|
+
events: StoredEvent[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Response when a version conflict occurs (HTTP 409)
|
|
186
|
+
*/
|
|
187
|
+
export interface ConflictResponse {
|
|
188
|
+
code: "entity_conflict";
|
|
189
|
+
/** The entity identifier that caused the conflict */
|
|
190
|
+
entityId: string;
|
|
191
|
+
/** Current version of the conflicting entity */
|
|
192
|
+
currentEntityVersion: number;
|
|
193
|
+
/** Current head version of the entire log (for context) */
|
|
194
|
+
currentLogVersion: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface EntityVersionChainErrorResponse extends ErrorResponse {
|
|
198
|
+
code: "invalid_entity_version_chain";
|
|
199
|
+
entityId: string;
|
|
200
|
+
expectedBaseEntityVersion: number | null;
|
|
201
|
+
actualBaseEntityVersion: number | null;
|
|
202
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Version Tracker Singleton
|
|
3
|
+
*
|
|
4
|
+
* Provides a shared instance of EntityVersionTracker for use throughout the app.
|
|
5
|
+
* This ensures consistent version tracking between sync, rebuild, and handlers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from '../db/index.js';
|
|
9
|
+
import { EntityVersionTracker } from './version-tracker.js';
|
|
10
|
+
|
|
11
|
+
/** Shared version tracker instance */
|
|
12
|
+
export const versionTracker = new EntityVersionTracker(db);
|