@firtoz/drizzle-durable-sqlite 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +22 -7
- package/package.json +14 -10
- package/src/drizzle-mutation-store.ts +105 -0
- package/src/drizzle-partial-sync-changelog.ts +65 -0
- package/src/drizzle-partial-sync-store.ts +442 -0
- package/src/durable-sqlite-collection.ts +9 -3
- package/src/durable-sqlite-sync-server.ts +91 -0
- package/src/index.ts +47 -0
- package/src/partial-sync-predicate-sql.ts +157 -0
- package/src/partial-sync-sqlite-db.ts +8 -0
- package/src/queryable-durable-object.ts +413 -0
- package/src/syncable-durable-object.ts +284 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SyncServerBridge,
|
|
3
|
+
createClientMessageSchema,
|
|
4
|
+
createServerMessageSchema,
|
|
5
|
+
type PartialSyncRowShape,
|
|
6
|
+
type SyncClientMessage,
|
|
7
|
+
type SyncServerMessage,
|
|
8
|
+
} from "@firtoz/collection-sync";
|
|
9
|
+
import type { SyncMessage } from "@firtoz/db-helpers";
|
|
10
|
+
import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
|
|
11
|
+
import { createCollection } from "@tanstack/db";
|
|
12
|
+
import { drizzle } from "drizzle-orm/durable-sqlite";
|
|
13
|
+
import { migrate } from "drizzle-orm/durable-sqlite/migrator";
|
|
14
|
+
import type { Context } from "hono";
|
|
15
|
+
import type {
|
|
16
|
+
DrizzleSqliteTableCollection,
|
|
17
|
+
SelectSchema,
|
|
18
|
+
TableWithRequiredFields,
|
|
19
|
+
} from "@firtoz/drizzle-utils";
|
|
20
|
+
import {
|
|
21
|
+
ZodSession,
|
|
22
|
+
ZodWebSocketDO,
|
|
23
|
+
type ZodSessionOptions,
|
|
24
|
+
} from "@firtoz/websocket-do";
|
|
25
|
+
import {
|
|
26
|
+
durableSqliteCollectionOptions,
|
|
27
|
+
type ValidTableNames,
|
|
28
|
+
} from "./durable-sqlite-collection";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Drizzle/Valibot `InferSchemaOutput` is not always structurally assignable to
|
|
32
|
+
* {@link PartialSyncRowShape}. Intersecting keeps inferred columns while requiring sync row keys for
|
|
33
|
+
* {@link SyncServerBridge}.
|
|
34
|
+
*/
|
|
35
|
+
type SyncBridgeRowFromTable<TTable extends TableWithRequiredFields> =
|
|
36
|
+
InferSchemaOutput<SelectSchema<TTable>> & PartialSyncRowShape;
|
|
37
|
+
|
|
38
|
+
export type SyncableDurableObjectSyncRow<
|
|
39
|
+
TSchema extends Record<string, unknown>,
|
|
40
|
+
TTableName extends ValidTableNames<TSchema>,
|
|
41
|
+
> = SyncBridgeRowFromTable<TSchema[TTableName] & TableWithRequiredFields>;
|
|
42
|
+
|
|
43
|
+
type SessionData = { clientId: string };
|
|
44
|
+
|
|
45
|
+
function createSessionCodecOptions<TItem extends PartialSyncRowShape>(
|
|
46
|
+
enableBufferMessages: boolean,
|
|
47
|
+
serializeJson?: (value: unknown) => string,
|
|
48
|
+
deserializeJson?: (raw: string) => unknown,
|
|
49
|
+
): ZodSessionOptions<SyncClientMessage, SyncServerMessage<TItem>> {
|
|
50
|
+
const clientSchema = createClientMessageSchema();
|
|
51
|
+
const serverSchema = createServerMessageSchema<TItem>();
|
|
52
|
+
if (!enableBufferMessages) {
|
|
53
|
+
return {
|
|
54
|
+
clientSchema,
|
|
55
|
+
serverSchema,
|
|
56
|
+
enableBufferMessages: false,
|
|
57
|
+
...(serializeJson && deserializeJson
|
|
58
|
+
? { serializeJson, deserializeJson }
|
|
59
|
+
: {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
clientSchema,
|
|
64
|
+
serverSchema,
|
|
65
|
+
enableBufferMessages: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class SyncTableSession<
|
|
70
|
+
TItem extends PartialSyncRowShape,
|
|
71
|
+
TEnv extends Cloudflare.Env,
|
|
72
|
+
> extends ZodSession<
|
|
73
|
+
SessionData,
|
|
74
|
+
SyncServerMessage<TItem>,
|
|
75
|
+
SyncClientMessage,
|
|
76
|
+
TEnv
|
|
77
|
+
> {
|
|
78
|
+
public clientId: string;
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
websocket: WebSocket,
|
|
82
|
+
sessions: Map<WebSocket, SyncTableSession<TItem, TEnv>>,
|
|
83
|
+
options: ZodSessionOptions<SyncClientMessage, SyncServerMessage<TItem>>,
|
|
84
|
+
bridge: SyncServerBridge<TItem>,
|
|
85
|
+
) {
|
|
86
|
+
const generatedClientId = crypto.randomUUID();
|
|
87
|
+
super(websocket, sessions, options, {
|
|
88
|
+
createData: () => ({ clientId: generatedClientId }),
|
|
89
|
+
handleValidatedMessage: async (message: SyncClientMessage) => {
|
|
90
|
+
this.clientId = message.clientId;
|
|
91
|
+
await bridge.handleClientMessage(message);
|
|
92
|
+
},
|
|
93
|
+
handleClose: async () => {},
|
|
94
|
+
});
|
|
95
|
+
this.clientId = generatedClientId;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type SyncableDurableObjectConfig<
|
|
100
|
+
TSchema extends Record<string, unknown>,
|
|
101
|
+
TTableName extends ValidTableNames<TSchema>,
|
|
102
|
+
> = {
|
|
103
|
+
schema: TSchema;
|
|
104
|
+
tableName: TTableName;
|
|
105
|
+
migrations: Parameters<typeof migrate>[1];
|
|
106
|
+
syncMode?: SyncMode;
|
|
107
|
+
serializeJson?: (value: unknown) => string;
|
|
108
|
+
deserializeJson?: (raw: string) => unknown;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Durable Object base class: Drizzle SQLite + {@link SyncServerBridge} + WebSocket sessions.
|
|
113
|
+
*/
|
|
114
|
+
export abstract class SyncableDurableObject<
|
|
115
|
+
TSchema extends Record<string, unknown>,
|
|
116
|
+
TTableName extends ValidTableNames<TSchema>,
|
|
117
|
+
TEnv extends Cloudflare.Env = Cloudflare.Env,
|
|
118
|
+
> extends ZodWebSocketDO<
|
|
119
|
+
// biome-ignore lint/suspicious/noExplicitAny: ZodWebSocketDO session generic is internal; row type is fixed in constructor.
|
|
120
|
+
any,
|
|
121
|
+
SyncClientMessage,
|
|
122
|
+
SyncServerMessage<unknown>,
|
|
123
|
+
TEnv
|
|
124
|
+
> {
|
|
125
|
+
protected bridge!: SyncServerBridge<
|
|
126
|
+
SyncableDurableObjectSyncRow<TSchema, TTableName>
|
|
127
|
+
>;
|
|
128
|
+
|
|
129
|
+
protected collection!: DrizzleSqliteTableCollection<
|
|
130
|
+
TSchema[TTableName] & TableWithRequiredFields
|
|
131
|
+
>;
|
|
132
|
+
|
|
133
|
+
readonly app = this.getBaseApp().get("/health", (c: Context) => c.text("ok"));
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
ctx: DurableObjectState,
|
|
137
|
+
env: TEnv,
|
|
138
|
+
config: SyncableDurableObjectConfig<TSchema, TTableName>,
|
|
139
|
+
) {
|
|
140
|
+
type TTable = TSchema[TTableName] & TableWithRequiredFields;
|
|
141
|
+
type TRow = SyncBridgeRowFromTable<TTable>;
|
|
142
|
+
|
|
143
|
+
let bridgeRef!: SyncServerBridge<TRow>;
|
|
144
|
+
|
|
145
|
+
super(ctx, env, {
|
|
146
|
+
zodSessionOptions: (
|
|
147
|
+
sessionCtx: Context<{ Bindings: TEnv }> | undefined,
|
|
148
|
+
) => {
|
|
149
|
+
const useMsgpack =
|
|
150
|
+
sessionCtx !== undefined &&
|
|
151
|
+
new URL(sessionCtx.req.url).searchParams.get("transport") ===
|
|
152
|
+
"msgpack";
|
|
153
|
+
return createSessionCodecOptions<TRow>(
|
|
154
|
+
useMsgpack,
|
|
155
|
+
config.serializeJson,
|
|
156
|
+
config.deserializeJson,
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
createZodSession: (
|
|
160
|
+
_sessionCtx: Context<{ Bindings: TEnv }> | undefined,
|
|
161
|
+
websocket: WebSocket,
|
|
162
|
+
options: ZodSessionOptions<
|
|
163
|
+
SyncClientMessage,
|
|
164
|
+
SyncServerMessage<unknown>
|
|
165
|
+
>,
|
|
166
|
+
) => {
|
|
167
|
+
return new SyncTableSession<TRow, TEnv>(
|
|
168
|
+
websocket,
|
|
169
|
+
this.sessions as Map<WebSocket, SyncTableSession<TRow, TEnv>>,
|
|
170
|
+
options as ZodSessionOptions<
|
|
171
|
+
SyncClientMessage,
|
|
172
|
+
SyncServerMessage<TRow>
|
|
173
|
+
>,
|
|
174
|
+
bridgeRef,
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.ctx.blockConcurrencyWhile(async () => {
|
|
180
|
+
const db = drizzle(ctx.storage, { schema: config.schema });
|
|
181
|
+
migrate(db, config.migrations);
|
|
182
|
+
|
|
183
|
+
const tableName = config.tableName as never;
|
|
184
|
+
|
|
185
|
+
const collection = createCollection(
|
|
186
|
+
durableSqliteCollectionOptions({
|
|
187
|
+
drizzle: db,
|
|
188
|
+
tableName,
|
|
189
|
+
syncMode: config.syncMode ?? "eager",
|
|
190
|
+
// biome-ignore lint/suspicious/noExplicitAny: TanStack collection + Drizzle generic row inference is too heavy for TS here.
|
|
191
|
+
}) as any,
|
|
192
|
+
) as DrizzleSqliteTableCollection<TTable>;
|
|
193
|
+
|
|
194
|
+
const col = collection as unknown as {
|
|
195
|
+
insert: (v: unknown) => { isPersisted: { promise: Promise<void> } };
|
|
196
|
+
update: (
|
|
197
|
+
key: string | number,
|
|
198
|
+
fn: (draft: unknown) => void,
|
|
199
|
+
) => { isPersisted: { promise: Promise<void> } };
|
|
200
|
+
delete: (key: string | number) => {
|
|
201
|
+
isPersisted: { promise: Promise<void> };
|
|
202
|
+
};
|
|
203
|
+
utils: { truncate: () => Promise<void> };
|
|
204
|
+
toArray: unknown[];
|
|
205
|
+
state: { get: (key: string | number) => unknown | undefined };
|
|
206
|
+
preload: () => void;
|
|
207
|
+
onFirstReady: (cb: () => void) => void;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
col.preload();
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
col.onFirstReady(() => resolve());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
bridgeRef = new SyncServerBridge<TRow>({
|
|
216
|
+
store: {
|
|
217
|
+
applySyncMessages: async (messages: SyncMessage<TRow>[]) => {
|
|
218
|
+
for (const message of messages) {
|
|
219
|
+
if (message.type === "insert") {
|
|
220
|
+
const tx = col.insert(message.value);
|
|
221
|
+
await tx.isPersisted.promise;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (message.type === "update") {
|
|
225
|
+
const value = message.value as { id: string | number };
|
|
226
|
+
const tx = col.update(value.id, (draft) => {
|
|
227
|
+
Object.assign(draft as object, message.value);
|
|
228
|
+
});
|
|
229
|
+
await tx.isPersisted.promise;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (message.type === "delete") {
|
|
233
|
+
const tx = col.delete(message.key);
|
|
234
|
+
await tx.isPersisted.promise;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
await col.utils.truncate();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
getSnapshotMessages: async () => {
|
|
241
|
+
return (col.toArray as TRow[]).map((row) => ({
|
|
242
|
+
type: "insert" as const,
|
|
243
|
+
value: row,
|
|
244
|
+
}));
|
|
245
|
+
},
|
|
246
|
+
getRow: async (key: string | number) => {
|
|
247
|
+
return col.state.get(key) as TRow | undefined;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
sendToClient: (clientId: string, message: SyncServerMessage<TRow>) => {
|
|
251
|
+
for (const session of this.sessions.values()) {
|
|
252
|
+
const s = session as SyncTableSession<TRow, TEnv>;
|
|
253
|
+
if (s.clientId === clientId) {
|
|
254
|
+
s.send(message);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
broadcastExcept: (
|
|
260
|
+
excludeClientId: string,
|
|
261
|
+
message: SyncServerMessage<TRow>,
|
|
262
|
+
) => {
|
|
263
|
+
for (const session of this.sessions.values()) {
|
|
264
|
+
const s = session as SyncTableSession<TRow, TEnv>;
|
|
265
|
+
if (s.clientId === excludeClientId) continue;
|
|
266
|
+
s.send(message);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
broadcastAll: (message: SyncServerMessage<TRow>) => {
|
|
270
|
+
for (const session of this.sessions.values()) {
|
|
271
|
+
(session as SyncTableSession<TRow, TEnv>).send(message);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
this.bridge = bridgeRef as SyncableDurableObject<
|
|
277
|
+
TSchema,
|
|
278
|
+
TTableName,
|
|
279
|
+
TEnv
|
|
280
|
+
>["bridge"];
|
|
281
|
+
this.collection = collection;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|