@hearthlog/ledger 0.0.0-stage → 0.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 +235 -0
- package/README.md +173 -2
- package/contracts/append-hash.json +30 -0
- package/docs/database-structure.md +176 -0
- package/docs/event-store-service-design.md +124 -0
- package/docs/event-store-service.http +182 -0
- package/docs/event-store-service.openapi.yaml +1651 -0
- package/package.json +43 -3
- package/scripts/logical-backup.ts +656 -0
- package/src/action-batches.ts +65 -0
- package/src/app-registry.ts +479 -0
- package/src/auth.ts +186 -0
- package/src/contracts.ts +251 -0
- package/src/domain.ts +245 -0
- package/src/errors.ts +50 -0
- package/src/event-validation.ts +181 -0
- package/src/fence.ts +76 -0
- package/src/http.ts +656 -0
- package/src/index.ts +3 -0
- package/src/ledger-app.ts +2057 -0
- package/src/rpc.ts +42 -0
- package/src/schema.ts +236 -0
- package/src/schemas.ts +429 -0
- package/src/sqlite-export.ts +136 -0
- package/src/transfer.ts +219 -0
|
@@ -0,0 +1,2057 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
|
|
3
|
+
import { SOCKET_PROTOCOL } from "./auth";
|
|
4
|
+
import {
|
|
5
|
+
INTERNAL_SOCKET_ACCESS_HEADER,
|
|
6
|
+
type AccessClaims,
|
|
7
|
+
type InitializeRequest,
|
|
8
|
+
parseAccessClaims,
|
|
9
|
+
decodeRpc,
|
|
10
|
+
parseInitializeRequest,
|
|
11
|
+
parseNonnegativeInteger,
|
|
12
|
+
parseRevokeRequest,
|
|
13
|
+
parseRotateRequest,
|
|
14
|
+
parseSocketAttachment,
|
|
15
|
+
parseStoredAppendEventsResponse,
|
|
16
|
+
parseUuid,
|
|
17
|
+
type RevokeRequest,
|
|
18
|
+
type RotateRequest,
|
|
19
|
+
type SocketAttachment,
|
|
20
|
+
type SocketFrame,
|
|
21
|
+
} from "./contracts";
|
|
22
|
+
import { effectiveValidationMode, findPayloadViolations, parseEventPolicy, type EventPolicy } from "./event-validation";
|
|
23
|
+
import {
|
|
24
|
+
buildStoredEvent,
|
|
25
|
+
normalizeActor,
|
|
26
|
+
fileReference,
|
|
27
|
+
hashAppendRequest,
|
|
28
|
+
validateEntityChain,
|
|
29
|
+
validateIncomingEvents,
|
|
30
|
+
} from "./domain";
|
|
31
|
+
import {
|
|
32
|
+
errorResponse,
|
|
33
|
+
jsonError,
|
|
34
|
+
LedgerError,
|
|
35
|
+
normalizeError,
|
|
36
|
+
validationError,
|
|
37
|
+
} from "./errors";
|
|
38
|
+
import { type RpcResult, rpcCall, rpcSuccess } from "./rpc";
|
|
39
|
+
import { fenceError, parseFence, validateFenceRequest, type Fence, type FenceRequest } from "./fence";
|
|
40
|
+
import { migrateLedgerApp } from "./schema";
|
|
41
|
+
import {
|
|
42
|
+
chainChecksum,
|
|
43
|
+
checksumLine,
|
|
44
|
+
parseTransferRecords,
|
|
45
|
+
TRANSFER_FORMAT,
|
|
46
|
+
TRANSFER_PAGE,
|
|
47
|
+
TRANSFER_VERSION,
|
|
48
|
+
transferError,
|
|
49
|
+
type LogChecksum,
|
|
50
|
+
type TransferRecord,
|
|
51
|
+
type TransferTotals,
|
|
52
|
+
} from "./transfer";
|
|
53
|
+
import { sqliteExport } from "./sqlite-export";
|
|
54
|
+
import {
|
|
55
|
+
applicationSummary,
|
|
56
|
+
appendEventsBody,
|
|
57
|
+
type ApplicationSummary,
|
|
58
|
+
type AppendEventsRequest,
|
|
59
|
+
type AppendEventsResponse,
|
|
60
|
+
type CreateLogRequest,
|
|
61
|
+
type CreateLogResponse,
|
|
62
|
+
createLogBody,
|
|
63
|
+
forkLogBody,
|
|
64
|
+
logVisibilityBody,
|
|
65
|
+
logLineageBody,
|
|
66
|
+
type ForkLogRequest,
|
|
67
|
+
type LogVisibilityRequest,
|
|
68
|
+
type EventBatchResponse,
|
|
69
|
+
type LogSummary,
|
|
70
|
+
type StoredEvent,
|
|
71
|
+
adminEventsQuery,
|
|
72
|
+
type AdminEventsQuery,
|
|
73
|
+
type AdminEventsResponse,
|
|
74
|
+
type AdminMetrics,
|
|
75
|
+
adminConnectionsQuery,
|
|
76
|
+
type AdminConnectionsQuery,
|
|
77
|
+
type AdminConnectionsResponse,
|
|
78
|
+
type AdminConnection,
|
|
79
|
+
type AdminRecentConnection,
|
|
80
|
+
} from "./schemas";
|
|
81
|
+
|
|
82
|
+
const MAX_CATCH_UP = 512;
|
|
83
|
+
// Sessions kept for the administrator console, including live ones.
|
|
84
|
+
const RECENT_SOCKET_SESSIONS = 50;
|
|
85
|
+
// Durable Object SQLite limits a row to 2 MB; leave room for the other
|
|
86
|
+
// append_requests columns.
|
|
87
|
+
const MAX_APPEND_RESPONSE_BYTES = 1_900_000;
|
|
88
|
+
const ADMIN_EXPORT_FORMAT = "ledger-application-export";
|
|
89
|
+
const ADMIN_EXPORT_VERSION = 1;
|
|
90
|
+
|
|
91
|
+
interface MetadataRow extends Record<string, SqlStorageValue> {
|
|
92
|
+
app_id: string;
|
|
93
|
+
principal_id: string;
|
|
94
|
+
principal_generation: number;
|
|
95
|
+
revoked_at: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface LogRow extends Record<string, SqlStorageValue> {
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
version: number;
|
|
102
|
+
created_at: string;
|
|
103
|
+
last_updated_at: string | null;
|
|
104
|
+
source_log_id: string | null;
|
|
105
|
+
source_log_name: string | null;
|
|
106
|
+
source_version: number | null;
|
|
107
|
+
source_timestamp: string | null;
|
|
108
|
+
status: 'ready' | 'preparing';
|
|
109
|
+
hidden_at: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface EventRow extends Record<string, SqlStorageValue> {
|
|
113
|
+
version: number;
|
|
114
|
+
event_id: string;
|
|
115
|
+
event_type: string;
|
|
116
|
+
entity_id: string;
|
|
117
|
+
entity_version: number;
|
|
118
|
+
event_timestamp: string;
|
|
119
|
+
payload: string;
|
|
120
|
+
action_id: string | null;
|
|
121
|
+
action_index: number | null;
|
|
122
|
+
action_size: number | null;
|
|
123
|
+
actor: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface SocketSessionRow extends Record<string, SqlStorageValue> {
|
|
127
|
+
connection_id: string;
|
|
128
|
+
log_id: string;
|
|
129
|
+
principal_id: string;
|
|
130
|
+
principal_generation: number;
|
|
131
|
+
connected_at: string;
|
|
132
|
+
disconnected_at: string | null;
|
|
133
|
+
close_code: number | null;
|
|
134
|
+
cursor: number;
|
|
135
|
+
last_sent_at: string | null;
|
|
136
|
+
user_agent: string | null;
|
|
137
|
+
ip: string | null;
|
|
138
|
+
country: string | null;
|
|
139
|
+
colo: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class LedgerApp extends DurableObject<Env> {
|
|
143
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
144
|
+
super(ctx, env);
|
|
145
|
+
this.ctx.blockConcurrencyWhile(async () => {
|
|
146
|
+
migrateLedgerApp(this.ctx.storage);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override async fetch(request: Request): Promise<Response> {
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
const pathname = url.pathname;
|
|
153
|
+
const segments = routeSegments(pathname);
|
|
154
|
+
if (
|
|
155
|
+
request.method === "GET" &&
|
|
156
|
+
segments.length === 4 &&
|
|
157
|
+
segments[0] === "logs" &&
|
|
158
|
+
segments[2] === "events" &&
|
|
159
|
+
segments[3] === "socket" &&
|
|
160
|
+
pathname === `/logs/${segments[1]}/events/socket`
|
|
161
|
+
) {
|
|
162
|
+
try {
|
|
163
|
+
const access = internalSocketAccess(request);
|
|
164
|
+
const logId = parseUuid(segments[1], "logId");
|
|
165
|
+
const afterVersion = parseNonnegativeInteger(
|
|
166
|
+
Number(url.searchParams.get("afterVersion") ?? 0),
|
|
167
|
+
"afterVersion",
|
|
168
|
+
);
|
|
169
|
+
return this.upgradeSocket(request, access, logId, afterVersion);
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
if (cause instanceof LedgerError) {
|
|
172
|
+
return errorResponse(cause);
|
|
173
|
+
}
|
|
174
|
+
console.error(
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
message: "Ledger WebSocket upgrade failed",
|
|
177
|
+
error: normalizeError(cause).message,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
return jsonError("internal_error", "internal error", 500);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return jsonError("not_found", "resource not found", 404);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
override webSocketMessage(
|
|
187
|
+
ws: WebSocket,
|
|
188
|
+
_message: string | ArrayBuffer,
|
|
189
|
+
): void {
|
|
190
|
+
this.sendFrame(ws, {
|
|
191
|
+
type: "error",
|
|
192
|
+
code: "client_messages_not_supported",
|
|
193
|
+
});
|
|
194
|
+
ws.close(1008, "server-to-client protocol");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
override webSocketClose(
|
|
198
|
+
ws: WebSocket,
|
|
199
|
+
code: number,
|
|
200
|
+
_reason: string,
|
|
201
|
+
_wasClean: boolean,
|
|
202
|
+
): void {
|
|
203
|
+
this.recordSocketDisconnect(ws, code);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
override webSocketError(ws: WebSocket, cause: unknown): void {
|
|
207
|
+
console.error(
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
message: "Ledger WebSocket error",
|
|
210
|
+
error: normalizeError(cause).message,
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
this.recordSocketDisconnect(ws, null);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async getSession(
|
|
217
|
+
access: AccessClaims,
|
|
218
|
+
): Promise<RpcResult<{ appId: string; principalId: string; capabilities: string[]; fence?: Fence }>> {
|
|
219
|
+
return rpcCall(() => {
|
|
220
|
+
const claims = this.currentAccess(access);
|
|
221
|
+
const fence = this.fence();
|
|
222
|
+
return rpcSuccess({
|
|
223
|
+
appId: claims.appId,
|
|
224
|
+
principalId: claims.principalId,
|
|
225
|
+
capabilities: ["action-metadata-v1", "workspace-restore-v1", "event-excision-v1"],
|
|
226
|
+
...(fence ? { fence } : {}),
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async listLogs(
|
|
232
|
+
access: AccessClaims,
|
|
233
|
+
): Promise<RpcResult<{ logs: LogSummary[] }>> {
|
|
234
|
+
return rpcCall(() => {
|
|
235
|
+
this.currentAccess(access);
|
|
236
|
+
return rpcSuccess(this.listLogsValue());
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async administratorListLogs(
|
|
241
|
+
appId: string,
|
|
242
|
+
): Promise<RpcResult<{ logs: LogSummary[] }>> {
|
|
243
|
+
return rpcCall(() => {
|
|
244
|
+
this.administratorApplication(appId);
|
|
245
|
+
return rpcSuccess(this.listLogsValue());
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async administratorSetLogLineage(appId: string, logId: string, payload: unknown): Promise<RpcResult<LogSummary>> {
|
|
250
|
+
return rpcCall(() => {
|
|
251
|
+
this.administratorApplication(appId);
|
|
252
|
+
const { sourceLogId, sourceVersion } = decodeRpc(logLineageBody, payload, 'log lineage');
|
|
253
|
+
this.ctx.storage.transactionSync(() => {
|
|
254
|
+
const target = this.log(logId);
|
|
255
|
+
const source = this.log(sourceLogId);
|
|
256
|
+
if (!target || !source || logId === sourceLogId) throw validationError('Source and target logs must exist and differ');
|
|
257
|
+
if (target.source_log_id) {
|
|
258
|
+
if (target.source_log_id === sourceLogId && target.source_version === sourceVersion) return;
|
|
259
|
+
throw validationError('Log lineage is already set');
|
|
260
|
+
}
|
|
261
|
+
if (sourceVersion > source.version || sourceVersion > target.version) {
|
|
262
|
+
throw validationError('Source version exceeds a log head');
|
|
263
|
+
}
|
|
264
|
+
let ancestor = source;
|
|
265
|
+
while (ancestor.source_log_id) {
|
|
266
|
+
if (ancestor.source_log_id === logId) throw validationError('Log lineage cannot form a cycle');
|
|
267
|
+
const next = this.log(ancestor.source_log_id);
|
|
268
|
+
if (!next) throw validationError('Source ancestry is incomplete');
|
|
269
|
+
ancestor = next;
|
|
270
|
+
}
|
|
271
|
+
const mismatches = this.ctx.storage.sql.exec<{ count: number }>(
|
|
272
|
+
`SELECT COUNT(*) AS count FROM log_events AS child
|
|
273
|
+
LEFT JOIN log_events AS parent ON parent.log_id = ? AND parent.version = child.version
|
|
274
|
+
WHERE child.log_id = ? AND child.version <= ?
|
|
275
|
+
AND (parent.event_id IS NULL OR parent.event_id != child.event_id OR parent.payload != child.payload)`,
|
|
276
|
+
sourceLogId, logId, sourceVersion,
|
|
277
|
+
).toArray()[0]?.count ?? 0;
|
|
278
|
+
if (mismatches) throw validationError('Fork prefix does not match its source');
|
|
279
|
+
this.ctx.storage.sql.exec(
|
|
280
|
+
'UPDATE logs SET source_log_id = ?, source_version = ? WHERE id = ?',
|
|
281
|
+
sourceLogId, sourceVersion, logId,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
return rpcSuccess(this.getLogValue(logId));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async administratorExportApplication(
|
|
289
|
+
application: ApplicationSummary,
|
|
290
|
+
): Promise<Response> {
|
|
291
|
+
const parsed = decodeRpc(
|
|
292
|
+
applicationSummary,
|
|
293
|
+
application,
|
|
294
|
+
"application summary",
|
|
295
|
+
);
|
|
296
|
+
this.administratorApplication(parsed.id);
|
|
297
|
+
const logs = this.logRows().map(logSummary);
|
|
298
|
+
const exportedAt = new Date().toISOString();
|
|
299
|
+
return new Response(
|
|
300
|
+
byteStream(this.applicationExportChunks(parsed, logs, exportedAt)),
|
|
301
|
+
{
|
|
302
|
+
headers: {
|
|
303
|
+
"Cache-Control": "no-store",
|
|
304
|
+
"Content-Disposition": `attachment; filename="${exportFilename(parsed.name, exportedAt)}"`,
|
|
305
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
306
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
307
|
+
"X-Content-Type-Options": "nosniff",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async administratorMetrics(
|
|
314
|
+
appId: string,
|
|
315
|
+
): Promise<RpcResult<AdminMetrics>> {
|
|
316
|
+
return rpcCall(() => {
|
|
317
|
+
this.administratorApplication(appId);
|
|
318
|
+
const logs = this.logRows();
|
|
319
|
+
const { count } = this.ctx.storage.sql
|
|
320
|
+
.exec<{ count: number }>(
|
|
321
|
+
"SELECT COUNT(*) AS count FROM entity_claims",
|
|
322
|
+
)
|
|
323
|
+
.one();
|
|
324
|
+
return rpcSuccess({
|
|
325
|
+
sampledAt: new Date().toISOString(),
|
|
326
|
+
logCount: logs.length,
|
|
327
|
+
eventCount: logs.reduce((total, log) => total + log.version, 0),
|
|
328
|
+
entityCount: count,
|
|
329
|
+
databaseBytes: this.ctx.storage.sql.databaseSize,
|
|
330
|
+
connectedClients: this.ctx.getWebSockets().length,
|
|
331
|
+
lastUpdatedAt: logs.reduce<string | null>(
|
|
332
|
+
(latest, log) =>
|
|
333
|
+
log.last_updated_at &&
|
|
334
|
+
(!latest || log.last_updated_at > latest)
|
|
335
|
+
? log.last_updated_at
|
|
336
|
+
: latest,
|
|
337
|
+
null,
|
|
338
|
+
),
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async administratorExportSqlite(appId: string): Promise<Response> {
|
|
344
|
+
this.administratorApplication(appId);
|
|
345
|
+
return sqliteExport(
|
|
346
|
+
this.ctx.storage,
|
|
347
|
+
`ledger-${appId}-${new Date().toISOString().replaceAll(":", "-")}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async administratorConnections(
|
|
352
|
+
appId: string,
|
|
353
|
+
query: AdminConnectionsQuery,
|
|
354
|
+
): Promise<RpcResult<AdminConnectionsResponse>> {
|
|
355
|
+
return rpcCall(() => {
|
|
356
|
+
this.administratorApplication(appId);
|
|
357
|
+
const { afterId } = decodeRpc(
|
|
358
|
+
adminConnectionsQuery,
|
|
359
|
+
query,
|
|
360
|
+
"connections query",
|
|
361
|
+
);
|
|
362
|
+
const sockets = this.ctx.getWebSockets();
|
|
363
|
+
const logs = new Map(this.logRows().map((log) => [log.id, log]));
|
|
364
|
+
let unavailable = 0;
|
|
365
|
+
const connections: AdminConnection[] = [];
|
|
366
|
+
for (const socket of sockets) {
|
|
367
|
+
let attachment: SocketAttachment;
|
|
368
|
+
try {
|
|
369
|
+
attachment = parseSocketAttachment(
|
|
370
|
+
socket.deserializeAttachment(),
|
|
371
|
+
);
|
|
372
|
+
} catch {
|
|
373
|
+
unavailable++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!attachment.connectionId) {
|
|
377
|
+
// Existing hibernated sockets predate connection metadata.
|
|
378
|
+
// Assign identity, but leave unknown timestamps/client info unknown.
|
|
379
|
+
attachment.connectionId = crypto.randomUUID();
|
|
380
|
+
socket.serializeAttachment(attachment);
|
|
381
|
+
}
|
|
382
|
+
const log = logs.get(attachment.logId);
|
|
383
|
+
connections.push({
|
|
384
|
+
id: attachment.connectionId,
|
|
385
|
+
logId: attachment.logId,
|
|
386
|
+
logName: log?.name ?? null,
|
|
387
|
+
principalId: attachment.principalId,
|
|
388
|
+
generation: attachment.generation,
|
|
389
|
+
state:
|
|
390
|
+
(["connecting", "open", "closing", "closed"] as const)[
|
|
391
|
+
socket.readyState
|
|
392
|
+
] ?? "closed",
|
|
393
|
+
cursor: attachment.cursor,
|
|
394
|
+
headVersion: log?.version ?? null,
|
|
395
|
+
connectedAt: attachment.connectedAt ?? null,
|
|
396
|
+
lastSentAt: attachment.lastSentAt ?? null,
|
|
397
|
+
client: attachment.client ?? {
|
|
398
|
+
userAgent: null,
|
|
399
|
+
ip: null,
|
|
400
|
+
country: null,
|
|
401
|
+
colo: null,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
connections.sort((a, b) => a.id.localeCompare(b.id));
|
|
406
|
+
const remaining = connections.filter(
|
|
407
|
+
(item) => !afterId || item.id > afterId,
|
|
408
|
+
);
|
|
409
|
+
const page = remaining.slice(0, 100);
|
|
410
|
+
const live = new Set(connections.map((item) => item.id));
|
|
411
|
+
const recent: AdminRecentConnection[] = this.ctx.storage.sql
|
|
412
|
+
.exec<SocketSessionRow>(
|
|
413
|
+
`SELECT connection_id, log_id, principal_id,
|
|
414
|
+
principal_generation, connected_at, disconnected_at,
|
|
415
|
+
close_code, cursor, last_sent_at, user_agent, ip,
|
|
416
|
+
country, colo
|
|
417
|
+
FROM socket_sessions
|
|
418
|
+
ORDER BY connected_at DESC, connection_id DESC
|
|
419
|
+
LIMIT ?`,
|
|
420
|
+
RECENT_SOCKET_SESSIONS,
|
|
421
|
+
)
|
|
422
|
+
.toArray()
|
|
423
|
+
.filter((row) => !live.has(row.connection_id))
|
|
424
|
+
.map((row) => ({
|
|
425
|
+
id: row.connection_id,
|
|
426
|
+
logId: row.log_id,
|
|
427
|
+
logName: logs.get(row.log_id)?.name ?? null,
|
|
428
|
+
principalId: row.principal_id,
|
|
429
|
+
generation: row.principal_generation,
|
|
430
|
+
cursor: row.cursor,
|
|
431
|
+
connectedAt: row.connected_at,
|
|
432
|
+
disconnectedAt: row.disconnected_at,
|
|
433
|
+
closeCode: row.close_code,
|
|
434
|
+
lastSentAt: row.last_sent_at,
|
|
435
|
+
client: {
|
|
436
|
+
userAgent: row.user_agent,
|
|
437
|
+
ip: row.ip,
|
|
438
|
+
country: row.country,
|
|
439
|
+
colo: row.colo,
|
|
440
|
+
},
|
|
441
|
+
}));
|
|
442
|
+
return rpcSuccess({
|
|
443
|
+
sampledAt: new Date().toISOString(),
|
|
444
|
+
total: sockets.length,
|
|
445
|
+
unavailable,
|
|
446
|
+
connections: page,
|
|
447
|
+
nextAfterId:
|
|
448
|
+
remaining.length > page.length ? page.at(-1)!.id : null,
|
|
449
|
+
recent,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async administratorListEvents(
|
|
455
|
+
appId: string,
|
|
456
|
+
logId: string,
|
|
457
|
+
query: AdminEventsQuery,
|
|
458
|
+
): Promise<RpcResult<AdminEventsResponse>> {
|
|
459
|
+
return rpcCall(() => {
|
|
460
|
+
this.administratorApplication(appId);
|
|
461
|
+
const id = parseUuid(logId, "log id");
|
|
462
|
+
const parsed = decodeRpc(
|
|
463
|
+
adminEventsQuery,
|
|
464
|
+
query,
|
|
465
|
+
"admin events query",
|
|
466
|
+
);
|
|
467
|
+
const log = this.log(id);
|
|
468
|
+
if (!log)
|
|
469
|
+
throw new LedgerError(404, {
|
|
470
|
+
error: "log not found",
|
|
471
|
+
code: "log_not_found",
|
|
472
|
+
});
|
|
473
|
+
const limit = parsed.limit ?? 50;
|
|
474
|
+
const search = parsed.search?.trim().toLowerCase();
|
|
475
|
+
const conditions = ["log_id = ?"];
|
|
476
|
+
const bindings: SqlStorageValue[] = [id];
|
|
477
|
+
if (parsed.beforeVersion !== undefined) {
|
|
478
|
+
conditions.push("version < ?");
|
|
479
|
+
bindings.push(parsed.beforeVersion);
|
|
480
|
+
}
|
|
481
|
+
if (search) {
|
|
482
|
+
conditions.push(
|
|
483
|
+
"(instr(lower(event_type), ?) > 0 OR instr(lower(entity_id), ?) > 0 OR instr(lower(event_id), ?) > 0)",
|
|
484
|
+
);
|
|
485
|
+
bindings.push(search, search, search);
|
|
486
|
+
}
|
|
487
|
+
const rows = this.ctx.storage.sql
|
|
488
|
+
.exec<EventRow>(
|
|
489
|
+
`SELECT version, event_id, event_type, entity_id, entity_version, event_timestamp, payload, action_id, action_index, action_size, actor
|
|
490
|
+
FROM log_events WHERE ${conditions.join(" AND ")} ORDER BY version DESC LIMIT ?`,
|
|
491
|
+
...bindings,
|
|
492
|
+
limit + 1,
|
|
493
|
+
)
|
|
494
|
+
.toArray();
|
|
495
|
+
const events = rows.slice(0, limit).map(storedEvent);
|
|
496
|
+
return rpcSuccess({
|
|
497
|
+
events,
|
|
498
|
+
headVersion: log.version,
|
|
499
|
+
nextBeforeVersion:
|
|
500
|
+
rows.length > limit ? events.at(-1)!.version : null,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async createLog(
|
|
506
|
+
access: AccessClaims,
|
|
507
|
+
payload: CreateLogRequest,
|
|
508
|
+
): Promise<RpcResult<CreateLogResponse>> {
|
|
509
|
+
return rpcCall(() => {
|
|
510
|
+
const claims = this.writableAccess(access);
|
|
511
|
+
return rpcSuccess(
|
|
512
|
+
this.createLogValue(
|
|
513
|
+
decodeRpc(createLogBody, payload, "create log request"),
|
|
514
|
+
claims,
|
|
515
|
+
),
|
|
516
|
+
201,
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async forkLog(
|
|
522
|
+
access: AccessClaims,
|
|
523
|
+
sourceLogId: string,
|
|
524
|
+
payload: ForkLogRequest,
|
|
525
|
+
): Promise<RpcResult<LogSummary>> {
|
|
526
|
+
return rpcCall(() => {
|
|
527
|
+
const claims = this.writableAccess(access);
|
|
528
|
+
return rpcSuccess(this.forkLogValue(
|
|
529
|
+
parseUuid(sourceLogId, 'source log id'),
|
|
530
|
+
decodeRpc(forkLogBody, payload, 'fork log request'),
|
|
531
|
+
claims,
|
|
532
|
+
), 201);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async finalizeFork(access: AccessClaims, logId: string): Promise<RpcResult<LogSummary>> {
|
|
537
|
+
return rpcCall(() => {
|
|
538
|
+
const claims = this.writableAccess(access);
|
|
539
|
+
const id = parseUuid(logId, 'fork log id');
|
|
540
|
+
this.ctx.storage.transactionSync(() => {
|
|
541
|
+
this.currentAccess(claims);
|
|
542
|
+
const row = this.log(id);
|
|
543
|
+
if (!row || !row.source_log_id) throw new LedgerError(404, {
|
|
544
|
+
error: 'fork not found', code: 'fork_not_found',
|
|
545
|
+
});
|
|
546
|
+
this.ctx.storage.sql.exec("UPDATE logs SET status = 'ready' WHERE id = ?", id);
|
|
547
|
+
});
|
|
548
|
+
return rpcSuccess(this.getLogValue(id));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async getLog(
|
|
553
|
+
access: AccessClaims,
|
|
554
|
+
logId: string,
|
|
555
|
+
): Promise<RpcResult<LogSummary>> {
|
|
556
|
+
return rpcCall(() => {
|
|
557
|
+
this.currentAccess(access);
|
|
558
|
+
return rpcSuccess(this.getLogValue(parseUuid(logId, "logId")));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async getGalleryPhotoReference(
|
|
563
|
+
access: AccessClaims,
|
|
564
|
+
logId: string,
|
|
565
|
+
photoId: string,
|
|
566
|
+
fileVersionId: string,
|
|
567
|
+
): Promise<RpcResult<{ present: boolean }>> {
|
|
568
|
+
return rpcCall(() => {
|
|
569
|
+
this.currentAccess(access);
|
|
570
|
+
const id = parseUuid(logId, 'log id');
|
|
571
|
+
const photo = parseUuid(photoId, 'gallery photo id');
|
|
572
|
+
const file = parseUuid(fileVersionId, 'gallery file version id');
|
|
573
|
+
const log = this.log(id);
|
|
574
|
+
if (!log) throw new LedgerError(404, { error: 'log not found', code: 'log_not_found' });
|
|
575
|
+
if (log.status !== 'ready') throw new LedgerError(409, {
|
|
576
|
+
error: 'Fork preparing', code: 'fork_preparing',
|
|
577
|
+
});
|
|
578
|
+
const present = this.ctx.storage.sql.exec(
|
|
579
|
+
`SELECT 1 FROM gallery_photo_references
|
|
580
|
+
WHERE log_id = ? AND photo_id = ? AND file_version_id = ? LIMIT 1`,
|
|
581
|
+
id, photo, file,
|
|
582
|
+
).toArray().length > 0;
|
|
583
|
+
return rpcSuccess({ present });
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async setLogVisibility(
|
|
588
|
+
access: AccessClaims,
|
|
589
|
+
logId: string,
|
|
590
|
+
payload: LogVisibilityRequest,
|
|
591
|
+
): Promise<RpcResult<LogSummary>> {
|
|
592
|
+
return rpcCall(() => {
|
|
593
|
+
const claims = this.writableAccess(access);
|
|
594
|
+
const id = parseUuid(logId, "logId");
|
|
595
|
+
const { hidden } = decodeRpc(logVisibilityBody, payload, "log visibility request");
|
|
596
|
+
this.ctx.storage.transactionSync(() => {
|
|
597
|
+
this.currentAccess(claims);
|
|
598
|
+
if (!this.log(id)) throw new LedgerError(404, { error: "log not found", code: "log_not_found" });
|
|
599
|
+
// Hiding again keeps the first time, so retries do not move it.
|
|
600
|
+
this.ctx.storage.sql.exec(
|
|
601
|
+
"UPDATE logs SET hidden_at = CASE WHEN ? THEN COALESCE(hidden_at, ?) ELSE NULL END WHERE id = ?",
|
|
602
|
+
hidden ? 1 : 0, new Date().toISOString(), id,
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
return rpcSuccess(this.getLogValue(id));
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async deleteLog(
|
|
610
|
+
access: AccessClaims,
|
|
611
|
+
logId: string,
|
|
612
|
+
): Promise<RpcResult<null>> {
|
|
613
|
+
return rpcCall(() => {
|
|
614
|
+
const claims = this.writableAccess(access);
|
|
615
|
+
this.deleteLogValue(parseUuid(logId, "logId"), claims);
|
|
616
|
+
return rpcSuccess(null, 204);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async listEvents(
|
|
621
|
+
access: AccessClaims,
|
|
622
|
+
logId: string,
|
|
623
|
+
afterVersion: number | undefined,
|
|
624
|
+
limit: number | undefined,
|
|
625
|
+
replayVersion = 0,
|
|
626
|
+
): Promise<RpcResult<EventBatchResponse>> {
|
|
627
|
+
return rpcCall(() => {
|
|
628
|
+
this.currentAccess(access);
|
|
629
|
+
const id = parseUuid(logId, "logId");
|
|
630
|
+
this.assertReplayCompatible(id, replayVersion);
|
|
631
|
+
return rpcSuccess(this.getEventsValue(id, afterVersion, limit));
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async appendEvents(
|
|
636
|
+
access: AccessClaims,
|
|
637
|
+
logId: string,
|
|
638
|
+
payload: AppendEventsRequest,
|
|
639
|
+
replayVersion = 0,
|
|
640
|
+
actor: string | null = null,
|
|
641
|
+
): Promise<RpcResult<AppendEventsResponse>> {
|
|
642
|
+
return rpcCall(async () => {
|
|
643
|
+
const claims = this.writableAccess(access);
|
|
644
|
+
return rpcSuccess(
|
|
645
|
+
await this.append(
|
|
646
|
+
parseUuid(logId, "logId"),
|
|
647
|
+
decodeRpc(
|
|
648
|
+
appendEventsBody,
|
|
649
|
+
payload,
|
|
650
|
+
"append events request",
|
|
651
|
+
),
|
|
652
|
+
claims,
|
|
653
|
+
replayVersion,
|
|
654
|
+
normalizeActor(actor),
|
|
655
|
+
),
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async initializeApplication(
|
|
661
|
+
payload: InitializeRequest,
|
|
662
|
+
): Promise<RpcResult<null>> {
|
|
663
|
+
return rpcCall(() => {
|
|
664
|
+
const parsed = parseInitializeRequest(payload);
|
|
665
|
+
if (this.metadataOptional() === undefined) {
|
|
666
|
+
this.ctx.storage.sql.exec(
|
|
667
|
+
`INSERT INTO app_metadata (
|
|
668
|
+
singleton, app_id, name, principal_id,
|
|
669
|
+
principal_generation, created_at, token_created_at, event_policy, import_state
|
|
670
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
671
|
+
parsed.appId,
|
|
672
|
+
parsed.name,
|
|
673
|
+
parsed.principalId,
|
|
674
|
+
parsed.generation,
|
|
675
|
+
parsed.createdAt,
|
|
676
|
+
parsed.createdAt,
|
|
677
|
+
parsed.eventPolicy ?? null,
|
|
678
|
+
parsed.importing ? "importing" : null,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return rpcSuccess(null, 204);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The application's history as NDJSON records, with everything an identity-preserving import needs. */
|
|
686
|
+
async administratorTransferExport(appId: string): Promise<Response> {
|
|
687
|
+
try {
|
|
688
|
+
this.administratorApplication(appId);
|
|
689
|
+
// A fork still copying its files has no complete history to move.
|
|
690
|
+
const preparing = this.ctx.storage.sql.exec<{ id: string }>("SELECT id FROM logs WHERE status != 'ready'").toArray();
|
|
691
|
+
if (preparing.length) {
|
|
692
|
+
throw transferError("fork_preparing", `Log ${preparing[0]!.id} is still preparing; finish or wait for it before exporting`);
|
|
693
|
+
}
|
|
694
|
+
} catch (cause) {
|
|
695
|
+
if (cause instanceof LedgerError) return errorResponse(cause);
|
|
696
|
+
throw cause;
|
|
697
|
+
}
|
|
698
|
+
return new Response(byteStream(this.transferChunks(appId)), {
|
|
699
|
+
headers: {
|
|
700
|
+
"Cache-Control": "no-store",
|
|
701
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
702
|
+
"X-Content-Type-Options": "nosniff",
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** One digest per log over its events, for comparing a source and its imported copy. */
|
|
708
|
+
async administratorChecksums(appId: string): Promise<RpcResult<{ logs: LogChecksum[] }>> {
|
|
709
|
+
try {
|
|
710
|
+
this.administratorApplication(appId);
|
|
711
|
+
const logs: LogChecksum[] = [];
|
|
712
|
+
for (const log of this.ctx.storage.sql.exec<{ id: string; version: number }>("SELECT id, version FROM logs ORDER BY id").toArray()) {
|
|
713
|
+
let digest = "";
|
|
714
|
+
let count = 0;
|
|
715
|
+
let after = 0;
|
|
716
|
+
for (;;) {
|
|
717
|
+
const rows = this.ctx.storage.sql.exec<EventRow>(
|
|
718
|
+
`SELECT version, event_id, event_type, entity_id, entity_version, event_timestamp, payload,
|
|
719
|
+
action_id, action_index, action_size, actor
|
|
720
|
+
FROM log_events WHERE log_id = ? AND version > ? ORDER BY version LIMIT ?`,
|
|
721
|
+
log.id, after, TRANSFER_PAGE,
|
|
722
|
+
).toArray();
|
|
723
|
+
if (rows.length === 0) break;
|
|
724
|
+
digest = await chainChecksum(digest, rows.map(checksumLine));
|
|
725
|
+
count += rows.length;
|
|
726
|
+
after = rows[rows.length - 1]!.version;
|
|
727
|
+
}
|
|
728
|
+
logs.push({ id: log.id, headVersion: log.version, eventCount: count, sha256: digest });
|
|
729
|
+
}
|
|
730
|
+
return rpcSuccess({ logs });
|
|
731
|
+
} catch (cause) {
|
|
732
|
+
if (cause instanceof LedgerError) return { ok: false, status: cause.status, body: cause.body };
|
|
733
|
+
throw cause;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Applies one chunk of an identity-preserving import; the application must still be empty and importing. */
|
|
738
|
+
async importRecords(appId: string, records: unknown): Promise<RpcResult<TransferTotals>> {
|
|
739
|
+
return rpcCall(() => {
|
|
740
|
+
this.administratorApplication(appId);
|
|
741
|
+
if (this.importState() !== "importing") {
|
|
742
|
+
throw transferError("not_importing", "This application is not accepting an import");
|
|
743
|
+
}
|
|
744
|
+
const parsed = parseTransferRecords(records);
|
|
745
|
+
const totals: TransferTotals = { logs: 0, events: 0, requests: 0 };
|
|
746
|
+
this.ctx.storage.transactionSync(() => {
|
|
747
|
+
for (const record of parsed) this.importRecord(record, totals);
|
|
748
|
+
});
|
|
749
|
+
return rpcSuccess(totals);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Empties an application whose import failed, so the import can start again. Refused once it has finished. */
|
|
754
|
+
async abortImport(appId: string): Promise<RpcResult<null>> {
|
|
755
|
+
return rpcCall(() => {
|
|
756
|
+
this.administratorApplication(appId);
|
|
757
|
+
if (this.importState() !== "importing") {
|
|
758
|
+
throw transferError("not_importing", "Only an import in progress can be aborted");
|
|
759
|
+
}
|
|
760
|
+
this.ctx.storage.transactionSync(() => {
|
|
761
|
+
for (const table of ["gallery_photo_references", "log_fork_requests", "append_requests", "entity_claims", "log_events", "logs", "app_metadata"]) {
|
|
762
|
+
this.ctx.storage.sql.exec(`DELETE FROM ${table}`);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
return rpcSuccess(null, 204);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Checks the imported history against what the source said it held, then opens the application. */
|
|
770
|
+
async finishImport(appId: string, expected: { logs: Array<{ id: string; headVersion: number }> }): Promise<RpcResult<{ logs: number; events: number }>> {
|
|
771
|
+
return rpcCall(() => {
|
|
772
|
+
this.administratorApplication(appId);
|
|
773
|
+
if (this.importState() !== "importing") {
|
|
774
|
+
throw transferError("not_importing", "This application is not accepting an import");
|
|
775
|
+
}
|
|
776
|
+
const actual = this.ctx.storage.sql.exec<{ id: string; version: number; events: number }>(
|
|
777
|
+
`SELECT logs.id AS id, logs.version AS version,
|
|
778
|
+
(SELECT COUNT(*) FROM log_events WHERE log_id = logs.id) AS events
|
|
779
|
+
FROM logs ORDER BY logs.id`,
|
|
780
|
+
).toArray();
|
|
781
|
+
const wanted = new Map(expected.logs.map((log) => [log.id.toLowerCase(), log.headVersion]));
|
|
782
|
+
if (wanted.size !== actual.length) {
|
|
783
|
+
throw transferError("import_incomplete", `Expected ${wanted.size} logs, found ${actual.length}`);
|
|
784
|
+
}
|
|
785
|
+
let events = 0;
|
|
786
|
+
for (const log of actual) {
|
|
787
|
+
const head = wanted.get(log.id);
|
|
788
|
+
if (head === undefined) throw transferError("import_incomplete", `Log ${log.id} was not expected`);
|
|
789
|
+
if (log.version !== head || log.events !== head) {
|
|
790
|
+
throw transferError("import_incomplete", `Log ${log.id} holds ${log.events} events at version ${log.version}; expected ${head}`);
|
|
791
|
+
}
|
|
792
|
+
events += log.events;
|
|
793
|
+
}
|
|
794
|
+
this.ctx.storage.sql.exec("UPDATE app_metadata SET import_state = NULL WHERE singleton = 1");
|
|
795
|
+
return rpcSuccess({ logs: actual.length, events });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Sets or lifts the write fence. Reads stay open; see fence.ts. */
|
|
800
|
+
async setFence(request: FenceRequest): Promise<RpcResult<{ fence: Fence | null }>> {
|
|
801
|
+
return rpcCall(() => {
|
|
802
|
+
this.metadata();
|
|
803
|
+
const fence = validateFenceRequest(request);
|
|
804
|
+
this.ctx.storage.sql.exec(
|
|
805
|
+
"UPDATE app_metadata SET fence_mode = ?, fence_destination = ? WHERE singleton = 1",
|
|
806
|
+
fence?.mode ?? null,
|
|
807
|
+
fence?.destination ?? null,
|
|
808
|
+
);
|
|
809
|
+
// A paused or moved workspace should not keep serving live batches as if nothing changed.
|
|
810
|
+
if (fence) this.closeAllSockets(1013, fence.mode);
|
|
811
|
+
return rpcSuccess({ fence });
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async getFence(): Promise<RpcResult<{ fence: Fence | null }>> {
|
|
816
|
+
return rpcCall(() => {
|
|
817
|
+
this.metadata();
|
|
818
|
+
return rpcSuccess({ fence: this.fence() });
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Replaces the application's event policy; appends already accepted are never re-judged. */
|
|
823
|
+
async setEventPolicy(policy: EventPolicy): Promise<RpcResult<EventPolicy>> {
|
|
824
|
+
return rpcCall(() => {
|
|
825
|
+
this.metadata();
|
|
826
|
+
this.ctx.storage.sql.exec(
|
|
827
|
+
"UPDATE app_metadata SET event_policy = ? WHERE singleton = 1",
|
|
828
|
+
JSON.stringify(policy),
|
|
829
|
+
);
|
|
830
|
+
return rpcSuccess(policy);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The application's event policy; null for an application that predates policies. */
|
|
835
|
+
async getEventPolicy(): Promise<RpcResult<{ policy: EventPolicy | null }>> {
|
|
836
|
+
return rpcCall(() => {
|
|
837
|
+
this.metadata();
|
|
838
|
+
return rpcSuccess({ policy: this.eventPolicy() });
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async rotateApplicationPrincipal(
|
|
843
|
+
payload: RotateRequest,
|
|
844
|
+
): Promise<RpcResult<null>> {
|
|
845
|
+
return rpcCall(() => {
|
|
846
|
+
const parsed = parseRotateRequest(payload);
|
|
847
|
+
// Generations only move forward, so a delayed or concurrent
|
|
848
|
+
// rotation can never replace a newer principal.
|
|
849
|
+
const rotated = this.ctx.storage.sql.exec(
|
|
850
|
+
`UPDATE app_metadata
|
|
851
|
+
SET principal_id = ?, principal_generation = ?, token_created_at = ?
|
|
852
|
+
WHERE singleton = 1 AND revoked_at IS NULL
|
|
853
|
+
AND principal_generation < ?
|
|
854
|
+
RETURNING singleton`,
|
|
855
|
+
parsed.principalId,
|
|
856
|
+
parsed.generation,
|
|
857
|
+
parsed.createdAt,
|
|
858
|
+
parsed.generation,
|
|
859
|
+
).toArray();
|
|
860
|
+
if (rotated.length === 0) {
|
|
861
|
+
throw new LedgerError(409, {
|
|
862
|
+
error: "principal was already rotated or revoked",
|
|
863
|
+
code: "rotation_superseded",
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
this.closeAllSockets(1008, "principal_rotated");
|
|
867
|
+
return rpcSuccess(null, 204);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async revokeApplicationState(
|
|
872
|
+
payload: RevokeRequest,
|
|
873
|
+
): Promise<RpcResult<null>> {
|
|
874
|
+
return rpcCall(() => {
|
|
875
|
+
const parsed = parseRevokeRequest(payload);
|
|
876
|
+
this.ctx.storage.sql.exec(
|
|
877
|
+
"UPDATE app_metadata SET revoked_at = ? WHERE singleton = 1",
|
|
878
|
+
parsed.revokedAt,
|
|
879
|
+
);
|
|
880
|
+
this.closeAllSockets(1008, "application_revoked");
|
|
881
|
+
return rpcSuccess(null, 204);
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private currentAccess(value: AccessClaims): AccessClaims {
|
|
886
|
+
const access = parseAccessClaims(value);
|
|
887
|
+
const metadata = this.metadata();
|
|
888
|
+
if (
|
|
889
|
+
metadata.revoked_at !== null ||
|
|
890
|
+
access.appId !== metadata.app_id ||
|
|
891
|
+
access.principalId !== metadata.principal_id ||
|
|
892
|
+
access.generation !== metadata.principal_generation
|
|
893
|
+
) {
|
|
894
|
+
throw new LedgerError(401, {
|
|
895
|
+
error: "access token has been rotated or revoked",
|
|
896
|
+
code: "unauthorized",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
return access;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private administratorApplication(appId: string): void {
|
|
903
|
+
const parsed = parseUuid(appId, "application id");
|
|
904
|
+
if (this.metadata().app_id !== parsed) {
|
|
905
|
+
throw new LedgerError(404, {
|
|
906
|
+
error: "application not found",
|
|
907
|
+
code: "not_found",
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private listLogsValue() {
|
|
913
|
+
const logs = this.logRows().map(logSummary);
|
|
914
|
+
const logIds = new Set(logs.map(log => log.id));
|
|
915
|
+
const rootIds: string[] = [];
|
|
916
|
+
const childIdsByLogId: Record<string, string[]> = Object.fromEntries(
|
|
917
|
+
logs.map(log => [log.id, []]),
|
|
918
|
+
);
|
|
919
|
+
for (const log of logs) {
|
|
920
|
+
if (log.sourceLogId && logIds.has(log.sourceLogId)) {
|
|
921
|
+
(childIdsByLogId[log.sourceLogId] ??= []).push(log.id);
|
|
922
|
+
} else {
|
|
923
|
+
rootIds.push(log.id);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return { logs, lineage: { rootIds, childIdsByLogId } };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private logRows(): LogRow[] {
|
|
930
|
+
return this.ctx.storage.sql
|
|
931
|
+
.exec<LogRow>(
|
|
932
|
+
`SELECT child.id, child.name, child.version, child.created_at, child.last_updated_at,
|
|
933
|
+
child.source_log_id, parent.name AS source_log_name, child.source_version,
|
|
934
|
+
(SELECT event_timestamp FROM log_events
|
|
935
|
+
WHERE log_id = child.source_log_id AND version = child.source_version) AS source_timestamp,
|
|
936
|
+
child.status, child.hidden_at
|
|
937
|
+
FROM logs AS child LEFT JOIN logs AS parent ON parent.id = child.source_log_id
|
|
938
|
+
ORDER BY child.created_at`,
|
|
939
|
+
)
|
|
940
|
+
.toArray();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private createLogValue(
|
|
944
|
+
payload: CreateLogRequest,
|
|
945
|
+
access: AccessClaims,
|
|
946
|
+
): CreateLogResponse {
|
|
947
|
+
const requestedName = payload.name ?? undefined;
|
|
948
|
+
if (requestedName !== undefined && requestedName.trim() === "") {
|
|
949
|
+
throw validationError("name must not be empty");
|
|
950
|
+
}
|
|
951
|
+
const id = crypto.randomUUID();
|
|
952
|
+
const name = requestedName ?? `log-${id.replaceAll("-", "")}`;
|
|
953
|
+
const createdAt = new Date().toISOString();
|
|
954
|
+
this.ctx.storage.transactionSync(() => {
|
|
955
|
+
this.currentAccess(access);
|
|
956
|
+
this.ctx.storage.sql.exec(
|
|
957
|
+
"INSERT INTO logs (id, name, created_at) VALUES (?, ?, ?)",
|
|
958
|
+
id,
|
|
959
|
+
name,
|
|
960
|
+
createdAt,
|
|
961
|
+
);
|
|
962
|
+
});
|
|
963
|
+
return { id, name, headVersion: 0, createdAt };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private forkLogValue(sourceLogId: string, payload: ForkLogRequest, access: AccessClaims): LogSummary {
|
|
967
|
+
const name = payload.name.trim();
|
|
968
|
+
if (!name) throw validationError('name must not be empty');
|
|
969
|
+
const { targetVersion, expectedSourceHeadVersion, requestId } = payload;
|
|
970
|
+
const forkId = this.ctx.storage.transactionSync(() => {
|
|
971
|
+
this.currentAccess(access);
|
|
972
|
+
const prior = this.ctx.storage.sql.exec<{
|
|
973
|
+
source_log_id: string; target_version: number; expected_source_head_version: number;
|
|
974
|
+
name: string; fork_log_id: string;
|
|
975
|
+
}>('SELECT * FROM log_fork_requests WHERE request_id = ?', requestId).toArray()[0];
|
|
976
|
+
if (prior) {
|
|
977
|
+
if (prior.source_log_id !== sourceLogId || prior.target_version !== targetVersion
|
|
978
|
+
|| prior.expected_source_head_version !== expectedSourceHeadVersion || prior.name !== name) {
|
|
979
|
+
throw new LedgerError(409, { error: 'Conflict', code: 'idempotency_mismatch' });
|
|
980
|
+
}
|
|
981
|
+
return prior.fork_log_id;
|
|
982
|
+
}
|
|
983
|
+
const source = this.log(sourceLogId);
|
|
984
|
+
if (!source || source.status !== 'ready') throw new LedgerError(404, {
|
|
985
|
+
error: 'source log not found', code: 'log_not_found',
|
|
986
|
+
});
|
|
987
|
+
if (source.version !== expectedSourceHeadVersion) throw new LedgerError(409, {
|
|
988
|
+
error: 'Conflict', code: 'stale_fork_preview', message: 'The source log changed. Review this point again.',
|
|
989
|
+
});
|
|
990
|
+
if (targetVersion > source.version) throw validationError('Fork point exceeds the source head');
|
|
991
|
+
if (targetVersion > 0) {
|
|
992
|
+
const boundary = this.ctx.storage.sql.exec<{
|
|
993
|
+
action_id: string | null; action_index: number | null; action_size: number | null;
|
|
994
|
+
}>('SELECT action_id, action_index, action_size FROM log_events WHERE log_id = ? AND version = ?',
|
|
995
|
+
sourceLogId, targetVersion).toArray()[0];
|
|
996
|
+
if (!boundary || (boundary.action_id !== null && boundary.action_index !== boundary.action_size! - 1)) {
|
|
997
|
+
throw validationError('Fork point must end a complete accepted action');
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const id = crypto.randomUUID();
|
|
1001
|
+
const now = new Date().toISOString();
|
|
1002
|
+
this.ctx.storage.sql.exec(
|
|
1003
|
+
`INSERT INTO logs (id, name, version, created_at, last_updated_at, source_log_id, source_version, status)
|
|
1004
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'preparing')`,
|
|
1005
|
+
id, name, targetVersion, now, now, sourceLogId, targetVersion,
|
|
1006
|
+
);
|
|
1007
|
+
this.ctx.storage.sql.exec(
|
|
1008
|
+
`INSERT INTO log_events (log_id, version, event_id, event_type, entity_id, entity_version,
|
|
1009
|
+
event_timestamp, payload, action_id, action_index, action_size, actor)
|
|
1010
|
+
SELECT ?, version, event_id, event_type, entity_id, entity_version,
|
|
1011
|
+
event_timestamp, payload, action_id, action_index, action_size, actor
|
|
1012
|
+
FROM log_events WHERE log_id = ? AND version <= ? ORDER BY version`,
|
|
1013
|
+
id, sourceLogId, targetVersion,
|
|
1014
|
+
);
|
|
1015
|
+
this.ctx.storage.sql.exec(
|
|
1016
|
+
`INSERT INTO entity_claims (log_id, entity_id, current_version, version_created, created_at, updated_at)
|
|
1017
|
+
SELECT ?, source.entity_id, source.entity_version, first_version.first_version, ?, ?
|
|
1018
|
+
FROM log_events AS source
|
|
1019
|
+
JOIN (SELECT entity_id, MAX(version) AS last_version, MIN(version) AS first_version
|
|
1020
|
+
FROM log_events WHERE log_id = ? AND version <= ? GROUP BY entity_id) AS first_version
|
|
1021
|
+
ON first_version.entity_id = source.entity_id AND first_version.last_version = source.version
|
|
1022
|
+
WHERE source.log_id = ?`,
|
|
1023
|
+
id, now, now, sourceLogId, targetVersion, sourceLogId,
|
|
1024
|
+
);
|
|
1025
|
+
this.ctx.storage.sql.exec(
|
|
1026
|
+
`INSERT INTO gallery_photo_references (log_id, photo_id, file_version_id, source_version)
|
|
1027
|
+
SELECT ?, photo_id, file_version_id, source_version
|
|
1028
|
+
FROM gallery_photo_references WHERE log_id = ? AND source_version <= ?`,
|
|
1029
|
+
id, sourceLogId, targetVersion,
|
|
1030
|
+
);
|
|
1031
|
+
this.ctx.storage.sql.exec(
|
|
1032
|
+
`INSERT INTO log_fork_requests (request_id, source_log_id, target_version,
|
|
1033
|
+
expected_source_head_version, name, fork_log_id) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1034
|
+
requestId, sourceLogId, targetVersion, expectedSourceHeadVersion, name, id,
|
|
1035
|
+
);
|
|
1036
|
+
return id;
|
|
1037
|
+
});
|
|
1038
|
+
return this.getLogValue(forkId);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private getLogValue(logId: string): LogSummary {
|
|
1042
|
+
const row = this.log(logId);
|
|
1043
|
+
if (!row) {
|
|
1044
|
+
throw new LedgerError(404, {
|
|
1045
|
+
error: "log not found",
|
|
1046
|
+
code: "log_not_found",
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
return logSummary(row);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private deleteLogValue(logId: string, access: AccessClaims): void {
|
|
1053
|
+
if (!this.log(logId)) {
|
|
1054
|
+
throw new LedgerError(404, {
|
|
1055
|
+
error: "log not found",
|
|
1056
|
+
code: "log_not_found",
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
this.ctx.storage.transactionSync(() => {
|
|
1060
|
+
this.currentAccess(access);
|
|
1061
|
+
if (this.ctx.storage.sql.exec('SELECT 1 FROM logs WHERE source_log_id = ? LIMIT 1', logId).toArray().length) {
|
|
1062
|
+
throw new LedgerError(409, { error: 'Conflict', code: 'log_has_forks',
|
|
1063
|
+
message: 'Remove descendant logs before deleting their source.' });
|
|
1064
|
+
}
|
|
1065
|
+
for (const query of [
|
|
1066
|
+
"DELETE FROM log_fork_requests WHERE fork_log_id = ?",
|
|
1067
|
+
"DELETE FROM append_requests WHERE log_id = ?",
|
|
1068
|
+
"DELETE FROM gallery_photo_references WHERE log_id = ?",
|
|
1069
|
+
"DELETE FROM entity_claims WHERE log_id = ?",
|
|
1070
|
+
"DELETE FROM log_events WHERE log_id = ?",
|
|
1071
|
+
"DELETE FROM logs WHERE id = ?",
|
|
1072
|
+
]) {
|
|
1073
|
+
this.ctx.storage.sql.exec(query, logId);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
this.closeLogSockets(logId, 1008, "log_deleted");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private getEventsValue(
|
|
1080
|
+
logId: string,
|
|
1081
|
+
afterVersion: number | undefined,
|
|
1082
|
+
limit: number | undefined,
|
|
1083
|
+
): { events: StoredEvent[] } {
|
|
1084
|
+
const log = this.log(logId);
|
|
1085
|
+
if (!log) {
|
|
1086
|
+
throw new LedgerError(404, {
|
|
1087
|
+
error: "log not found",
|
|
1088
|
+
code: "log_not_found",
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
if (log.status !== 'ready') throw new LedgerError(409, {
|
|
1092
|
+
error: 'Fork preparing', code: 'fork_preparing',
|
|
1093
|
+
});
|
|
1094
|
+
return {
|
|
1095
|
+
events: this.eventsAfter(
|
|
1096
|
+
logId,
|
|
1097
|
+
Math.max(0, afterVersion ?? 0),
|
|
1098
|
+
clamp(limit ?? MAX_CATCH_UP, 1, MAX_CATCH_UP),
|
|
1099
|
+
),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private async append(
|
|
1104
|
+
logId: string,
|
|
1105
|
+
request: AppendEventsRequest,
|
|
1106
|
+
access: AccessClaims,
|
|
1107
|
+
replayVersion: number,
|
|
1108
|
+
actor: string | null,
|
|
1109
|
+
): Promise<AppendEventsResponse> {
|
|
1110
|
+
if (request.events.length === 0) {
|
|
1111
|
+
throw validationError("events must not be empty");
|
|
1112
|
+
}
|
|
1113
|
+
validateIncomingEvents(request.events);
|
|
1114
|
+
const requestHash = await hashAppendRequest(logId, request);
|
|
1115
|
+
|
|
1116
|
+
const response = this.ctx.storage.transactionSync(() => {
|
|
1117
|
+
this.currentAccess(access);
|
|
1118
|
+
this.assertReplayCompatible(logId, replayVersion);
|
|
1119
|
+
if (request.events.some(event => event.type === "WORKSPACE_RESTORED") && replayVersion < 1) {
|
|
1120
|
+
throw validationError("Restore requires replay version 1");
|
|
1121
|
+
}
|
|
1122
|
+
if (request.events.some(event => event.type === "EVENTS_EXCISED") && replayVersion < 2) {
|
|
1123
|
+
throw validationError("Removing events requires replay version 2");
|
|
1124
|
+
}
|
|
1125
|
+
return this.appendTransaction(logId, request, requestHash, actor);
|
|
1126
|
+
});
|
|
1127
|
+
// The append is committed. Realtime delivery is best effort: sockets
|
|
1128
|
+
// recover through HTTP replay, and a delivery fault must never turn an
|
|
1129
|
+
// accepted append into an error response.
|
|
1130
|
+
try {
|
|
1131
|
+
this.deliver(logId);
|
|
1132
|
+
} catch (cause) {
|
|
1133
|
+
console.error(
|
|
1134
|
+
JSON.stringify({
|
|
1135
|
+
message: "Ledger socket delivery failed",
|
|
1136
|
+
error: normalizeError(cause).message,
|
|
1137
|
+
}),
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
return response;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private assertReplayCompatible(logId: string, replayVersion: number): void {
|
|
1144
|
+
if (!Number.isSafeInteger(replayVersion) || replayVersion < 0) throw validationError("Invalid replay version");
|
|
1145
|
+
// The IN list must match log_events_replay_markers exactly so SQLite
|
|
1146
|
+
// reads only marker events instead of the whole log.
|
|
1147
|
+
const { required } = this.ctx.storage.sql.exec<{ required: number | null }>(
|
|
1148
|
+
`SELECT MAX(CASE event_type WHEN 'EVENTS_EXCISED' THEN 2 ELSE 1 END) AS required
|
|
1149
|
+
FROM log_events
|
|
1150
|
+
WHERE log_id = ? AND event_type IN ('WORKSPACE_RESTORED', 'EVENTS_EXCISED')`,
|
|
1151
|
+
logId,
|
|
1152
|
+
).one();
|
|
1153
|
+
if (required === null || replayVersion >= required) return;
|
|
1154
|
+
throw new LedgerError(409, { error: "UpgradeRequired", code: "client_upgrade_required",
|
|
1155
|
+
message: required === 2
|
|
1156
|
+
? "Update this app to read or edit a log with removed events."
|
|
1157
|
+
: "Update this app to read or edit a restored log." });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
private appendTransaction(
|
|
1161
|
+
logId: string,
|
|
1162
|
+
request: AppendEventsRequest,
|
|
1163
|
+
requestHash: string,
|
|
1164
|
+
actor: string | null,
|
|
1165
|
+
): AppendEventsResponse {
|
|
1166
|
+
const log = this.log(logId);
|
|
1167
|
+
if (!log) {
|
|
1168
|
+
throw new LedgerError(404, {
|
|
1169
|
+
error: "NotFound",
|
|
1170
|
+
code: "log_not_found",
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
if (log.status !== 'ready') throw new LedgerError(409, {
|
|
1174
|
+
error: 'Fork preparing', code: 'fork_preparing',
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
const replay = this.ctx.storage.sql
|
|
1178
|
+
.exec<{ request_hash: string; result: string; first_version: number | null; last_version: number | null }>(
|
|
1179
|
+
`SELECT request_hash, result, first_version, last_version FROM append_requests
|
|
1180
|
+
WHERE request_id = ? AND log_id = ?`,
|
|
1181
|
+
request.requestId,
|
|
1182
|
+
logId,
|
|
1183
|
+
)
|
|
1184
|
+
.toArray()[0];
|
|
1185
|
+
if (replay) {
|
|
1186
|
+
if (replay.request_hash !== requestHash) {
|
|
1187
|
+
throw new LedgerError(409, {
|
|
1188
|
+
error: "Conflict",
|
|
1189
|
+
code: "idempotency_mismatch",
|
|
1190
|
+
message:
|
|
1191
|
+
"requestId was already used with different request content",
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
if (replay.first_version !== null && replay.last_version !== null) {
|
|
1195
|
+
return this.replayedAppend(logId, replay.first_version, replay.last_version);
|
|
1196
|
+
}
|
|
1197
|
+
return parseStoredAppendEventsResponse(JSON.parse(replay.result));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// After the replay check, so an accepted request always replays even
|
|
1201
|
+
// when the rules have tightened since.
|
|
1202
|
+
this.checkEventPayloads(request);
|
|
1203
|
+
|
|
1204
|
+
if (request.expectedLogVersion !== undefined && request.expectedLogVersion !== log.version) {
|
|
1205
|
+
throw new LedgerError(409, { error: "Conflict", code: "stale_restore_preview",
|
|
1206
|
+
message: "The log changed. Preview the restore again." });
|
|
1207
|
+
}
|
|
1208
|
+
const restore = request.events.find(event => event.type === "WORKSPACE_RESTORED");
|
|
1209
|
+
if (restore) {
|
|
1210
|
+
const target = restore.payload.targetVersion;
|
|
1211
|
+
if (request.events.length !== 1 || !request.actionId || request.expectedLogVersion === undefined
|
|
1212
|
+
|| typeof target !== "number" || !Number.isSafeInteger(target) || target < 0 || target > log.version) {
|
|
1213
|
+
throw validationError("Restore requires one complete action, an expected head and a valid target");
|
|
1214
|
+
}
|
|
1215
|
+
if (target > 0) {
|
|
1216
|
+
const boundary = this.ctx.storage.sql.exec<{ action_id: string | null; action_index: number | null; action_size: number | null }>(
|
|
1217
|
+
'SELECT action_id,action_index,action_size FROM log_events WHERE log_id = ? AND version = ?', logId, target,
|
|
1218
|
+
).toArray()[0];
|
|
1219
|
+
if (!boundary || (boundary.action_id !== null && boundary.action_index !== boundary.action_size! - 1)) {
|
|
1220
|
+
throw validationError("Restore target must be a complete accepted action");
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (request.actionId && this.ctx.storage.sql.exec(
|
|
1226
|
+
'SELECT 1 FROM log_events WHERE log_id = ? AND action_id = ? LIMIT 1',
|
|
1227
|
+
logId, request.actionId,
|
|
1228
|
+
).toArray().length) {
|
|
1229
|
+
throw new LedgerError(409, { error: "Conflict", code: "action_already_accepted" });
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const suppliedEventIds = request.events.flatMap((event) => event.id ? [event.id] : []);
|
|
1233
|
+
const accepted = suppliedEventIds.length === 0 ? undefined : this.ctx.storage.sql.exec<{ event_id: string }>(
|
|
1234
|
+
`SELECT event_id FROM log_events
|
|
1235
|
+
WHERE log_id = ? AND event_id IN (SELECT value FROM json_each(?))
|
|
1236
|
+
LIMIT 1`,
|
|
1237
|
+
logId, JSON.stringify(suppliedEventIds),
|
|
1238
|
+
).toArray()[0];
|
|
1239
|
+
if (accepted) {
|
|
1240
|
+
throw new LedgerError(409, {
|
|
1241
|
+
error: "Conflict",
|
|
1242
|
+
code: "event_already_accepted",
|
|
1243
|
+
message: "An event with this id was already accepted by another request",
|
|
1244
|
+
eventId: accepted.event_id,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const byEntity = new Map<string, typeof request.events>();
|
|
1249
|
+
for (const event of request.events) {
|
|
1250
|
+
const entityId = event.entityId.trim();
|
|
1251
|
+
const events = byEntity.get(entityId) ?? [];
|
|
1252
|
+
events.push(event);
|
|
1253
|
+
byEntity.set(entityId, events);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const entityVersions = new Map<string, number>();
|
|
1257
|
+
for (const [entityId, events] of byEntity) {
|
|
1258
|
+
const row = this.ctx.storage.sql
|
|
1259
|
+
.exec<{ version: number }>(
|
|
1260
|
+
`SELECT current_version AS version FROM entity_claims
|
|
1261
|
+
WHERE log_id = ? AND entity_id = ?`,
|
|
1262
|
+
logId,
|
|
1263
|
+
entityId,
|
|
1264
|
+
)
|
|
1265
|
+
.toArray()[0];
|
|
1266
|
+
validateEntityChain(entityId, events, row?.version, log.version);
|
|
1267
|
+
if (row) entityVersions.set(entityId, row.version);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let logVersion = log.version;
|
|
1271
|
+
const stored: StoredEvent[] = [];
|
|
1272
|
+
for (const incoming of request.events) {
|
|
1273
|
+
const entityId = incoming.entityId.trim();
|
|
1274
|
+
const entityVersion = (entityVersions.get(entityId) ?? 0) + 1;
|
|
1275
|
+
entityVersions.set(entityId, entityVersion);
|
|
1276
|
+
logVersion += 1;
|
|
1277
|
+
const event = buildStoredEvent(incoming, logVersion, entityVersion);
|
|
1278
|
+
if (request.actionId) event.action = {
|
|
1279
|
+
id: request.actionId, index: stored.length, size: request.events.length,
|
|
1280
|
+
};
|
|
1281
|
+
if (actor) event.actor = actor;
|
|
1282
|
+
stored.push(event);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// The idempotency record stores the whole response in one SQLite row.
|
|
1286
|
+
// Reject what can never fit instead of failing inside the transaction.
|
|
1287
|
+
const response = { newVersion: logVersion, events: stored };
|
|
1288
|
+
// The stored copy keeps the pre-authorship shape: a rolled-back Worker
|
|
1289
|
+
// validates it strictly, and replays read log_events anyway.
|
|
1290
|
+
const result = JSON.stringify({
|
|
1291
|
+
newVersion: logVersion,
|
|
1292
|
+
events: stored.map(({ actor: _actor, ...event }) => event),
|
|
1293
|
+
});
|
|
1294
|
+
if (new TextEncoder().encode(result).byteLength > MAX_APPEND_RESPONSE_BYTES) {
|
|
1295
|
+
throw new LedgerError(400, {
|
|
1296
|
+
error: "ValidationError",
|
|
1297
|
+
code: "request_too_large",
|
|
1298
|
+
message: `accepted events must serialize to at most ${MAX_APPEND_RESPONSE_BYTES} bytes`,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
for (const event of stored) {
|
|
1303
|
+
this.ctx.storage.sql.exec(
|
|
1304
|
+
`INSERT INTO log_events (
|
|
1305
|
+
log_id, version, event_id, event_type, entity_id,
|
|
1306
|
+
entity_version, event_timestamp, payload, action_id, action_index, action_size, actor
|
|
1307
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1308
|
+
logId,
|
|
1309
|
+
event.version,
|
|
1310
|
+
event.id,
|
|
1311
|
+
event.type,
|
|
1312
|
+
event.entityId,
|
|
1313
|
+
event.entityVersion,
|
|
1314
|
+
new Date(event.timestamp).toISOString(),
|
|
1315
|
+
JSON.stringify(event.payload),
|
|
1316
|
+
event.action?.id ?? null,
|
|
1317
|
+
event.action?.index ?? null,
|
|
1318
|
+
event.action?.size ?? null,
|
|
1319
|
+
event.actor ?? null,
|
|
1320
|
+
);
|
|
1321
|
+
const now = new Date().toISOString();
|
|
1322
|
+
this.ctx.storage.sql.exec(
|
|
1323
|
+
`INSERT INTO entity_claims (
|
|
1324
|
+
log_id, entity_id, current_version, version_created,
|
|
1325
|
+
created_at, updated_at
|
|
1326
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1327
|
+
ON CONFLICT (log_id, entity_id) DO UPDATE SET
|
|
1328
|
+
current_version = excluded.current_version,
|
|
1329
|
+
updated_at = excluded.updated_at`,
|
|
1330
|
+
logId,
|
|
1331
|
+
event.entityId,
|
|
1332
|
+
event.entityVersion,
|
|
1333
|
+
event.version,
|
|
1334
|
+
now,
|
|
1335
|
+
now,
|
|
1336
|
+
);
|
|
1337
|
+
const reference = fileReference(event);
|
|
1338
|
+
if (reference) {
|
|
1339
|
+
this.ctx.storage.sql.exec(
|
|
1340
|
+
`INSERT INTO gallery_photo_references
|
|
1341
|
+
(log_id, photo_id, file_version_id, source_version)
|
|
1342
|
+
VALUES (?, ?, ?, ?)
|
|
1343
|
+
ON CONFLICT (log_id, photo_id, file_version_id) DO NOTHING`,
|
|
1344
|
+
logId,
|
|
1345
|
+
reference.ownerId,
|
|
1346
|
+
reference.fileVersionId,
|
|
1347
|
+
event.version,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
this.ctx.storage.sql.exec(
|
|
1353
|
+
"UPDATE logs SET version = ?, last_updated_at = ? WHERE id = ?",
|
|
1354
|
+
logVersion,
|
|
1355
|
+
new Date().toISOString(),
|
|
1356
|
+
logId,
|
|
1357
|
+
);
|
|
1358
|
+
this.ctx.storage.sql.exec(
|
|
1359
|
+
`INSERT INTO append_requests (
|
|
1360
|
+
request_id, log_id, request_hash, result, created_at,
|
|
1361
|
+
first_version, last_version
|
|
1362
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1363
|
+
request.requestId,
|
|
1364
|
+
logId,
|
|
1365
|
+
requestHash,
|
|
1366
|
+
// Still written so a rolled-back Worker can replay this request;
|
|
1367
|
+
// replays read the version range.
|
|
1368
|
+
result,
|
|
1369
|
+
new Date().toISOString(),
|
|
1370
|
+
stored[0]!.version,
|
|
1371
|
+
logVersion,
|
|
1372
|
+
);
|
|
1373
|
+
return response;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
private checkEventPayloads(request: AppendEventsRequest): void {
|
|
1377
|
+
const policy = this.eventPolicy();
|
|
1378
|
+
const violations = findPayloadViolations(request.events, 5, policy);
|
|
1379
|
+
if (violations.length === 0) return;
|
|
1380
|
+
const mode = effectiveValidationMode(policy, this.env.EVENT_VALIDATION);
|
|
1381
|
+
// Types, keys and rule names only: payload values are never logged.
|
|
1382
|
+
console.warn(JSON.stringify({ message: "Ledger event validation", mode, violations }));
|
|
1383
|
+
if (mode !== "enforce") return;
|
|
1384
|
+
const first = violations[0]!;
|
|
1385
|
+
throw new LedgerError(422, {
|
|
1386
|
+
error: "UnprocessableEntity",
|
|
1387
|
+
code: "invalid_event_payload",
|
|
1388
|
+
message: `event ${first.index + 1} (${first.type}): ${first.problem}`,
|
|
1389
|
+
eventIndex: first.index,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** Rebuild an accepted append's response from the events it stored. */
|
|
1394
|
+
private replayedAppend(logId: string, firstVersion: number, lastVersion: number): AppendEventsResponse {
|
|
1395
|
+
const events = this.eventsThrough(logId, firstVersion - 1, lastVersion, MAX_CATCH_UP);
|
|
1396
|
+
if (events.length !== lastVersion - firstVersion + 1 || events[0]?.version !== firstVersion) {
|
|
1397
|
+
throw new Error("invalid persisted idempotency response");
|
|
1398
|
+
}
|
|
1399
|
+
return { newVersion: lastVersion, events };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private upgradeSocket(
|
|
1403
|
+
request: Request,
|
|
1404
|
+
access: AccessClaims,
|
|
1405
|
+
logId: string,
|
|
1406
|
+
afterVersion: number,
|
|
1407
|
+
): Response {
|
|
1408
|
+
const protocols = (request.headers.get("sec-websocket-protocol") ?? "")
|
|
1409
|
+
.split(",")
|
|
1410
|
+
.map((value) => value.trim());
|
|
1411
|
+
if (!protocols.includes(SOCKET_PROTOCOL)) {
|
|
1412
|
+
return jsonError(
|
|
1413
|
+
"invalid_subprotocol",
|
|
1414
|
+
"ledger.v1 subprotocol required",
|
|
1415
|
+
400,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
1419
|
+
return jsonError("upgrade_required", "WebSocket upgrade required", 426);
|
|
1420
|
+
}
|
|
1421
|
+
const claims = this.currentAccess(access);
|
|
1422
|
+
const log = this.log(logId);
|
|
1423
|
+
if (!log) {
|
|
1424
|
+
return jsonError("log_not_found", "log not found", 404);
|
|
1425
|
+
}
|
|
1426
|
+
if (log.status !== "ready") {
|
|
1427
|
+
return jsonError("fork_preparing", "Fork preparing", 409);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const replayVersion = parseNonnegativeInteger(Number(new URL(request.url).searchParams.get("replayVersion") ?? 0), "replayVersion");
|
|
1431
|
+
this.assertReplayCompatible(logId, replayVersion);
|
|
1432
|
+
const pair = new WebSocketPair();
|
|
1433
|
+
const client = pair[0];
|
|
1434
|
+
const server = pair[1];
|
|
1435
|
+
const attachment: SocketAttachment = {
|
|
1436
|
+
logId,
|
|
1437
|
+
replayVersion,
|
|
1438
|
+
cursor: afterVersion,
|
|
1439
|
+
principalId: claims.principalId,
|
|
1440
|
+
generation: claims.generation,
|
|
1441
|
+
connectionId: crypto.randomUUID(),
|
|
1442
|
+
connectedAt: new Date().toISOString(),
|
|
1443
|
+
client: {
|
|
1444
|
+
userAgent:
|
|
1445
|
+
request.headers.get("User-Agent")?.slice(0, 512) ?? null,
|
|
1446
|
+
ip:
|
|
1447
|
+
request.headers.get("CF-Connecting-IP")?.slice(0, 64) ??
|
|
1448
|
+
null,
|
|
1449
|
+
country:
|
|
1450
|
+
typeof request.cf?.country === "string"
|
|
1451
|
+
? request.cf.country.slice(0, 16)
|
|
1452
|
+
: null,
|
|
1453
|
+
colo:
|
|
1454
|
+
typeof request.cf?.colo === "string"
|
|
1455
|
+
? request.cf.colo.slice(0, 16)
|
|
1456
|
+
: null,
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
this.ctx.acceptWebSocket(server, [socketTag(logId)]);
|
|
1460
|
+
server.serializeAttachment(attachment);
|
|
1461
|
+
this.recordSocketSession(attachment);
|
|
1462
|
+
this.sendCatchUp(server, attachment, true);
|
|
1463
|
+
return new Response(null, {
|
|
1464
|
+
status: 101,
|
|
1465
|
+
webSocket: client,
|
|
1466
|
+
headers: { "Sec-WebSocket-Protocol": SOCKET_PROTOCOL },
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
private sendCatchUp(
|
|
1471
|
+
ws: WebSocket,
|
|
1472
|
+
attachment: SocketAttachment,
|
|
1473
|
+
sendReady: boolean,
|
|
1474
|
+
): void {
|
|
1475
|
+
try { this.assertReplayCompatible(attachment.logId, attachment.replayVersion ?? 0); }
|
|
1476
|
+
catch {
|
|
1477
|
+
this.sendFrame(ws, { type: "error", code: "client_upgrade_required" });
|
|
1478
|
+
ws.close(1008, "client upgrade required");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const head = this.logHead(attachment.logId);
|
|
1482
|
+
if (attachment.cursor > head) {
|
|
1483
|
+
this.sendFrame(ws, {
|
|
1484
|
+
type: "resync_required",
|
|
1485
|
+
headVersion: head,
|
|
1486
|
+
reason: "cursor_ahead",
|
|
1487
|
+
});
|
|
1488
|
+
ws.close(1008, "resync required");
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (head - attachment.cursor > MAX_CATCH_UP) {
|
|
1492
|
+
this.sendFrame(ws, {
|
|
1493
|
+
type: "resync_required",
|
|
1494
|
+
headVersion: head,
|
|
1495
|
+
reason: "gap_too_large",
|
|
1496
|
+
});
|
|
1497
|
+
ws.close(1008, "resync required");
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
for (const event of this.eventsAfter(
|
|
1501
|
+
attachment.logId,
|
|
1502
|
+
attachment.cursor,
|
|
1503
|
+
MAX_CATCH_UP,
|
|
1504
|
+
)) {
|
|
1505
|
+
this.sendFrame(ws, { type: "event", event });
|
|
1506
|
+
attachment.cursor = event.version;
|
|
1507
|
+
attachment.lastSentAt = new Date().toISOString();
|
|
1508
|
+
ws.serializeAttachment(attachment);
|
|
1509
|
+
}
|
|
1510
|
+
if (sendReady) {
|
|
1511
|
+
this.sendFrame(ws, { type: "ready", headVersion: head });
|
|
1512
|
+
attachment.lastSentAt = new Date().toISOString();
|
|
1513
|
+
ws.serializeAttachment(attachment);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private deliver(logId: string): void {
|
|
1518
|
+
const metadata = this.metadata();
|
|
1519
|
+
for (const ws of this.ctx.getWebSockets(socketTag(logId))) {
|
|
1520
|
+
let attachment: SocketAttachment;
|
|
1521
|
+
try {
|
|
1522
|
+
attachment = parseSocketAttachment(ws.deserializeAttachment());
|
|
1523
|
+
} catch {
|
|
1524
|
+
ws.close(1011, "missing attachment");
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (
|
|
1528
|
+
attachment.principalId !== metadata.principal_id ||
|
|
1529
|
+
attachment.generation !== metadata.principal_generation ||
|
|
1530
|
+
metadata.revoked_at !== null
|
|
1531
|
+
) {
|
|
1532
|
+
ws.close(1008, "principal revoked");
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
try {
|
|
1536
|
+
this.sendCatchUp(ws, attachment, false);
|
|
1537
|
+
} catch (cause) {
|
|
1538
|
+
console.error(
|
|
1539
|
+
JSON.stringify({
|
|
1540
|
+
message: "Ledger socket delivery failed",
|
|
1541
|
+
error: normalizeError(cause).message,
|
|
1542
|
+
}),
|
|
1543
|
+
);
|
|
1544
|
+
ws.close(1011, "delivery failure");
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
private sendFrame(ws: WebSocket, frame: SocketFrame): void {
|
|
1550
|
+
ws.send(JSON.stringify(frame));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private recordSocketSession(attachment: SocketAttachment): void {
|
|
1554
|
+
const sql = this.ctx.storage.sql;
|
|
1555
|
+
sql.exec(
|
|
1556
|
+
`INSERT INTO socket_sessions (
|
|
1557
|
+
connection_id, log_id, principal_id, principal_generation,
|
|
1558
|
+
connected_at, cursor, user_agent, ip, country, colo
|
|
1559
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1560
|
+
attachment.connectionId!,
|
|
1561
|
+
attachment.logId,
|
|
1562
|
+
attachment.principalId,
|
|
1563
|
+
attachment.generation,
|
|
1564
|
+
attachment.connectedAt!,
|
|
1565
|
+
attachment.cursor,
|
|
1566
|
+
attachment.client?.userAgent ?? null,
|
|
1567
|
+
attachment.client?.ip ?? null,
|
|
1568
|
+
attachment.client?.country ?? null,
|
|
1569
|
+
attachment.client?.colo ?? null,
|
|
1570
|
+
);
|
|
1571
|
+
sql.exec(
|
|
1572
|
+
`DELETE FROM socket_sessions WHERE connection_id IN (
|
|
1573
|
+
SELECT connection_id FROM socket_sessions
|
|
1574
|
+
ORDER BY connected_at DESC, connection_id DESC
|
|
1575
|
+
LIMIT -1 OFFSET ?
|
|
1576
|
+
)`,
|
|
1577
|
+
RECENT_SOCKET_SESSIONS,
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
private recordSocketDisconnect(ws: WebSocket, code: number | null): void {
|
|
1582
|
+
let attachment: SocketAttachment;
|
|
1583
|
+
try {
|
|
1584
|
+
attachment = parseSocketAttachment(ws.deserializeAttachment());
|
|
1585
|
+
} catch {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (!attachment.connectionId) return;
|
|
1589
|
+
this.ctx.storage.sql.exec(
|
|
1590
|
+
`UPDATE socket_sessions
|
|
1591
|
+
SET disconnected_at = COALESCE(disconnected_at, ?),
|
|
1592
|
+
close_code = COALESCE(close_code, ?),
|
|
1593
|
+
cursor = ?,
|
|
1594
|
+
last_sent_at = ?
|
|
1595
|
+
WHERE connection_id = ?`,
|
|
1596
|
+
new Date().toISOString(),
|
|
1597
|
+
code,
|
|
1598
|
+
attachment.cursor,
|
|
1599
|
+
attachment.lastSentAt ?? null,
|
|
1600
|
+
attachment.connectionId,
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
private metadata(): MetadataRow {
|
|
1605
|
+
const row = this.metadataOptional();
|
|
1606
|
+
if (!row) throw new Error("application is not initialized");
|
|
1607
|
+
return row;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
private fence(): Fence | null {
|
|
1611
|
+
const row = this.ctx.storage.sql
|
|
1612
|
+
.exec<{ fence_mode: string | null; fence_destination: string | null }>(
|
|
1613
|
+
"SELECT fence_mode, fence_destination FROM app_metadata WHERE singleton = 1",
|
|
1614
|
+
)
|
|
1615
|
+
.toArray()[0];
|
|
1616
|
+
return parseFence(row?.fence_mode, row?.fence_destination);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** The claims, for a request that writes: refused while the application is fenced. */
|
|
1620
|
+
private writableAccess(access: AccessClaims): AccessClaims {
|
|
1621
|
+
const claims = this.currentAccess(access);
|
|
1622
|
+
const fence = this.fence();
|
|
1623
|
+
if (fence) throw fenceError(fence);
|
|
1624
|
+
return claims;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
private importState(): string | null {
|
|
1628
|
+
return this.ctx.storage.sql
|
|
1629
|
+
.exec<{ import_state: string | null }>("SELECT import_state FROM app_metadata WHERE singleton = 1")
|
|
1630
|
+
.toArray()[0]?.import_state ?? null;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private importRecord(record: TransferRecord, totals: TransferTotals): void {
|
|
1634
|
+
const sql = this.ctx.storage.sql;
|
|
1635
|
+
const requireLog = (logId: string): { version: number } => {
|
|
1636
|
+
const log = sql.exec<{ version: number }>("SELECT version FROM logs WHERE id = ?", logId).toArray()[0];
|
|
1637
|
+
if (!log) throw transferError("import_order", `Log ${logId} must be imported before its events`);
|
|
1638
|
+
return log;
|
|
1639
|
+
};
|
|
1640
|
+
switch (record.t) {
|
|
1641
|
+
case "log": {
|
|
1642
|
+
if (sql.exec("SELECT 1 FROM logs WHERE id = ?", record.id).toArray().length) {
|
|
1643
|
+
throw transferError("import_duplicate", `Log ${record.id} was already imported`);
|
|
1644
|
+
}
|
|
1645
|
+
// The version grows with the events; finishImport compares it with the source's head.
|
|
1646
|
+
sql.exec(
|
|
1647
|
+
`INSERT INTO logs (id, name, version, created_at, last_updated_at, source_log_id, source_version, status, hidden_at)
|
|
1648
|
+
VALUES (?, ?, 0, ?, ?, ?, ?, 'ready', ?)`,
|
|
1649
|
+
record.id, record.name, record.createdAt, record.lastUpdatedAt ?? record.createdAt,
|
|
1650
|
+
record.sourceLogId, record.sourceVersion, record.hiddenAt,
|
|
1651
|
+
);
|
|
1652
|
+
totals.logs += 1;
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
case "events": {
|
|
1656
|
+
let version = requireLog(record.logId).version;
|
|
1657
|
+
for (const event of record.events) {
|
|
1658
|
+
version += 1;
|
|
1659
|
+
if (event.version !== version) {
|
|
1660
|
+
throw transferError("import_gap", `Log ${record.logId} expected version ${version}, got ${event.version}`);
|
|
1661
|
+
}
|
|
1662
|
+
const iso = new Date(event.timestamp).toISOString();
|
|
1663
|
+
const claim = sql.exec<{ current_version: number }>(
|
|
1664
|
+
"SELECT current_version FROM entity_claims WHERE log_id = ? AND entity_id = ?", record.logId, event.entityId,
|
|
1665
|
+
).toArray()[0];
|
|
1666
|
+
if (event.entityVersion !== (claim?.current_version ?? 0) + 1) {
|
|
1667
|
+
throw transferError("import_entity_chain", `Entity ${event.entityId} in log ${record.logId} jumps to version ${event.entityVersion}`);
|
|
1668
|
+
}
|
|
1669
|
+
sql.exec(
|
|
1670
|
+
`INSERT INTO log_events (log_id, version, event_id, event_type, entity_id, entity_version,
|
|
1671
|
+
event_timestamp, payload, action_id, action_index, action_size, actor)
|
|
1672
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1673
|
+
record.logId, event.version, event.id, event.type, event.entityId, event.entityVersion, iso,
|
|
1674
|
+
JSON.stringify(event.payload), event.action?.id ?? null, event.action?.index ?? null,
|
|
1675
|
+
event.action?.size ?? null, event.actor ?? null,
|
|
1676
|
+
);
|
|
1677
|
+
sql.exec(
|
|
1678
|
+
`INSERT INTO entity_claims (log_id, entity_id, current_version, version_created, created_at, updated_at)
|
|
1679
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1680
|
+
ON CONFLICT (log_id, entity_id) DO UPDATE SET current_version = excluded.current_version, updated_at = excluded.updated_at`,
|
|
1681
|
+
record.logId, event.entityId, event.entityVersion, event.version, iso, iso,
|
|
1682
|
+
);
|
|
1683
|
+
const reference = fileReference(event);
|
|
1684
|
+
if (reference) {
|
|
1685
|
+
sql.exec(
|
|
1686
|
+
`INSERT INTO gallery_photo_references (log_id, photo_id, file_version_id, source_version)
|
|
1687
|
+
VALUES (?, ?, ?, ?) ON CONFLICT (log_id, photo_id, file_version_id) DO NOTHING`,
|
|
1688
|
+
record.logId, reference.ownerId, reference.fileVersionId, event.version,
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
sql.exec("UPDATE logs SET version = ? WHERE id = ?", version, record.logId);
|
|
1693
|
+
totals.events += record.events.length;
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
case "requests": {
|
|
1697
|
+
const log = requireLog(record.logId);
|
|
1698
|
+
for (const request of record.requests) {
|
|
1699
|
+
let result = request.result;
|
|
1700
|
+
if (request.firstVersion !== null && request.lastVersion !== null) {
|
|
1701
|
+
if (request.firstVersion < 1 || request.lastVersion < request.firstVersion || request.lastVersion > log.version) {
|
|
1702
|
+
throw transferError("import_request_range", `Request ${request.requestId} names versions the log does not hold`);
|
|
1703
|
+
}
|
|
1704
|
+
// Replays read log_events; the stored copy keeps the shape a rolled-back Worker validates.
|
|
1705
|
+
const events: StoredEvent[] = [];
|
|
1706
|
+
for (let after = request.firstVersion - 1; after < request.lastVersion;) {
|
|
1707
|
+
const page = this.eventsThrough(record.logId, after, request.lastVersion, MAX_CATCH_UP);
|
|
1708
|
+
if (page.length === 0) throw transferError("import_request_range", `Request ${request.requestId} names missing events`);
|
|
1709
|
+
events.push(...page);
|
|
1710
|
+
after = page[page.length - 1]!.version;
|
|
1711
|
+
}
|
|
1712
|
+
result = JSON.stringify({ newVersion: request.lastVersion, events: events.map(({ actor: _actor, ...rest }) => rest) });
|
|
1713
|
+
}
|
|
1714
|
+
sql.exec(
|
|
1715
|
+
`INSERT INTO append_requests (request_id, log_id, request_hash, result, created_at, first_version, last_version)
|
|
1716
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1717
|
+
request.requestId, record.logId, request.requestHash, result, request.createdAt, request.firstVersion, request.lastVersion,
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
totals.requests += record.requests.length;
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
case "forks": {
|
|
1724
|
+
for (const fork of record.requests) {
|
|
1725
|
+
sql.exec(
|
|
1726
|
+
`INSERT INTO log_fork_requests (request_id, source_log_id, target_version, expected_source_head_version, name, fork_log_id)
|
|
1727
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1728
|
+
fork.requestId, fork.sourceLogId, fork.targetVersion, fork.expectedSourceHeadVersion, fork.name, fork.forkLogId,
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
private async *transferChunks(appId: string): AsyncGenerator<Uint8Array> {
|
|
1737
|
+
const encoder = new TextEncoder();
|
|
1738
|
+
const line = (value: unknown) => encoder.encode(`${JSON.stringify(value)}\n`);
|
|
1739
|
+
const sql = this.ctx.storage.sql;
|
|
1740
|
+
const app = sql.exec<{ name: string; created_at: string; event_policy: string | null }>(
|
|
1741
|
+
"SELECT name, created_at, event_policy FROM app_metadata WHERE singleton = 1",
|
|
1742
|
+
).toArray()[0]!;
|
|
1743
|
+
yield line({
|
|
1744
|
+
t: "app", format: TRANSFER_FORMAT, version: TRANSFER_VERSION, id: appId, name: app.name,
|
|
1745
|
+
createdAt: app.created_at, policy: parseEventPolicy(app.event_policy), exportedAt: new Date().toISOString(),
|
|
1746
|
+
});
|
|
1747
|
+
const totals: TransferTotals = { logs: 0, events: 0, requests: 0 };
|
|
1748
|
+
const logs = sql.exec<{
|
|
1749
|
+
id: string; name: string; version: number; created_at: string; last_updated_at: string | null;
|
|
1750
|
+
source_log_id: string | null; source_version: number | null; hidden_at: string | null;
|
|
1751
|
+
}>("SELECT id, name, version, created_at, last_updated_at, source_log_id, source_version, hidden_at FROM logs ORDER BY created_at, id").toArray();
|
|
1752
|
+
for (const log of logs) {
|
|
1753
|
+
yield line({
|
|
1754
|
+
t: "log", id: log.id, name: log.name, createdAt: log.created_at, lastUpdatedAt: log.last_updated_at,
|
|
1755
|
+
sourceLogId: log.source_log_id, sourceVersion: log.source_version, hiddenAt: log.hidden_at, headVersion: log.version,
|
|
1756
|
+
});
|
|
1757
|
+
totals.logs += 1;
|
|
1758
|
+
for (let after = 0; after < log.version;) {
|
|
1759
|
+
const events = this.eventsThrough(log.id, after, log.version, TRANSFER_PAGE);
|
|
1760
|
+
if (events.length === 0) throw new Error(`log ${log.id} export stopped at version ${after}`);
|
|
1761
|
+
yield line({ t: "events", logId: log.id, events });
|
|
1762
|
+
after = events[events.length - 1]!.version;
|
|
1763
|
+
totals.events += events.length;
|
|
1764
|
+
}
|
|
1765
|
+
const requests = sql.exec<{
|
|
1766
|
+
request_id: string; request_hash: string; created_at: string; first_version: number | null;
|
|
1767
|
+
last_version: number | null; result: string;
|
|
1768
|
+
}>("SELECT request_id, request_hash, created_at, first_version, last_version, result FROM append_requests WHERE log_id = ? ORDER BY created_at, request_id", log.id).toArray();
|
|
1769
|
+
for (let at = 0; at < requests.length; at += 500) {
|
|
1770
|
+
yield line({
|
|
1771
|
+
t: "requests", logId: log.id,
|
|
1772
|
+
requests: requests.slice(at, at + 500).map((row) => ({
|
|
1773
|
+
requestId: row.request_id, requestHash: row.request_hash, createdAt: row.created_at,
|
|
1774
|
+
firstVersion: row.first_version, lastVersion: row.last_version,
|
|
1775
|
+
// Only rows that predate version ranges need the stored response.
|
|
1776
|
+
result: row.first_version === null ? row.result : null,
|
|
1777
|
+
})),
|
|
1778
|
+
});
|
|
1779
|
+
totals.requests += Math.min(500, requests.length - at);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
const forks = sql.exec<{
|
|
1783
|
+
request_id: string; source_log_id: string; target_version: number; expected_source_head_version: number; name: string; fork_log_id: string;
|
|
1784
|
+
}>("SELECT request_id, source_log_id, target_version, expected_source_head_version, name, fork_log_id FROM log_fork_requests ORDER BY request_id").toArray();
|
|
1785
|
+
for (let at = 0; at < forks.length; at += 500) {
|
|
1786
|
+
yield line({
|
|
1787
|
+
t: "forks",
|
|
1788
|
+
requests: forks.slice(at, at + 500).map((row) => ({
|
|
1789
|
+
requestId: row.request_id, sourceLogId: row.source_log_id, targetVersion: row.target_version,
|
|
1790
|
+
expectedSourceHeadVersion: row.expected_source_head_version, name: row.name, forkLogId: row.fork_log_id,
|
|
1791
|
+
})),
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
yield line({ t: "end", ...totals });
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
private eventPolicy(): EventPolicy | null {
|
|
1798
|
+
const row = this.ctx.storage.sql
|
|
1799
|
+
.exec<{ event_policy: string | null }>("SELECT event_policy FROM app_metadata WHERE singleton = 1")
|
|
1800
|
+
.toArray()[0];
|
|
1801
|
+
return parseEventPolicy(row?.event_policy);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
private metadataOptional(): MetadataRow | undefined {
|
|
1805
|
+
return this.ctx.storage.sql
|
|
1806
|
+
.exec<MetadataRow>(
|
|
1807
|
+
`SELECT app_id, principal_id, principal_generation, revoked_at
|
|
1808
|
+
FROM app_metadata WHERE singleton = 1`,
|
|
1809
|
+
)
|
|
1810
|
+
.toArray()[0];
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
private log(logId: string): LogRow | undefined {
|
|
1814
|
+
return this.ctx.storage.sql
|
|
1815
|
+
.exec<LogRow>(
|
|
1816
|
+
`SELECT child.id, child.name, child.version, child.created_at, child.last_updated_at,
|
|
1817
|
+
child.source_log_id, parent.name AS source_log_name, child.source_version,
|
|
1818
|
+
(SELECT event_timestamp FROM log_events
|
|
1819
|
+
WHERE log_id = child.source_log_id AND version = child.source_version) AS source_timestamp,
|
|
1820
|
+
child.status, child.hidden_at
|
|
1821
|
+
FROM logs AS child LEFT JOIN logs AS parent ON parent.id = child.source_log_id
|
|
1822
|
+
WHERE child.id = ?`,
|
|
1823
|
+
logId,
|
|
1824
|
+
)
|
|
1825
|
+
.toArray()[0];
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private logHead(logId: string): number {
|
|
1829
|
+
const log = this.log(logId);
|
|
1830
|
+
if (!log) throw new Error("log not found");
|
|
1831
|
+
return log.version;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
private eventsAfter(
|
|
1835
|
+
logId: string,
|
|
1836
|
+
after: number,
|
|
1837
|
+
limit: number,
|
|
1838
|
+
): StoredEvent[] {
|
|
1839
|
+
return this.ctx.storage.sql
|
|
1840
|
+
.exec<EventRow>(
|
|
1841
|
+
`SELECT version, event_id, event_type, entity_id, entity_version,
|
|
1842
|
+
event_timestamp, payload, action_id, action_index, action_size, actor
|
|
1843
|
+
FROM log_events
|
|
1844
|
+
WHERE log_id = ? AND version > ?
|
|
1845
|
+
ORDER BY version
|
|
1846
|
+
LIMIT ?`,
|
|
1847
|
+
logId,
|
|
1848
|
+
Math.max(0, after),
|
|
1849
|
+
clamp(limit, 1, MAX_CATCH_UP),
|
|
1850
|
+
)
|
|
1851
|
+
.toArray()
|
|
1852
|
+
.map(storedEvent);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
private eventsThrough(
|
|
1856
|
+
logId: string,
|
|
1857
|
+
after: number,
|
|
1858
|
+
through: number,
|
|
1859
|
+
limit: number,
|
|
1860
|
+
): StoredEvent[] {
|
|
1861
|
+
return this.ctx.storage.sql
|
|
1862
|
+
.exec<EventRow>(
|
|
1863
|
+
`SELECT version, event_id, event_type, entity_id, entity_version,
|
|
1864
|
+
event_timestamp, payload, action_id, action_index, action_size, actor
|
|
1865
|
+
FROM log_events
|
|
1866
|
+
WHERE log_id = ? AND version > ? AND version <= ?
|
|
1867
|
+
ORDER BY version
|
|
1868
|
+
LIMIT ?`,
|
|
1869
|
+
logId,
|
|
1870
|
+
Math.max(0, after),
|
|
1871
|
+
Math.max(0, through),
|
|
1872
|
+
clamp(limit, 1, MAX_CATCH_UP),
|
|
1873
|
+
)
|
|
1874
|
+
.toArray()
|
|
1875
|
+
.map(storedEvent);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
private async *applicationExportChunks(
|
|
1879
|
+
application: ApplicationSummary,
|
|
1880
|
+
logs: LogSummary[],
|
|
1881
|
+
exportedAt: string,
|
|
1882
|
+
): AsyncGenerator<Uint8Array> {
|
|
1883
|
+
const encoder = new TextEncoder();
|
|
1884
|
+
const encode = (value: string) => encoder.encode(value);
|
|
1885
|
+
yield encode(
|
|
1886
|
+
`{"format":${JSON.stringify(ADMIN_EXPORT_FORMAT)}` +
|
|
1887
|
+
`,"version":${ADMIN_EXPORT_VERSION}` +
|
|
1888
|
+
`,"exportedAt":${JSON.stringify(exportedAt)}` +
|
|
1889
|
+
`,"application":${JSON.stringify(application)}` +
|
|
1890
|
+
`,"logs":[`,
|
|
1891
|
+
);
|
|
1892
|
+
|
|
1893
|
+
for (const [logIndex, log] of logs.entries()) {
|
|
1894
|
+
// The full log summary, including fork lineage, opens each log.
|
|
1895
|
+
const summary = JSON.stringify(log);
|
|
1896
|
+
yield encode(
|
|
1897
|
+
`${logIndex === 0 ? "" : ","}${summary.slice(0, -1)},"events":[`,
|
|
1898
|
+
);
|
|
1899
|
+
let cursor = 0;
|
|
1900
|
+
let firstEvent = true;
|
|
1901
|
+
while (cursor < log.headVersion) {
|
|
1902
|
+
const events = this.eventsThrough(
|
|
1903
|
+
log.id,
|
|
1904
|
+
cursor,
|
|
1905
|
+
log.headVersion,
|
|
1906
|
+
MAX_CATCH_UP,
|
|
1907
|
+
);
|
|
1908
|
+
if (events.length === 0) {
|
|
1909
|
+
throw new Error(
|
|
1910
|
+
`log ${log.id} export stopped at version ${cursor}`,
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
for (const event of events) {
|
|
1914
|
+
if (event.version !== cursor + 1) {
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
`log ${log.id} export is not contiguous at version ${cursor + 1}`,
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
yield encode(
|
|
1920
|
+
`${firstEvent ? "" : ","}${JSON.stringify(event)}`,
|
|
1921
|
+
);
|
|
1922
|
+
firstEvent = false;
|
|
1923
|
+
cursor = event.version;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
yield encode("]}");
|
|
1927
|
+
}
|
|
1928
|
+
yield encode("]}\n");
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
private closeAllSockets(code: number, reason: string): void {
|
|
1932
|
+
for (const ws of this.ctx.getWebSockets()) ws.close(code, reason);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
private closeLogSockets(logId: string, code: number, reason: string): void {
|
|
1936
|
+
for (const ws of this.ctx.getWebSockets(socketTag(logId))) {
|
|
1937
|
+
ws.close(code, reason);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function storedEvent(row: EventRow): StoredEvent {
|
|
1943
|
+
const payload: unknown = JSON.parse(row.payload);
|
|
1944
|
+
if (
|
|
1945
|
+
payload === null ||
|
|
1946
|
+
typeof payload !== "object" ||
|
|
1947
|
+
Array.isArray(payload)
|
|
1948
|
+
) {
|
|
1949
|
+
throw new Error("invalid stored event payload");
|
|
1950
|
+
}
|
|
1951
|
+
return {
|
|
1952
|
+
...(row.action_id !== null ? { action: {
|
|
1953
|
+
id: row.action_id, index: row.action_index!, size: row.action_size!,
|
|
1954
|
+
} } : {}),
|
|
1955
|
+
version: row.version,
|
|
1956
|
+
id: parseUuid(row.event_id, "stored event id"),
|
|
1957
|
+
type: row.event_type,
|
|
1958
|
+
entityId: row.entity_id,
|
|
1959
|
+
entityVersion: row.entity_version,
|
|
1960
|
+
timestamp: Date.parse(row.event_timestamp),
|
|
1961
|
+
payload: payload as StoredEvent["payload"],
|
|
1962
|
+
// Omitted when unknown so events from before authorship export unchanged.
|
|
1963
|
+
...(row.actor !== null ? { actor: row.actor } : {}),
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function logSummary(row: LogRow): {
|
|
1968
|
+
id: string;
|
|
1969
|
+
name: string;
|
|
1970
|
+
headVersion: number;
|
|
1971
|
+
createdAt: string;
|
|
1972
|
+
lastUpdatedAt: string | null;
|
|
1973
|
+
sourceLogId: string | null;
|
|
1974
|
+
sourceLogName: string | null;
|
|
1975
|
+
sourceVersion: number | null;
|
|
1976
|
+
sourceTimestamp: string | null;
|
|
1977
|
+
status: 'ready' | 'preparing';
|
|
1978
|
+
hiddenAt: string | null;
|
|
1979
|
+
} {
|
|
1980
|
+
return {
|
|
1981
|
+
id: parseUuid(row.id, "stored log id"),
|
|
1982
|
+
name: row.name,
|
|
1983
|
+
headVersion: row.version,
|
|
1984
|
+
createdAt: row.created_at,
|
|
1985
|
+
lastUpdatedAt: row.last_updated_at,
|
|
1986
|
+
sourceLogId: row.source_log_id,
|
|
1987
|
+
sourceLogName: row.source_log_name,
|
|
1988
|
+
sourceVersion: row.source_version,
|
|
1989
|
+
sourceTimestamp: row.source_timestamp,
|
|
1990
|
+
status: row.status,
|
|
1991
|
+
hiddenAt: row.hidden_at,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function byteStream(
|
|
1996
|
+
chunks: AsyncGenerator<Uint8Array>,
|
|
1997
|
+
): ReadableStream<Uint8Array> {
|
|
1998
|
+
const source: UnderlyingByteSource = {
|
|
1999
|
+
type: "bytes",
|
|
2000
|
+
async pull(controller) {
|
|
2001
|
+
try {
|
|
2002
|
+
const next = await chunks.next();
|
|
2003
|
+
if (next.done) {
|
|
2004
|
+
controller.close();
|
|
2005
|
+
} else {
|
|
2006
|
+
controller.enqueue(next.value);
|
|
2007
|
+
}
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
controller.error(error);
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
async cancel() {
|
|
2013
|
+
await chunks.return(undefined);
|
|
2014
|
+
},
|
|
2015
|
+
};
|
|
2016
|
+
return new ReadableStream(source);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function exportFilename(name: string, exportedAt: string): string {
|
|
2020
|
+
const safeName =
|
|
2021
|
+
name
|
|
2022
|
+
.normalize("NFKD")
|
|
2023
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
2024
|
+
.replace(/^-+|-+$/g, "")
|
|
2025
|
+
.slice(0, 64) || "application";
|
|
2026
|
+
return `ledger-${safeName}-${exportedAt.slice(0, 10)}.json`;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function routeSegments(path: string): string[] {
|
|
2030
|
+
return path.split("/").filter(Boolean);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function internalSocketAccess(request: Request): AccessClaims {
|
|
2034
|
+
const value = request.headers.get(INTERNAL_SOCKET_ACCESS_HEADER);
|
|
2035
|
+
if (!value) {
|
|
2036
|
+
throw new LedgerError(401, {
|
|
2037
|
+
error: "invalid bearer token",
|
|
2038
|
+
code: "unauthorized",
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
try {
|
|
2042
|
+
return parseAccessClaims(JSON.parse(value));
|
|
2043
|
+
} catch {
|
|
2044
|
+
throw new LedgerError(401, {
|
|
2045
|
+
error: "invalid bearer token",
|
|
2046
|
+
code: "unauthorized",
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function socketTag(logId: string): string {
|
|
2052
|
+
return `log:${logId}`;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
2056
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
2057
|
+
}
|