@lunora/replica 0.0.0 → 1.0.0-alpha.3
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.md +231 -0
- package/README.md +194 -29
- package/dist/adapters/better-sqlite3.d.mts +28 -0
- package/dist/adapters/better-sqlite3.d.ts +28 -0
- package/dist/adapters/better-sqlite3.mjs +29 -0
- package/dist/adapters/sqlite-wasm.d.mts +26 -0
- package/dist/adapters/sqlite-wasm.d.ts +26 -0
- package/dist/adapters/sqlite-wasm.mjs +56 -0
- package/dist/adapters/sqljs.d.mts +20 -0
- package/dist/adapters/sqljs.d.ts +20 -0
- package/dist/adapters/sqljs.mjs +55 -0
- package/dist/index.d.mts +910 -0
- package/dist/index.d.ts +910 -0
- package/dist/index.mjs +20 -0
- package/dist/packem_shared/EventEmitter-CMZfct03.mjs +92 -0
- package/dist/packem_shared/EventLog-zMy7AYP4.mjs +162 -0
- package/dist/packem_shared/EventLogDO-CZYUvvSr.mjs +235 -0
- package/dist/packem_shared/EventLogDOClient-DGiEdi96.mjs +86 -0
- package/dist/packem_shared/EventSource-DfV4VoRD.mjs +195 -0
- package/dist/packem_shared/EventsSync-DkVbU0WV.mjs +91 -0
- package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +24 -0
- package/dist/packem_shared/LocalMirror-GeJ26eNe.mjs +188 -0
- package/dist/packem_shared/MaterializerRuntime-HqNXqJxp.mjs +204 -0
- package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +75 -0
- package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +40 -0
- package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +58 -0
- package/dist/packem_shared/classifyChanges-aZmkxgVI.mjs +38 -0
- package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +28 -0
- package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +6 -0
- package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +5 -0
- package/dist/packem_shared/local-mirror.d-BUeOe5KC.d.mts +439 -0
- package/dist/packem_shared/local-mirror.d-Cd8tAg-W.d.ts +439 -0
- package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +45 -0
- package/dist/packem_shared/types.d-BuAWjEY5.d.mts +24 -0
- package/dist/packem_shared/types.d-BuAWjEY5.d.ts +24 -0
- package/dist/react.d.mts +67 -0
- package/dist/react.d.ts +67 -0
- package/dist/react.mjs +15 -0
- package/package.json +88 -7
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { createBetterSqlite3Adapter } from './adapters/better-sqlite3.mjs';
|
|
2
|
+
export { createSqliteWasmAdapter } from './adapters/sqlite-wasm.mjs';
|
|
3
|
+
export { createSqlJsAdapter } from './adapters/sqljs.mjs';
|
|
4
|
+
export { applyDiff, applyDiffToSnapshot, applyDiffs } from './packem_shared/applyDiff-BtbIl1D3.mjs';
|
|
5
|
+
export { defineEvents } from './packem_shared/defineEvents-DiBkPTh_.mjs';
|
|
6
|
+
export { MaterializerRuntime, defineMaterializer } from './packem_shared/MaterializerRuntime-HqNXqJxp.mjs';
|
|
7
|
+
export { applyDiffToDb, applyDiffsToDb } from './packem_shared/applyDiffToDb-DQ1xZp5J.mjs';
|
|
8
|
+
export { EventEmitter } from './packem_shared/EventEmitter-CMZfct03.mjs';
|
|
9
|
+
export { EventLog } from './packem_shared/EventLog-zMy7AYP4.mjs';
|
|
10
|
+
export { EventLogDO } from './packem_shared/EventLogDO-CZYUvvSr.mjs';
|
|
11
|
+
export { EventLogDOClient } from './packem_shared/EventLogDOClient-DGiEdi96.mjs';
|
|
12
|
+
export { EventSource } from './packem_shared/EventSource-DfV4VoRD.mjs';
|
|
13
|
+
export { eventsContext } from './packem_shared/eventsContext-Bk_p48hj.mjs';
|
|
14
|
+
export { LocalMirror } from './packem_shared/LocalMirror-GeJ26eNe.mjs';
|
|
15
|
+
export { isClientSeq, isGlobalSeq, isInputEvent } from './packem_shared/isClientSeq-C46BkzqJ.mjs';
|
|
16
|
+
export { InMemorySnapshotStore } from './packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs';
|
|
17
|
+
export { subscribeToMirror } from './packem_shared/subscribeToMirror-CiaM-nQ7.mjs';
|
|
18
|
+
export { SubscriptionManager } from './packem_shared/SubscriptionManager-C5xbw0pg.mjs';
|
|
19
|
+
export { EventsSync } from './packem_shared/EventsSync-DkVbU0WV.mjs';
|
|
20
|
+
export { classifyChanges, createTableDiff, diffSize, isDiffEmpty, mergeDiffs } from './packem_shared/classifyChanges-aZmkxgVI.mjs';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
class EventEmitter {
|
|
2
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
3
|
+
#wildcardListeners = /* @__PURE__ */ new Set();
|
|
4
|
+
// ── Registration ──────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Register a handler for a specific event type.
|
|
7
|
+
* @returns An unsubscribe function (equivalent to calling {@link off}).
|
|
8
|
+
*/
|
|
9
|
+
on(event, handler) {
|
|
10
|
+
let set = this.#listeners.get(event);
|
|
11
|
+
if (!set) {
|
|
12
|
+
set = /* @__PURE__ */ new Set();
|
|
13
|
+
this.#listeners.set(event, set);
|
|
14
|
+
}
|
|
15
|
+
set.add(handler);
|
|
16
|
+
return () => {
|
|
17
|
+
this.off(event, handler);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Remove a previously registered handler.
|
|
22
|
+
*/
|
|
23
|
+
off(event, handler) {
|
|
24
|
+
this.#listeners.get(event)?.delete(handler);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register a wildcard handler that fires for **every** event type.
|
|
28
|
+
* @returns An unsubscribe function.
|
|
29
|
+
*/
|
|
30
|
+
onAny(handler) {
|
|
31
|
+
this.#wildcardListeners.add(handler);
|
|
32
|
+
return () => {
|
|
33
|
+
this.#wildcardListeners.delete(handler);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Remove a wildcard handler.
|
|
38
|
+
*/
|
|
39
|
+
offAny(handler) {
|
|
40
|
+
this.#wildcardListeners.delete(handler);
|
|
41
|
+
}
|
|
42
|
+
// ── Emission ──────────────────────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* Emit an event. All registered handlers (typed + wildcard) are invoked
|
|
45
|
+
* synchronously. Exceptions from handlers are caught and silently
|
|
46
|
+
* swallowed — they **must not** break the emitter loop.
|
|
47
|
+
* @returns `true` if at least one handler was called.
|
|
48
|
+
*/
|
|
49
|
+
emit(event, payload) {
|
|
50
|
+
let called = false;
|
|
51
|
+
const typed = this.#listeners.get(event);
|
|
52
|
+
if (typed) {
|
|
53
|
+
for (const handler of typed) {
|
|
54
|
+
try {
|
|
55
|
+
handler(payload);
|
|
56
|
+
called = true;
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const handler of this.#wildcardListeners) {
|
|
62
|
+
try {
|
|
63
|
+
handler(event, payload);
|
|
64
|
+
called = true;
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return called;
|
|
69
|
+
}
|
|
70
|
+
// ── Introspection ─────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Return `true` when at least one listener is registered for `event`.
|
|
73
|
+
*/
|
|
74
|
+
hasListeners(event) {
|
|
75
|
+
return (this.#listeners.get(event)?.size ?? 0) > 0 || this.#wildcardListeners.size > 0;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Return the number of typed listeners for a specific event.
|
|
79
|
+
*/
|
|
80
|
+
listenerCount(event) {
|
|
81
|
+
return this.#listeners.get(event)?.size ?? 0;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Remove all listeners.
|
|
85
|
+
*/
|
|
86
|
+
clear() {
|
|
87
|
+
this.#listeners.clear();
|
|
88
|
+
this.#wildcardListeners.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { EventEmitter };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
class EventLog {
|
|
2
|
+
#entries = [];
|
|
3
|
+
#nextSeq = 0;
|
|
4
|
+
// eslint-disable-next-line unicorn/no-null -- public contract uses `null` for an empty log head
|
|
5
|
+
#headSeq = null;
|
|
6
|
+
append(typeOrEvent, payload, tableDiffs, options) {
|
|
7
|
+
const type = typeof typeOrEvent === "string" ? typeOrEvent : typeOrEvent.type;
|
|
8
|
+
const pl = typeof typeOrEvent === "string" ? payload : typeOrEvent.payload;
|
|
9
|
+
let diffs;
|
|
10
|
+
let resolvedOptions;
|
|
11
|
+
if (typeof typeOrEvent === "string") {
|
|
12
|
+
diffs = tableDiffs;
|
|
13
|
+
resolvedOptions = options;
|
|
14
|
+
} else {
|
|
15
|
+
diffs = void 0;
|
|
16
|
+
resolvedOptions = payload;
|
|
17
|
+
}
|
|
18
|
+
const parentSeqNumber = resolvedOptions?.parentSeqNum ?? this.#headSeq ?? void 0;
|
|
19
|
+
const seq = this.#nextSeq;
|
|
20
|
+
this.#nextSeq += 1;
|
|
21
|
+
const entry = {
|
|
22
|
+
seq,
|
|
23
|
+
type,
|
|
24
|
+
payload: pl,
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
tableDiffs: diffs,
|
|
27
|
+
clientId: resolvedOptions?.clientId,
|
|
28
|
+
sessionId: resolvedOptions?.sessionId,
|
|
29
|
+
parentSeqNum: parentSeqNumber
|
|
30
|
+
};
|
|
31
|
+
this.#entries.push(entry);
|
|
32
|
+
this.#headSeq = entry.seq;
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Atomically append multiple events to the log.
|
|
37
|
+
*
|
|
38
|
+
* All events are assigned sequential global sequence numbers and
|
|
39
|
+
* automatically wired as a causal chain (each event's `parentSeqNum`
|
|
40
|
+
* points to the previous event in the batch, or to the log head for
|
|
41
|
+
* the first event).
|
|
42
|
+
* @param events An array of events to commit atomically.
|
|
43
|
+
* @returns The newly created entries in order.
|
|
44
|
+
*/
|
|
45
|
+
commitAll(events) {
|
|
46
|
+
if (events.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const entries = [];
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
const { type } = event;
|
|
52
|
+
const payload = "payload" in event ? event.payload : void 0;
|
|
53
|
+
const ts = "timestamp" in event ? event.timestamp : Date.now();
|
|
54
|
+
const parentSeqNumber = entries.at(-1)?.seq ?? this.#headSeq ?? void 0;
|
|
55
|
+
const seq = this.#nextSeq;
|
|
56
|
+
this.#nextSeq += 1;
|
|
57
|
+
const entry = {
|
|
58
|
+
seq,
|
|
59
|
+
type,
|
|
60
|
+
payload,
|
|
61
|
+
timestamp: ts,
|
|
62
|
+
parentSeqNum: parentSeqNumber
|
|
63
|
+
};
|
|
64
|
+
this.#entries.push(entry);
|
|
65
|
+
entries.push(entry);
|
|
66
|
+
}
|
|
67
|
+
this.#headSeq = entries.at(-1)?.seq ?? this.#headSeq;
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Replace the log contents with a previously captured snapshot.
|
|
72
|
+
* This is the restore counterpart of {@link EventLog#snapshot}.
|
|
73
|
+
* Restores `headSeq` from the snapshot so auto-parenting continues
|
|
74
|
+
* after restore.
|
|
75
|
+
*/
|
|
76
|
+
load(snapshot) {
|
|
77
|
+
this.#entries = [...snapshot.entries];
|
|
78
|
+
this.#nextSeq = snapshot.nextSeq;
|
|
79
|
+
this.#headSeq = snapshot.headSeq;
|
|
80
|
+
}
|
|
81
|
+
// ── Queries ───────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Return **all** entries whose `seq >= sinceSeq`.
|
|
84
|
+
* Useful for catch-up: "give me everything since my last watermark".
|
|
85
|
+
*/
|
|
86
|
+
getSince(sinceSeq) {
|
|
87
|
+
if (sinceSeq <= 0) {
|
|
88
|
+
return [...this.#entries];
|
|
89
|
+
}
|
|
90
|
+
const first = this.#entries.findIndex((entry) => entry.seq >= sinceSeq);
|
|
91
|
+
return first === -1 ? [] : this.#entries.slice(first);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Paginated read starting at `fromSeq`.
|
|
95
|
+
* @returns `{ entries, hasMore }` where `hasMore` is `true` when more
|
|
96
|
+
* entries exist beyond the requested page.
|
|
97
|
+
*/
|
|
98
|
+
getFrom(fromSeq, limit = 50) {
|
|
99
|
+
const first = this.#entries.findIndex((entry) => entry.seq >= fromSeq);
|
|
100
|
+
if (first === -1) {
|
|
101
|
+
return { entries: [], hasMore: false };
|
|
102
|
+
}
|
|
103
|
+
const slice = this.#entries.slice(first, first + limit);
|
|
104
|
+
return {
|
|
105
|
+
entries: slice,
|
|
106
|
+
hasMore: first + limit < this.#entries.length
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Return all entries as a snapshot suitable for serialisation.
|
|
111
|
+
*/
|
|
112
|
+
snapshot() {
|
|
113
|
+
return {
|
|
114
|
+
entries: [...this.#entries],
|
|
115
|
+
nextSeq: this.#nextSeq,
|
|
116
|
+
headSeq: this.#headSeq
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Number of entries currently in the log. */
|
|
120
|
+
get size() {
|
|
121
|
+
return this.#entries.length;
|
|
122
|
+
}
|
|
123
|
+
/** The next sequence number that will be assigned. */
|
|
124
|
+
get nextSeq() {
|
|
125
|
+
return this.#nextSeq;
|
|
126
|
+
}
|
|
127
|
+
/** Return `true` when there are no entries. */
|
|
128
|
+
get isEmpty() {
|
|
129
|
+
return this.#entries.length === 0;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The sequence number of the last (most recent) entry, or `null`
|
|
133
|
+
* when the log is empty. Used internally for auto-parenting and
|
|
134
|
+
* exposed for consumers that need the causal head.
|
|
135
|
+
*/
|
|
136
|
+
get headSeq() {
|
|
137
|
+
return this.#headSeq;
|
|
138
|
+
}
|
|
139
|
+
/** Remove all entries (primarily for testing). */
|
|
140
|
+
clear() {
|
|
141
|
+
this.#entries = [];
|
|
142
|
+
this.#nextSeq = 0;
|
|
143
|
+
this.#headSeq = null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Return an async generator that yields every entry starting from
|
|
147
|
+
* `fromSeq` (default `0` = all entries).
|
|
148
|
+
*
|
|
149
|
+
* Because `EventLog` is purely in-memory, the generator yields all
|
|
150
|
+
* matching entries synchronously on first iteration and then completes.
|
|
151
|
+
* For a streaming / push-based variant see {@link EventSource.events}.
|
|
152
|
+
*/
|
|
153
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- kept async so callers can uniformly `for await` over any event stream
|
|
154
|
+
async *events(fromSeq = 0) {
|
|
155
|
+
const entries = this.getSince(fromSeq);
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
yield entry;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { EventLog };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const toArray = (cursor) => {
|
|
2
|
+
if (typeof cursor.toArray === "function") {
|
|
3
|
+
return cursor.toArray();
|
|
4
|
+
}
|
|
5
|
+
if (typeof cursor[Symbol.iterator] === "function") {
|
|
6
|
+
return [...cursor];
|
|
7
|
+
}
|
|
8
|
+
return [];
|
|
9
|
+
};
|
|
10
|
+
const rowsToEntries = (cursor) => {
|
|
11
|
+
const rows = toArray(cursor);
|
|
12
|
+
return rows.map((row) => {
|
|
13
|
+
return {
|
|
14
|
+
seq: row.seq,
|
|
15
|
+
type: row.type,
|
|
16
|
+
payload: JSON.parse(row.payload),
|
|
17
|
+
timestamp: row.timestamp,
|
|
18
|
+
clientId: row.client_id ?? void 0,
|
|
19
|
+
sessionId: row.session_id ?? void 0,
|
|
20
|
+
parentSeqNum: row.parent_seq ?? void 0
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
class EventLogDO {
|
|
25
|
+
state;
|
|
26
|
+
env;
|
|
27
|
+
/** Whether the `events` table has been created. */
|
|
28
|
+
#initialized = false;
|
|
29
|
+
constructor(state, env) {
|
|
30
|
+
this.state = state;
|
|
31
|
+
this.env = env;
|
|
32
|
+
}
|
|
33
|
+
// ── Fetch (RPC dispatch) ──────────────────────────────────────────
|
|
34
|
+
async fetch(request) {
|
|
35
|
+
this.#ensureTable();
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
try {
|
|
38
|
+
if (request.method === "POST" && url.pathname === "/append") {
|
|
39
|
+
return await this.#handleAppend(request);
|
|
40
|
+
}
|
|
41
|
+
if (request.method === "GET" && url.pathname === "/since") {
|
|
42
|
+
return this.#handleSince(url);
|
|
43
|
+
}
|
|
44
|
+
if (request.method === "GET" && url.pathname === "/range") {
|
|
45
|
+
return this.#handleRange(url);
|
|
46
|
+
}
|
|
47
|
+
if (request.method === "GET" && url.pathname === "/size") {
|
|
48
|
+
return this.#handleSize();
|
|
49
|
+
}
|
|
50
|
+
if (request.method === "GET" && url.pathname === "/state") {
|
|
51
|
+
return this.#handleState();
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error("[event-log-do] request failed:", error);
|
|
55
|
+
return Response.json(
|
|
56
|
+
{
|
|
57
|
+
error: {
|
|
58
|
+
code: "INTERNAL_ERROR",
|
|
59
|
+
message: "internal error"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ status: 500, headers: { "content-type": "application/json" } }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return Response.json(
|
|
66
|
+
{ error: { code: "NOT_FOUND", message: "unknown route" } },
|
|
67
|
+
{
|
|
68
|
+
status: 404,
|
|
69
|
+
headers: { "content-type": "application/json" }
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
// ── Handlers ──────────────────────────────────────────────────────
|
|
74
|
+
/** POST /append — insert events, return entries with assigned seqs. */
|
|
75
|
+
async #handleAppend(request) {
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = await request.json();
|
|
79
|
+
} catch {
|
|
80
|
+
return Response.json(
|
|
81
|
+
{ error: { code: "BAD_REQUEST", message: "invalid JSON body" } },
|
|
82
|
+
{
|
|
83
|
+
status: 400,
|
|
84
|
+
headers: { "content-type": "application/json" }
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(body.events) || body.events.length === 0 || body.events.some((eventRecord) => typeof eventRecord.type !== "string" || eventRecord.type.length === 0)) {
|
|
89
|
+
return Response.json(
|
|
90
|
+
{ error: { code: "BAD_REQUEST", message: "events[] with a non-empty string `type` required" } },
|
|
91
|
+
{
|
|
92
|
+
status: 400,
|
|
93
|
+
headers: { "content-type": "application/json" }
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const entries = [];
|
|
98
|
+
const { sql } = this.state.storage;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const eventRecord of body.events) {
|
|
101
|
+
const seq = EventLogDO.#nextSeq(sql);
|
|
102
|
+
const entry = {
|
|
103
|
+
seq,
|
|
104
|
+
type: eventRecord.type,
|
|
105
|
+
payload: eventRecord.payload,
|
|
106
|
+
timestamp: eventRecord.timestamp ?? now
|
|
107
|
+
};
|
|
108
|
+
EventLogDO.#insertEvent(sql, entry);
|
|
109
|
+
entries.push(entry);
|
|
110
|
+
}
|
|
111
|
+
const response = { entries };
|
|
112
|
+
return Response.json(response, {
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { "content-type": "application/json" }
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** GET /since?seq=N — return entries with seq >= N. */
|
|
118
|
+
#handleSince(url) {
|
|
119
|
+
const seqParameter = url.searchParams.get("seq");
|
|
120
|
+
const sinceSeq = seqParameter === null ? 0 : Number(seqParameter);
|
|
121
|
+
if (!Number.isFinite(sinceSeq) || sinceSeq < 0) {
|
|
122
|
+
return Response.json(
|
|
123
|
+
{ error: { code: "BAD_REQUEST", message: "invalid seq" } },
|
|
124
|
+
{
|
|
125
|
+
status: 400,
|
|
126
|
+
headers: { "content-type": "application/json" }
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const { sql } = this.state.storage;
|
|
131
|
+
const cursor = sql.exec(
|
|
132
|
+
"SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC",
|
|
133
|
+
sinceSeq
|
|
134
|
+
);
|
|
135
|
+
const entries = rowsToEntries(cursor);
|
|
136
|
+
return Response.json(
|
|
137
|
+
{ entries },
|
|
138
|
+
{
|
|
139
|
+
status: 200,
|
|
140
|
+
headers: { "content-type": "application/json" }
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
/** GET /range?from=N&limit=M — paginated read (default limit 50). */
|
|
145
|
+
#handleRange(url) {
|
|
146
|
+
const fromParameter = url.searchParams.get("from");
|
|
147
|
+
const fromSeq = fromParameter === null ? 0 : Number(fromParameter);
|
|
148
|
+
const limitParameter = url.searchParams.get("limit");
|
|
149
|
+
const limit = limitParameter === null ? 50 : Number(limitParameter);
|
|
150
|
+
if (!Number.isFinite(fromSeq) || fromSeq < 0 || !Number.isFinite(limit) || limit < 1 || limit > 1e3) {
|
|
151
|
+
return Response.json(
|
|
152
|
+
{ error: { code: "BAD_REQUEST", message: "invalid from/limit" } },
|
|
153
|
+
{
|
|
154
|
+
status: 400,
|
|
155
|
+
headers: { "content-type": "application/json" }
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const { sql } = this.state.storage;
|
|
160
|
+
const cursor = sql.exec(
|
|
161
|
+
"SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC LIMIT ?",
|
|
162
|
+
fromSeq,
|
|
163
|
+
limit + 1
|
|
164
|
+
// Fetch one extra to detect hasMore
|
|
165
|
+
);
|
|
166
|
+
const allRows = rowsToEntries(cursor);
|
|
167
|
+
const hasMore = allRows.length > limit;
|
|
168
|
+
const entries = hasMore ? allRows.slice(0, limit) : allRows;
|
|
169
|
+
const response = { entries, hasMore };
|
|
170
|
+
return Response.json(response, {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { "content-type": "application/json" }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** GET /size — return the number of stored events. */
|
|
176
|
+
#handleSize() {
|
|
177
|
+
const { sql } = this.state.storage;
|
|
178
|
+
const cursor = sql.exec("SELECT COUNT(*) AS count FROM events");
|
|
179
|
+
const rows = toArray(cursor);
|
|
180
|
+
const count = rows[0]?.count ?? 0;
|
|
181
|
+
return Response.json(
|
|
182
|
+
{ count },
|
|
183
|
+
{
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: { "content-type": "application/json" }
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
/** GET /state — return the full event log state. */
|
|
190
|
+
#handleState() {
|
|
191
|
+
const { sql } = this.state.storage;
|
|
192
|
+
const cursor = sql.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events ORDER BY seq ASC");
|
|
193
|
+
const entries = rowsToEntries(cursor);
|
|
194
|
+
const nextSeq = (entries.at(-1)?.seq ?? -1) + 1;
|
|
195
|
+
return Response.json({ entries, nextSeq }, { status: 200, headers: { "content-type": "application/json" } });
|
|
196
|
+
}
|
|
197
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
198
|
+
#ensureTable() {
|
|
199
|
+
if (this.#initialized) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const { sql } = this.state.storage;
|
|
203
|
+
sql.exec(
|
|
204
|
+
"CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, payload TEXT NOT NULL, timestamp INTEGER NOT NULL, client_id TEXT, session_id TEXT, parent_seq INTEGER)"
|
|
205
|
+
);
|
|
206
|
+
this.#initialized = true;
|
|
207
|
+
}
|
|
208
|
+
/** Get the next available sequence number. */
|
|
209
|
+
static #nextSeq(sql) {
|
|
210
|
+
const cursor = sql.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");
|
|
211
|
+
const rows = toArray(cursor);
|
|
212
|
+
return rows[0]?.next_seq ?? 0;
|
|
213
|
+
}
|
|
214
|
+
/** Insert a single event entry. */
|
|
215
|
+
/* eslint-disable unicorn/no-null -- SQLite bindings use `null` for missing optional columns */
|
|
216
|
+
static #insertEvent(sql, entry) {
|
|
217
|
+
let parentSeq = null;
|
|
218
|
+
if (typeof entry.parentSeqNum === "number") {
|
|
219
|
+
parentSeq = entry.parentSeqNum;
|
|
220
|
+
}
|
|
221
|
+
sql.exec(
|
|
222
|
+
"INSERT INTO events (seq, type, payload, timestamp, client_id, session_id, parent_seq) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
223
|
+
entry.seq,
|
|
224
|
+
entry.type,
|
|
225
|
+
JSON.stringify(entry.payload),
|
|
226
|
+
entry.timestamp,
|
|
227
|
+
entry.clientId ?? null,
|
|
228
|
+
entry.sessionId ?? null,
|
|
229
|
+
parentSeq
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
/* eslint-enable unicorn/no-null */
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { EventLogDO };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
class EventLogDOClient {
|
|
2
|
+
#fetch;
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.#fetch = options.fetch;
|
|
5
|
+
}
|
|
6
|
+
// ── Append ─────────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* Append one or more events to the log.
|
|
9
|
+
* @returns The persisted entries with their assigned `seq` numbers.
|
|
10
|
+
*/
|
|
11
|
+
async append(events) {
|
|
12
|
+
const body = JSON.stringify({ events });
|
|
13
|
+
const response = await this.#fetch(
|
|
14
|
+
new Request("https://do/append", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
body
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw await EventLogDOClient.#toError(response, "append");
|
|
22
|
+
}
|
|
23
|
+
const data = await response.json();
|
|
24
|
+
return data.entries;
|
|
25
|
+
}
|
|
26
|
+
// ── Read / replay ───────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Fetch all entries with `seq >= sinceSeq`.
|
|
29
|
+
*
|
|
30
|
+
* Pass `sinceSeq = 0` to fetch the entire log.
|
|
31
|
+
*/
|
|
32
|
+
async getSince(sinceSeq) {
|
|
33
|
+
const response = await this.#fetch(new Request(`https://do/since?seq=${String(sinceSeq)}`));
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw await EventLogDOClient.#toError(response, "getSince");
|
|
36
|
+
}
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
return data.entries;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a paginated range of entries.
|
|
42
|
+
* @returns `{ entries, hasMore }` — `hasMore` is `true` when another
|
|
43
|
+
* page exists (i.e. the DO returned `limit + 1` rows).
|
|
44
|
+
*/
|
|
45
|
+
async getRange(fromSeq, limit = 50) {
|
|
46
|
+
const response = await this.#fetch(new Request(`https://do/range?from=${String(fromSeq)}&limit=${String(limit)}`));
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw await EventLogDOClient.#toError(response, "getRange");
|
|
49
|
+
}
|
|
50
|
+
return await response.json();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return the total number of entries currently in the log.
|
|
54
|
+
*/
|
|
55
|
+
async getSize() {
|
|
56
|
+
const response = await this.#fetch(new Request("https://do/size"));
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw await EventLogDOClient.#toError(response, "getSize");
|
|
59
|
+
}
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
return data.count;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Return the full log state — all entries plus the next seq number.
|
|
65
|
+
*/
|
|
66
|
+
async getState() {
|
|
67
|
+
const response = await this.#fetch(new Request("https://do/state"));
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw await EventLogDOClient.#toError(response, "getState");
|
|
70
|
+
}
|
|
71
|
+
return await response.json();
|
|
72
|
+
}
|
|
73
|
+
// ── Internal ────────────────────────────────────────────────────────
|
|
74
|
+
/** Parse an error response body and return a descriptive `Error`. */
|
|
75
|
+
static async #toError(response, method) {
|
|
76
|
+
try {
|
|
77
|
+
const body = await response.json();
|
|
78
|
+
const message = body.error?.message ?? response.statusText;
|
|
79
|
+
return new Error(`EventLogDO.${method} failed (${String(response.status)}): ${message}`);
|
|
80
|
+
} catch {
|
|
81
|
+
return new Error(`EventLogDO.${method} failed (${String(response.status)}): ${response.statusText}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { EventLogDOClient };
|