@firtoz/drizzle-durable-sqlite 0.2.1 → 1.0.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/CHANGELOG.md +26 -0
- package/README.md +21 -11
- 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 +5 -12
- package/src/durable-sqlite-sync-server.ts +91 -0
- package/src/index.ts +47 -1
- 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,157 @@
|
|
|
1
|
+
import type { RangeCondition } from "@firtoz/collection-sync";
|
|
2
|
+
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
3
|
+
import {
|
|
4
|
+
and,
|
|
5
|
+
eq,
|
|
6
|
+
getTableColumns,
|
|
7
|
+
gt,
|
|
8
|
+
gte,
|
|
9
|
+
lt,
|
|
10
|
+
lte,
|
|
11
|
+
ne,
|
|
12
|
+
type SQL,
|
|
13
|
+
} from "drizzle-orm";
|
|
14
|
+
import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
15
|
+
|
|
16
|
+
/** Column kind for predicate coercion and sort. */
|
|
17
|
+
export type PartialSyncColumnKind = "text" | "integer";
|
|
18
|
+
|
|
19
|
+
export type PartialSyncTableColumnConfig = {
|
|
20
|
+
kind: PartialSyncColumnKind;
|
|
21
|
+
/**
|
|
22
|
+
* When kind is `integer`, use `Math.trunc` after `Number()` (grid coordinates).
|
|
23
|
+
* Default false: finite number only.
|
|
24
|
+
*/
|
|
25
|
+
truncateInteger?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Declares which table columns exist for predicates/sorts and how to coerce literals.
|
|
30
|
+
* `sortableColumns` must be a subset of keys in `columns`.
|
|
31
|
+
*/
|
|
32
|
+
export type PartialSyncTableConfig<TSortable extends string = string> = {
|
|
33
|
+
columns: Record<string, PartialSyncTableColumnConfig>;
|
|
34
|
+
sortableColumns: readonly TSortable[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function columnRefForPredicate(
|
|
38
|
+
table: SQLiteTable,
|
|
39
|
+
columnName: string,
|
|
40
|
+
columnConfig: PartialSyncTableConfig,
|
|
41
|
+
): SQLiteColumn {
|
|
42
|
+
const meta = columnConfig.columns[columnName];
|
|
43
|
+
if (meta === undefined) {
|
|
44
|
+
throw new Error(`Unsupported predicate column: ${columnName}`);
|
|
45
|
+
}
|
|
46
|
+
const cols = getTableColumns(table);
|
|
47
|
+
const col = cols[columnName];
|
|
48
|
+
if (col === undefined) {
|
|
49
|
+
throw new Error(`Table has no column: ${columnName}`);
|
|
50
|
+
}
|
|
51
|
+
return col as SQLiteColumn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function coercePredicateScalar(
|
|
55
|
+
column: string,
|
|
56
|
+
value: unknown,
|
|
57
|
+
columnConfig: PartialSyncTableConfig,
|
|
58
|
+
): string | number {
|
|
59
|
+
const meta = columnConfig.columns[column];
|
|
60
|
+
if (meta === undefined) {
|
|
61
|
+
throw new Error(`Unsupported predicate column: ${column}`);
|
|
62
|
+
}
|
|
63
|
+
if (meta.kind === "integer") {
|
|
64
|
+
const n = Number(value);
|
|
65
|
+
if (!Number.isFinite(n)) {
|
|
66
|
+
throw new Error(`Predicate ${column} value must be a finite number`);
|
|
67
|
+
}
|
|
68
|
+
return meta.truncateInteger === true ? Math.trunc(n) : n;
|
|
69
|
+
}
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rangeConditionToSQL(
|
|
74
|
+
table: SQLiteTable,
|
|
75
|
+
condition: RangeCondition,
|
|
76
|
+
columnConfig: PartialSyncTableConfig,
|
|
77
|
+
): SQL {
|
|
78
|
+
const col = columnRefForPredicate(table, condition.column, columnConfig);
|
|
79
|
+
switch (condition.op) {
|
|
80
|
+
case "eq":
|
|
81
|
+
return eq(
|
|
82
|
+
col,
|
|
83
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
84
|
+
);
|
|
85
|
+
case "neq":
|
|
86
|
+
return ne(
|
|
87
|
+
col,
|
|
88
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
89
|
+
);
|
|
90
|
+
case "gt":
|
|
91
|
+
return gt(
|
|
92
|
+
col,
|
|
93
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
94
|
+
);
|
|
95
|
+
case "gte":
|
|
96
|
+
return gte(
|
|
97
|
+
col,
|
|
98
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
99
|
+
);
|
|
100
|
+
case "lt":
|
|
101
|
+
return lt(
|
|
102
|
+
col,
|
|
103
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
104
|
+
);
|
|
105
|
+
case "lte":
|
|
106
|
+
return lte(
|
|
107
|
+
col,
|
|
108
|
+
coercePredicateScalar(condition.column, condition.value, columnConfig),
|
|
109
|
+
);
|
|
110
|
+
case "between": {
|
|
111
|
+
const from = coercePredicateScalar(
|
|
112
|
+
condition.column,
|
|
113
|
+
condition.value,
|
|
114
|
+
columnConfig,
|
|
115
|
+
);
|
|
116
|
+
const to = coercePredicateScalar(
|
|
117
|
+
condition.column,
|
|
118
|
+
condition.valueTo,
|
|
119
|
+
columnConfig,
|
|
120
|
+
);
|
|
121
|
+
return and(gte(col, from), lte(col, to)) as SQL;
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
exhaustiveGuard(condition.op);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function predicateWhereFromConditions(
|
|
129
|
+
table: SQLiteTable,
|
|
130
|
+
conditions: RangeCondition[],
|
|
131
|
+
columnConfig: PartialSyncTableConfig,
|
|
132
|
+
): SQL | undefined {
|
|
133
|
+
if (conditions.length === 0) return undefined;
|
|
134
|
+
const parts = conditions.map((c) =>
|
|
135
|
+
rangeConditionToSQL(table, c, columnConfig),
|
|
136
|
+
);
|
|
137
|
+
return parts.length === 1 ? parts[0] : (and(...parts) as SQL);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function sortColumnFromConfig(
|
|
141
|
+
table: SQLiteTable,
|
|
142
|
+
columnName: string,
|
|
143
|
+
columnConfig: PartialSyncTableConfig,
|
|
144
|
+
): SQLiteColumn {
|
|
145
|
+
if (!columnConfig.sortableColumns.includes(columnName as never)) {
|
|
146
|
+
throw new Error(`Unsupported sort column: ${columnName}`);
|
|
147
|
+
}
|
|
148
|
+
if (columnConfig.columns[columnName] === undefined) {
|
|
149
|
+
throw new Error(`Unknown column in sort: ${columnName}`);
|
|
150
|
+
}
|
|
151
|
+
const cols = getTableColumns(table);
|
|
152
|
+
const col = cols[columnName];
|
|
153
|
+
if (col === undefined) {
|
|
154
|
+
throw new Error(`Table has no column: ${columnName}`);
|
|
155
|
+
}
|
|
156
|
+
return col as SQLiteColumn;
|
|
157
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drizzle Durable Object SQLite database used by partial-sync helpers.
|
|
5
|
+
* (Bun/libsql drivers differ in `select` overloads; use DO SQLite in Workers.)
|
|
6
|
+
*/
|
|
7
|
+
export type PartialSyncSqliteDatabase<TSchema extends Record<string, unknown>> =
|
|
8
|
+
DrizzleSqliteDODatabase<TSchema>;
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PartialSyncMutationHandler,
|
|
3
|
+
PartialSyncServerBridge,
|
|
4
|
+
createClientMessageSchema,
|
|
5
|
+
createServerMessageSchema,
|
|
6
|
+
DEFAULT_SYNC_COLLECTION_ID,
|
|
7
|
+
type PartialSyncServerBridgeStore,
|
|
8
|
+
type PartialSyncRowShape,
|
|
9
|
+
type RangeCondition,
|
|
10
|
+
type SyncClientMessage,
|
|
11
|
+
type SyncRange,
|
|
12
|
+
type SyncRangeSort,
|
|
13
|
+
type SyncServerBridgeStore,
|
|
14
|
+
type SyncServerMessage,
|
|
15
|
+
} from "@firtoz/collection-sync";
|
|
16
|
+
import type { SyncMessage } from "@firtoz/db-helpers";
|
|
17
|
+
import {
|
|
18
|
+
ZodSession,
|
|
19
|
+
ZodWebSocketDO,
|
|
20
|
+
type ZodSessionOptions,
|
|
21
|
+
} from "@firtoz/websocket-do";
|
|
22
|
+
import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
|
23
|
+
import { drizzle } from "drizzle-orm/durable-sqlite";
|
|
24
|
+
import { migrate } from "drizzle-orm/durable-sqlite/migrator";
|
|
25
|
+
import type { Context } from "hono";
|
|
26
|
+
|
|
27
|
+
type SessionData = { clientId: string };
|
|
28
|
+
|
|
29
|
+
type MutationSyncRow = PartialSyncRowShape;
|
|
30
|
+
|
|
31
|
+
type SessionDispatch<TRow extends MutationSyncRow> = {
|
|
32
|
+
partialBridge: PartialSyncServerBridge<TRow>;
|
|
33
|
+
partialMutationHandler?: PartialSyncMutationHandler<TRow>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type SessionSlot<TRow extends MutationSyncRow> = {
|
|
37
|
+
dispatch?: SessionDispatch<TRow>;
|
|
38
|
+
pending: SyncClientMessage[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function createSessionCodecOptions<TItem extends PartialSyncRowShape>(
|
|
42
|
+
enableBufferMessages: boolean,
|
|
43
|
+
serializeJson?: (value: unknown) => string,
|
|
44
|
+
deserializeJson?: (raw: string) => unknown,
|
|
45
|
+
): ZodSessionOptions<SyncClientMessage, SyncServerMessage<TItem>> {
|
|
46
|
+
const clientSchema = createClientMessageSchema();
|
|
47
|
+
const serverSchema = createServerMessageSchema<TItem>();
|
|
48
|
+
if (!enableBufferMessages) {
|
|
49
|
+
return {
|
|
50
|
+
clientSchema,
|
|
51
|
+
serverSchema,
|
|
52
|
+
enableBufferMessages: false,
|
|
53
|
+
...(serializeJson && deserializeJson
|
|
54
|
+
? { serializeJson, deserializeJson }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
clientSchema,
|
|
60
|
+
serverSchema,
|
|
61
|
+
enableBufferMessages: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function routeQueryableClientMessage<TRow extends MutationSyncRow>(
|
|
66
|
+
message: SyncClientMessage,
|
|
67
|
+
dispatch: SessionDispatch<TRow>,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const mid = message.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
70
|
+
switch (message.type) {
|
|
71
|
+
case "mutateBatch":
|
|
72
|
+
case "syncHello":
|
|
73
|
+
if (
|
|
74
|
+
dispatch.partialMutationHandler !== undefined &&
|
|
75
|
+
mid === dispatch.partialMutationHandler.collectionId
|
|
76
|
+
) {
|
|
77
|
+
await dispatch.partialMutationHandler.handleClientMessage(message);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
default:
|
|
81
|
+
if (mid === dispatch.partialBridge.collectionId) {
|
|
82
|
+
await dispatch.partialBridge.handleClientMessage(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class QueryableSession<
|
|
88
|
+
TItem extends PartialSyncRowShape,
|
|
89
|
+
TEnv extends Cloudflare.Env,
|
|
90
|
+
> extends ZodSession<
|
|
91
|
+
SessionData,
|
|
92
|
+
SyncServerMessage<TItem>,
|
|
93
|
+
SyncClientMessage,
|
|
94
|
+
TEnv
|
|
95
|
+
> {
|
|
96
|
+
public clientId: string;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
websocket: WebSocket,
|
|
100
|
+
sessions: Map<WebSocket, QueryableSession<TItem, TEnv>>,
|
|
101
|
+
options: ZodSessionOptions<SyncClientMessage, SyncServerMessage<TItem>>,
|
|
102
|
+
private readonly sessionSlot: SessionSlot<TItem>,
|
|
103
|
+
) {
|
|
104
|
+
const generatedClientId = crypto.randomUUID();
|
|
105
|
+
super(websocket, sessions, options, {
|
|
106
|
+
createData: () => ({ clientId: generatedClientId }),
|
|
107
|
+
handleValidatedMessage: async (message: SyncClientMessage) => {
|
|
108
|
+
this.clientId = message.clientId;
|
|
109
|
+
const dispatch = this.sessionSlot.dispatch;
|
|
110
|
+
if (dispatch === undefined) {
|
|
111
|
+
this.sessionSlot.pending.push(message);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await routeQueryableClientMessage(message, dispatch);
|
|
115
|
+
},
|
|
116
|
+
handleClose: async () => {
|
|
117
|
+
const dispatch = this.sessionSlot.dispatch;
|
|
118
|
+
if (dispatch !== undefined) {
|
|
119
|
+
dispatch.partialBridge.removeClient(this.clientId);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
this.clientId = generatedClientId;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type QueryableDurableObjectConfig<
|
|
128
|
+
TSchema extends Record<string, unknown>,
|
|
129
|
+
TRow extends PartialSyncRowShape = PartialSyncRowShape,
|
|
130
|
+
> = {
|
|
131
|
+
schema: TSchema;
|
|
132
|
+
migrations: Parameters<typeof migrate>[1];
|
|
133
|
+
queryChunkSize?: number;
|
|
134
|
+
seedInBackground?: boolean;
|
|
135
|
+
serializeJson?: (value: unknown) => string;
|
|
136
|
+
deserializeJson?: (raw: string) => unknown;
|
|
137
|
+
/**
|
|
138
|
+
* When set, used as the partial-sync store instead of overriding
|
|
139
|
+
* {@link QueryableDurableObject.queryRange}, {@link QueryableDurableObject.queryByOffset},
|
|
140
|
+
* and {@link QueryableDurableObject.getTotalCount}.
|
|
141
|
+
*/
|
|
142
|
+
createPartialSyncStore?: (
|
|
143
|
+
db: DrizzleSqliteDODatabase<TSchema>,
|
|
144
|
+
) => PartialSyncServerBridgeStore<TRow>;
|
|
145
|
+
/**
|
|
146
|
+
* Multiplex key for partial-sync WebSocket messages.
|
|
147
|
+
* When using {@link PartialSyncMutationHandler} on the same socket, use the same id unless you multiplex multiple collections.
|
|
148
|
+
*/
|
|
149
|
+
collectionId?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Server-side narrowing of client predicate viewports (e.g. fog of war). Passed to
|
|
152
|
+
* {@link PartialSyncServerBridge}.
|
|
153
|
+
*/
|
|
154
|
+
resolveClientVisibility?: (
|
|
155
|
+
clientId: string,
|
|
156
|
+
requestedConditions: RangeCondition[],
|
|
157
|
+
) => RangeCondition[] | Promise<RangeCondition[]>;
|
|
158
|
+
/**
|
|
159
|
+
* Optional hints for rows that left the client's range during `rangeReconcile`. Return `null` for
|
|
160
|
+
* fog of war (default when omitted).
|
|
161
|
+
*/
|
|
162
|
+
resolveMovedHint?: (
|
|
163
|
+
row: TRow,
|
|
164
|
+
range: SyncRange,
|
|
165
|
+
) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export abstract class QueryableDurableObject<
|
|
169
|
+
TRow extends PartialSyncRowShape,
|
|
170
|
+
TSchema extends Record<string, unknown>,
|
|
171
|
+
TEnv extends Cloudflare.Env = Cloudflare.Env,
|
|
172
|
+
> extends ZodWebSocketDO<
|
|
173
|
+
QueryableSession<TRow, TEnv>,
|
|
174
|
+
SyncClientMessage,
|
|
175
|
+
SyncServerMessage<TRow>,
|
|
176
|
+
TEnv
|
|
177
|
+
> {
|
|
178
|
+
protected db!: ReturnType<typeof drizzle>;
|
|
179
|
+
protected bridge!: PartialSyncServerBridge<TRow>;
|
|
180
|
+
protected partialMutationHandler?: PartialSyncMutationHandler<TRow>;
|
|
181
|
+
|
|
182
|
+
readonly app = this.getBaseApp().get("/health", (c: Context) => c.text("ok"));
|
|
183
|
+
|
|
184
|
+
constructor(
|
|
185
|
+
ctx: DurableObjectState,
|
|
186
|
+
env: TEnv,
|
|
187
|
+
config: QueryableDurableObjectConfig<TSchema, TRow>,
|
|
188
|
+
) {
|
|
189
|
+
let bridgeRef!: PartialSyncServerBridge<TRow>;
|
|
190
|
+
const sessionSlot: SessionSlot<TRow> = { pending: [] };
|
|
191
|
+
super(ctx, env, {
|
|
192
|
+
zodSessionOptions: (
|
|
193
|
+
sessionCtx: Context<{ Bindings: TEnv }> | undefined,
|
|
194
|
+
) => {
|
|
195
|
+
const useMsgpack =
|
|
196
|
+
sessionCtx !== undefined &&
|
|
197
|
+
new URL(sessionCtx.req.url).searchParams.get("transport") ===
|
|
198
|
+
"msgpack";
|
|
199
|
+
return createSessionCodecOptions<TRow>(
|
|
200
|
+
useMsgpack,
|
|
201
|
+
config.serializeJson,
|
|
202
|
+
config.deserializeJson,
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
createZodSession: (
|
|
206
|
+
_sessionCtx: Context<{ Bindings: TEnv }> | undefined,
|
|
207
|
+
websocket: WebSocket,
|
|
208
|
+
options: ZodSessionOptions<
|
|
209
|
+
SyncClientMessage,
|
|
210
|
+
SyncServerMessage<unknown>
|
|
211
|
+
>,
|
|
212
|
+
) =>
|
|
213
|
+
new QueryableSession<TRow, TEnv>(
|
|
214
|
+
websocket,
|
|
215
|
+
this.sessions as Map<WebSocket, QueryableSession<TRow, TEnv>>,
|
|
216
|
+
options as ZodSessionOptions<
|
|
217
|
+
SyncClientMessage,
|
|
218
|
+
SyncServerMessage<TRow>
|
|
219
|
+
>,
|
|
220
|
+
sessionSlot,
|
|
221
|
+
),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.ctx.blockConcurrencyWhile(async () => {
|
|
225
|
+
const db = drizzle(ctx.storage, { schema: config.schema });
|
|
226
|
+
migrate(db, config.migrations);
|
|
227
|
+
this.db = db;
|
|
228
|
+
|
|
229
|
+
const queryByPredicate = this.queryByPredicate;
|
|
230
|
+
const getPredicateCount = this.getPredicateCount;
|
|
231
|
+
const changesSince = this.changesSince;
|
|
232
|
+
|
|
233
|
+
const store: PartialSyncServerBridgeStore<TRow> =
|
|
234
|
+
config.createPartialSyncStore !== undefined
|
|
235
|
+
? config.createPartialSyncStore(db)
|
|
236
|
+
: {
|
|
237
|
+
queryRange: (options) => this.queryRange(options),
|
|
238
|
+
queryByOffset: (options) => this.queryByOffset(options),
|
|
239
|
+
getTotalCount: async () => this.getTotalCount(),
|
|
240
|
+
getSortValue: (row, column) => this.getSortValue(row, column),
|
|
241
|
+
...(queryByPredicate !== undefined
|
|
242
|
+
? {
|
|
243
|
+
queryByPredicate: (opts: {
|
|
244
|
+
conditions: RangeCondition[];
|
|
245
|
+
sort?: SyncRangeSort;
|
|
246
|
+
limit?: number;
|
|
247
|
+
chunkSize: number;
|
|
248
|
+
}) => queryByPredicate.call(this, opts),
|
|
249
|
+
}
|
|
250
|
+
: {}),
|
|
251
|
+
...(getPredicateCount !== undefined
|
|
252
|
+
? {
|
|
253
|
+
getPredicateCount: (conditions: RangeCondition[]) =>
|
|
254
|
+
getPredicateCount.call(this, conditions),
|
|
255
|
+
}
|
|
256
|
+
: {}),
|
|
257
|
+
...(changesSince !== undefined
|
|
258
|
+
? {
|
|
259
|
+
changesSince: (opts: {
|
|
260
|
+
range: SyncRange;
|
|
261
|
+
sinceVersion: number;
|
|
262
|
+
chunkSize: number;
|
|
263
|
+
}) => changesSince.call(this, opts),
|
|
264
|
+
}
|
|
265
|
+
: {}),
|
|
266
|
+
};
|
|
267
|
+
const collectionId = config.collectionId ?? DEFAULT_SYNC_COLLECTION_ID;
|
|
268
|
+
bridgeRef = new PartialSyncServerBridge<TRow>({
|
|
269
|
+
store,
|
|
270
|
+
sendToClient: (clientId, message) =>
|
|
271
|
+
this.sendToClient(clientId, message),
|
|
272
|
+
queryChunkSize: config.queryChunkSize,
|
|
273
|
+
collectionId,
|
|
274
|
+
...(config.resolveClientVisibility !== undefined
|
|
275
|
+
? {
|
|
276
|
+
resolveClientVisibility: config.resolveClientVisibility,
|
|
277
|
+
}
|
|
278
|
+
: {}),
|
|
279
|
+
...(config.resolveMovedHint !== undefined
|
|
280
|
+
? { resolveMovedHint: config.resolveMovedHint }
|
|
281
|
+
: {}),
|
|
282
|
+
});
|
|
283
|
+
this.bridge = bridgeRef;
|
|
284
|
+
|
|
285
|
+
const mutationStore = this.createClientMutationSyncStore();
|
|
286
|
+
let partialMutationHandler: PartialSyncMutationHandler<TRow> | undefined;
|
|
287
|
+
if (mutationStore !== undefined) {
|
|
288
|
+
partialMutationHandler = new PartialSyncMutationHandler<TRow>({
|
|
289
|
+
store: mutationStore,
|
|
290
|
+
partialBridge: bridgeRef,
|
|
291
|
+
sendToClient: (clientId, message) =>
|
|
292
|
+
this.sendToClient(clientId, message),
|
|
293
|
+
collectionId,
|
|
294
|
+
});
|
|
295
|
+
this.partialMutationHandler = partialMutationHandler;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
sessionSlot.dispatch = {
|
|
299
|
+
partialBridge: bridgeRef,
|
|
300
|
+
partialMutationHandler,
|
|
301
|
+
};
|
|
302
|
+
for (const message of sessionSlot.pending) {
|
|
303
|
+
await routeQueryableClientMessage(message, sessionSlot.dispatch);
|
|
304
|
+
}
|
|
305
|
+
sessionSlot.pending.length = 0;
|
|
306
|
+
if (config.seedInBackground) {
|
|
307
|
+
void this.seedData().catch((error: unknown) => {
|
|
308
|
+
console.error("Background seedData failed", error);
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await this.seedData();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
protected queryRange(_options: {
|
|
317
|
+
sort: { column: string; direction: "asc" | "desc" };
|
|
318
|
+
limit: number;
|
|
319
|
+
afterCursor: unknown | null;
|
|
320
|
+
chunkSize: number;
|
|
321
|
+
}): AsyncIterable<TRow[]> {
|
|
322
|
+
const message =
|
|
323
|
+
"QueryableDurableObject: override queryRange() or pass createPartialSyncStore in config";
|
|
324
|
+
return {
|
|
325
|
+
[Symbol.asyncIterator](): AsyncIterator<TRow[]> {
|
|
326
|
+
return {
|
|
327
|
+
next: () => Promise.reject(new Error(message)),
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
protected queryByOffset(_options: {
|
|
334
|
+
sort: { column: string; direction: "asc" | "desc" };
|
|
335
|
+
limit: number;
|
|
336
|
+
offset: number;
|
|
337
|
+
chunkSize: number;
|
|
338
|
+
}): AsyncIterable<TRow[]> {
|
|
339
|
+
const message =
|
|
340
|
+
"QueryableDurableObject: override queryByOffset() or pass createPartialSyncStore in config";
|
|
341
|
+
return {
|
|
342
|
+
[Symbol.asyncIterator](): AsyncIterator<TRow[]> {
|
|
343
|
+
return {
|
|
344
|
+
next: () => Promise.reject(new Error(message)),
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
protected async getTotalCount(): Promise<number> {
|
|
351
|
+
throw new Error(
|
|
352
|
+
"QueryableDurableObject: override getTotalCount() or pass createPartialSyncStore in config",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
protected queryByPredicate?(_options: {
|
|
357
|
+
conditions: RangeCondition[];
|
|
358
|
+
sort?: SyncRangeSort;
|
|
359
|
+
limit?: number;
|
|
360
|
+
chunkSize: number;
|
|
361
|
+
}): AsyncIterable<TRow[]>;
|
|
362
|
+
|
|
363
|
+
protected getPredicateCount?(_conditions: RangeCondition[]): Promise<number>;
|
|
364
|
+
|
|
365
|
+
protected changesSince?(_options: {
|
|
366
|
+
range: SyncRange;
|
|
367
|
+
sinceVersion: number;
|
|
368
|
+
chunkSize: number;
|
|
369
|
+
}): Promise<{ changes: SyncMessage<TRow>[]; totalCount: number } | null>;
|
|
370
|
+
|
|
371
|
+
protected getSortValue(row: TRow, column: string): unknown {
|
|
372
|
+
return (row as Record<string, unknown>)[column];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* When overridden to return a store, `mutateBatch` is handled by {@link PartialSyncMutationHandler}
|
|
377
|
+
* (interest-scoped `rangePatch` + `ack` with `serverVersion: 0`). `syncHello` is not handled on this path.
|
|
378
|
+
* Range traffic stays on {@link PartialSyncServerBridge}.
|
|
379
|
+
*/
|
|
380
|
+
protected createClientMutationSyncStore():
|
|
381
|
+
| SyncServerBridgeStore<TRow>
|
|
382
|
+
| undefined {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
protected async seedData(): Promise<void> {}
|
|
387
|
+
|
|
388
|
+
async pushServerChanges(changes: SyncMessage<TRow>[]): Promise<void> {
|
|
389
|
+
await this.bridge.pushServerChanges(changes);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
protected sendToClient(
|
|
393
|
+
clientId: string,
|
|
394
|
+
message: SyncServerMessage<TRow>,
|
|
395
|
+
): void {
|
|
396
|
+
for (const session of this.sessions.values()) {
|
|
397
|
+
const typedSession = session as QueryableSession<TRow, TEnv>;
|
|
398
|
+
if (typedSession.clientId !== clientId) continue;
|
|
399
|
+
typedSession.send(message);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
protected broadcastExcept(
|
|
404
|
+
excludeClientId: string,
|
|
405
|
+
message: SyncServerMessage<TRow>,
|
|
406
|
+
): void {
|
|
407
|
+
for (const session of this.sessions.values()) {
|
|
408
|
+
const typedSession = session as QueryableSession<TRow, TEnv>;
|
|
409
|
+
if (typedSession.clientId === excludeClientId) continue;
|
|
410
|
+
typedSession.send(message);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|