@livestore/sync-cf 0.4.0-dev.9 → 0.4.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/README.md +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +13 -8
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +5 -5
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +32 -9
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +7 -2
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +16 -10
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +5 -4
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +25 -17
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/sqlite.d.ts +10 -1
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +13 -4
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +14 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +92 -18
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +13 -7
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +4 -2
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +24 -15
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +2 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +30 -8
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +118 -31
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +40 -7
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +46 -38
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +51 -34
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +27 -10
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +29 -9
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +2 -1
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +31 -17
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +6 -6
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +4 -4
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +4 -4
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +4 -4
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +4 -1
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -1
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +7 -7
- package/dist/common/sync-message-types.js +3 -3
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +3 -3
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +3 -3
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +72 -14
- package/src/cf-worker/do/durable-object.ts +19 -10
- package/src/cf-worker/do/layer.ts +35 -13
- package/src/cf-worker/do/pull.ts +31 -14
- package/src/cf-worker/do/push.ts +49 -34
- package/src/cf-worker/do/sqlite.ts +14 -4
- package/src/cf-worker/do/sync-storage.ts +151 -31
- package/src/cf-worker/do/transport/do-rpc-server.ts +18 -7
- package/src/cf-worker/do/transport/http-rpc-server.ts +33 -13
- package/src/cf-worker/do/transport/ws-rpc-server.ts +40 -12
- package/src/cf-worker/shared.ts +136 -25
- package/src/cf-worker/worker.ts +107 -54
- package/src/client/transport/do-rpc-client.ts +41 -17
- package/src/client/transport/http-rpc-client.ts +43 -17
- package/src/client/transport/ws-rpc-client.ts +42 -19
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +5 -4
- package/src/common/http-rpc-schema.ts +5 -4
- package/src/common/mod.ts +4 -2
- package/src/common/sync-message-types.ts +3 -3
- package/src/common/ws-rpc-schema.ts +4 -3
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
package/src/cf-worker/do/push.ts
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BackendIdMismatchError,
|
|
3
|
-
InvalidPushError,
|
|
4
3
|
ServerAheadError,
|
|
5
4
|
SyncBackend,
|
|
6
|
-
|
|
5
|
+
UnknownError,
|
|
7
6
|
} from '@livestore/common'
|
|
8
7
|
import { type CfTypes, emitStreamResponse } from '@livestore/common-cf'
|
|
8
|
+
import { splitChunkBySize } from '@livestore/common/sync'
|
|
9
9
|
import { Chunk, Effect, Option, type RpcMessage, Schema } from '@livestore/utils/effect'
|
|
10
|
+
|
|
11
|
+
import { MAX_PUSH_EVENTS_PER_REQUEST, MAX_WS_MESSAGE_BYTES } from '../../common/constants.ts'
|
|
10
12
|
import { SyncMessage } from '../../common/mod.ts'
|
|
11
13
|
import {
|
|
12
14
|
type Env,
|
|
13
|
-
|
|
14
|
-
MAX_WS_MESSAGE_BYTES,
|
|
15
|
+
type ForwardedHeaders,
|
|
15
16
|
type MakeDurableObjectClassOptions,
|
|
16
17
|
type StoreId,
|
|
17
18
|
WebSocketAttachmentSchema,
|
|
18
19
|
} from '../shared.ts'
|
|
19
20
|
import { DoCtx } from './layer.ts'
|
|
20
|
-
import { splitChunkBySize } from './ws-chunking.ts'
|
|
21
21
|
|
|
22
22
|
const encodePullResponse = Schema.encodeSync(SyncMessage.PullResponse)
|
|
23
|
+
const jsonStringify = Schema.encodeSync(Schema.parseJson())
|
|
23
24
|
type PullBatchItem = SyncMessage.PullResponse['batch'][number]
|
|
24
25
|
|
|
25
26
|
export const makePush =
|
|
26
27
|
({
|
|
27
28
|
payload,
|
|
29
|
+
headers,
|
|
28
30
|
options,
|
|
29
31
|
storeId,
|
|
30
32
|
ctx,
|
|
31
33
|
env,
|
|
32
34
|
}: {
|
|
33
35
|
payload: Schema.JsonValue | undefined
|
|
36
|
+
headers: ForwardedHeaders | undefined
|
|
34
37
|
options: MakeDurableObjectClassOptions | undefined
|
|
35
38
|
storeId: StoreId
|
|
36
39
|
ctx: CfTypes.DurableObjectState
|
|
@@ -45,9 +48,15 @@ export const makePush =
|
|
|
45
48
|
return SyncMessage.PushAck.make({})
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
if (options?.onPush) {
|
|
49
|
-
yield* Effect.tryAll(() =>
|
|
50
|
-
|
|
51
|
+
if (options?.onPush !== undefined) {
|
|
52
|
+
yield* Effect.tryAll(() =>
|
|
53
|
+
options.onPush!(pushRequest, {
|
|
54
|
+
storeId,
|
|
55
|
+
...(payload !== undefined ? { payload } : {}),
|
|
56
|
+
...(headers !== undefined ? { headers } : {}),
|
|
57
|
+
}),
|
|
58
|
+
).pipe(
|
|
59
|
+
UnknownError.mapToUnknownError,
|
|
51
60
|
)
|
|
52
61
|
}
|
|
53
62
|
|
|
@@ -87,9 +96,9 @@ export const makePush =
|
|
|
87
96
|
const connectedClients = ctx.getWebSockets()
|
|
88
97
|
|
|
89
98
|
// Preparing chunks of responses to make sure we don't exceed the WS message size limit.
|
|
90
|
-
const responses = Chunk.fromIterable(pushRequest.batch).pipe(
|
|
99
|
+
const responses = yield* Chunk.fromIterable(pushRequest.batch).pipe(
|
|
91
100
|
splitChunkBySize({
|
|
92
|
-
maxItems:
|
|
101
|
+
maxItems: MAX_PUSH_EVENTS_PER_REQUEST,
|
|
93
102
|
maxBytes: MAX_WS_MESSAGE_BYTES,
|
|
94
103
|
encode: (items) =>
|
|
95
104
|
encodePullResponse(
|
|
@@ -105,23 +114,25 @@ export const makePush =
|
|
|
105
114
|
}),
|
|
106
115
|
),
|
|
107
116
|
}),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
eventEncoded
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
Effect.map(
|
|
118
|
+
Chunk.map((eventsChunk) => {
|
|
119
|
+
const batchWithMetadata = Chunk.toReadonlyArray(eventsChunk).map((eventEncoded) => ({
|
|
120
|
+
eventEncoded,
|
|
121
|
+
metadata: Option.some(SyncMessage.SyncMetadata.make({ createdAt })),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
const response = SyncMessage.PullResponse.make({
|
|
125
|
+
batch: batchWithMetadata,
|
|
126
|
+
pageInfo: SyncBackend.pageInfoNoMore,
|
|
127
|
+
backendId,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
response,
|
|
132
|
+
encoded: Schema.encodeSync(SyncMessage.PullResponse)(response),
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
125
136
|
)
|
|
126
137
|
|
|
127
138
|
// Dual broadcasting: WebSocket + RPC clients
|
|
@@ -130,13 +141,13 @@ export const makePush =
|
|
|
130
141
|
if (connectedClients.length > 0) {
|
|
131
142
|
for (const { response, encoded } of responses) {
|
|
132
143
|
// Only calling once for now.
|
|
133
|
-
if (options?.onPullRes) {
|
|
134
|
-
yield* Effect.tryAll(() => options.onPullRes!(response)).pipe(
|
|
144
|
+
if (options?.onPullRes !== undefined) {
|
|
145
|
+
yield* Effect.tryAll(() => options.onPullRes!(response)).pipe(UnknownError.mapToUnknownError)
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
// NOTE we're also sending the pullRes chunk to the pushing ws client as confirmation
|
|
138
149
|
for (const conn of connectedClients) {
|
|
139
|
-
const attachment = Schema.
|
|
150
|
+
const attachment = yield* Schema.decode(WebSocketAttachmentSchema)(conn.deserializeAttachment())
|
|
140
151
|
|
|
141
152
|
// We're doing something a bit "advanced" here as we're directly emitting Effect RPC-compatible
|
|
142
153
|
// response messsages on the Effect RPC-managed websocket connection to the WS client.
|
|
@@ -147,7 +158,7 @@ export const makePush =
|
|
|
147
158
|
requestId,
|
|
148
159
|
values: [encoded],
|
|
149
160
|
}
|
|
150
|
-
conn.send(
|
|
161
|
+
conn.send(jsonStringify(res))
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
}
|
|
@@ -184,12 +195,16 @@ export const makePush =
|
|
|
184
195
|
}).pipe(
|
|
185
196
|
Effect.tap(
|
|
186
197
|
Effect.fn(function* (message) {
|
|
187
|
-
if (options?.onPushRes) {
|
|
188
|
-
yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(
|
|
198
|
+
if (options?.onPushRes !== undefined) {
|
|
199
|
+
yield* Effect.tryAll(() => options.onPushRes!(message)).pipe(UnknownError.mapToUnknownError)
|
|
189
200
|
}
|
|
190
201
|
}),
|
|
191
202
|
),
|
|
192
|
-
Effect.mapError((cause) =>
|
|
203
|
+
Effect.mapError((cause) =>
|
|
204
|
+
cause._tag === 'BackendIdMismatchError' || cause._tag === 'ServerAheadError' || cause._tag === 'UnknownError'
|
|
205
|
+
? cause
|
|
206
|
+
: new UnknownError({ cause }),
|
|
207
|
+
),
|
|
193
208
|
Effect.withSpan('sync-cf:do:push', { attributes: { storeId, batchSize: pushRequest.batch.length } }),
|
|
194
209
|
)
|
|
195
210
|
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { EventSequenceNumber, State } from '@livestore/common/schema'
|
|
2
2
|
import { Schema } from '@livestore/utils/effect'
|
|
3
|
+
|
|
3
4
|
import { PERSISTENCE_FORMAT_VERSION } from '../shared.ts'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Main event log table storing all LiveStore events.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IMPORTANT: Any changes to this schema require bumping PERSISTENCE_FORMAT_VERSION in shared.ts
|
|
10
|
+
*/
|
|
5
11
|
export const eventlogTable = State.SQLite.table({
|
|
6
12
|
// NOTE actual table name is determined at runtime to use proper storeId
|
|
7
13
|
name: `eventlog_${PERSISTENCE_FORMAT_VERSION}_$storeId`,
|
|
8
14
|
columns: {
|
|
9
|
-
seqNum: State.SQLite.integer({ primaryKey: true, schema: EventSequenceNumber.
|
|
10
|
-
parentSeqNum: State.SQLite.integer({ schema: EventSequenceNumber.
|
|
15
|
+
seqNum: State.SQLite.integer({ primaryKey: true, schema: EventSequenceNumber.Global.Schema }),
|
|
16
|
+
parentSeqNum: State.SQLite.integer({ schema: EventSequenceNumber.Global.Schema }),
|
|
11
17
|
name: State.SQLite.text({}),
|
|
12
18
|
args: State.SQLite.text({ schema: Schema.parseJson(Schema.Any), nullable: true }),
|
|
13
19
|
/** ISO date format. Currently only used for debugging purposes. */
|
|
@@ -17,12 +23,16 @@ export const eventlogTable = State.SQLite.table({
|
|
|
17
23
|
},
|
|
18
24
|
})
|
|
19
25
|
|
|
20
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Context metadata table - one row per Durable Object.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ IMPORTANT: Any changes to this schema require bumping PERSISTENCE_FORMAT_VERSION in shared.ts
|
|
30
|
+
*/
|
|
21
31
|
export const contextTable = State.SQLite.table({
|
|
22
32
|
name: `context_${PERSISTENCE_FORMAT_VERSION}`,
|
|
23
33
|
columns: {
|
|
24
34
|
storeId: State.SQLite.text({ primaryKey: true }),
|
|
25
|
-
currentHead: State.SQLite.integer({ schema: EventSequenceNumber.
|
|
35
|
+
currentHead: State.SQLite.integer({ schema: EventSequenceNumber.Global.Schema }),
|
|
26
36
|
backendId: State.SQLite.text({}),
|
|
27
37
|
},
|
|
28
38
|
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
3
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
+
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
4
4
|
import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
|
|
5
|
+
|
|
5
6
|
import { SyncMetadata } from '../../common/sync-message-types.ts'
|
|
6
|
-
import {
|
|
7
|
+
import { PERSISTENCE_FORMAT_VERSION, type StoreId } from '../shared.ts'
|
|
7
8
|
import { eventlogTable } from './sqlite.ts'
|
|
8
9
|
|
|
9
10
|
export type SyncStorage = {
|
|
@@ -12,26 +13,30 @@ export type SyncStorage = {
|
|
|
12
13
|
{
|
|
13
14
|
total: number
|
|
14
15
|
stream: Stream.Stream<
|
|
15
|
-
{ eventEncoded: LiveStoreEvent.
|
|
16
|
-
|
|
16
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
17
|
+
UnknownError
|
|
17
18
|
>
|
|
18
19
|
},
|
|
19
|
-
|
|
20
|
+
UnknownError
|
|
20
21
|
>
|
|
21
22
|
appendEvents: (
|
|
22
|
-
batch: ReadonlyArray<LiveStoreEvent.
|
|
23
|
+
batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
|
|
23
24
|
createdAt: string,
|
|
24
|
-
) => Effect.Effect<void,
|
|
25
|
-
resetStore: Effect.Effect<void,
|
|
25
|
+
) => Effect.Effect<void, UnknownError>
|
|
26
|
+
resetStore: Effect.Effect<void, UnknownError>
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export const makeStorage = (
|
|
29
|
+
export const makeStorage = (
|
|
30
|
+
ctx: CfTypes.DurableObjectState,
|
|
31
|
+
storeId: StoreId,
|
|
32
|
+
engine: { _tag: 'd1'; db: CfTypes.D1Database } | { _tag: 'do-sqlite' },
|
|
33
|
+
): SyncStorage => {
|
|
29
34
|
const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`
|
|
30
35
|
|
|
31
36
|
const execDb = <T>(cb: (db: CfTypes.D1Database) => Promise<CfTypes.D1Result<T>>) =>
|
|
32
37
|
Effect.tryPromise({
|
|
33
|
-
try: () => cb(
|
|
34
|
-
catch: (error) => new
|
|
38
|
+
try: () => cb(engine._tag === 'd1' ? engine.db : (undefined as never)),
|
|
39
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName } }),
|
|
35
40
|
}).pipe(
|
|
36
41
|
Effect.map((_) => _.results),
|
|
37
42
|
Effect.withSpan('@livestore/sync-cf:durable-object:execDb'),
|
|
@@ -47,6 +52,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
47
52
|
const D1_MIN_PAGE_SIZE = 1
|
|
48
53
|
|
|
49
54
|
const decodeEventlogRows = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))
|
|
55
|
+
const jsonStringify = Schema.encodeSync(Schema.parseJson())
|
|
50
56
|
const textEncoder = new TextEncoder()
|
|
51
57
|
|
|
52
58
|
const decreaseLimit = (limit: number) => Math.max(D1_MIN_PAGE_SIZE, Math.floor(limit / 2))
|
|
@@ -66,17 +72,17 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
66
72
|
return limit
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
const
|
|
75
|
+
const getEventsD1 = (
|
|
70
76
|
cursor: number | undefined,
|
|
71
77
|
): Effect.Effect<
|
|
72
78
|
{
|
|
73
79
|
total: number
|
|
74
80
|
stream: Stream.Stream<
|
|
75
|
-
{ eventEncoded: LiveStoreEvent.
|
|
76
|
-
|
|
81
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
82
|
+
UnknownError
|
|
77
83
|
>
|
|
78
84
|
},
|
|
79
|
-
|
|
85
|
+
UnknownError
|
|
80
86
|
> =>
|
|
81
87
|
Effect.gen(function* () {
|
|
82
88
|
const countStatement =
|
|
@@ -92,13 +98,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
92
98
|
const total = Number(countRows[0]?.total ?? 0)
|
|
93
99
|
|
|
94
100
|
type State = { cursor: number | undefined; limit: number }
|
|
95
|
-
type EmittedEvent = { eventEncoded: LiveStoreEvent.
|
|
101
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
96
102
|
|
|
97
103
|
const initialState: State = { cursor, limit: D1_INITIAL_PAGE_SIZE }
|
|
98
104
|
|
|
99
105
|
const fetchPage = (
|
|
100
106
|
state: State,
|
|
101
|
-
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>,
|
|
107
|
+
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
|
|
102
108
|
Effect.gen(function* () {
|
|
103
109
|
const statement =
|
|
104
110
|
state.cursor === undefined
|
|
@@ -116,7 +122,7 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
116
122
|
return Option.none()
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
const encodedSize = textEncoder.encode(
|
|
125
|
+
const encodedSize = textEncoder.encode(jsonStringify(rawEvents)).byteLength
|
|
120
126
|
|
|
121
127
|
if (encodedSize > D1_TARGET_RESPONSE_BYTES && state.limit > D1_MIN_PAGE_SIZE) {
|
|
122
128
|
const nextLimit = decreaseLimit(state.limit)
|
|
@@ -143,11 +149,13 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
143
149
|
|
|
144
150
|
return { total, stream }
|
|
145
151
|
}).pipe(
|
|
146
|
-
|
|
147
|
-
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
152
|
+
UnknownError.mapToUnknownError,
|
|
153
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
154
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
155
|
+
}),
|
|
148
156
|
)
|
|
149
157
|
|
|
150
|
-
const
|
|
158
|
+
const appendEventsD1: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
151
159
|
Effect.gen(function* () {
|
|
152
160
|
// If there are no events, do nothing.
|
|
153
161
|
if (batch.length === 0) return
|
|
@@ -182,24 +190,136 @@ export const makeStorage = (ctx: CfTypes.DurableObjectState, env: Env, storeId:
|
|
|
182
190
|
)
|
|
183
191
|
}
|
|
184
192
|
}).pipe(
|
|
185
|
-
|
|
193
|
+
UnknownError.mapToUnknownError,
|
|
186
194
|
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
187
|
-
attributes: { dbName, batchLength: batch.length },
|
|
195
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
188
196
|
}),
|
|
189
197
|
)
|
|
190
198
|
|
|
191
199
|
const resetStore = Effect.promise(() => ctx.storage.deleteAll()).pipe(
|
|
192
|
-
|
|
200
|
+
UnknownError.mapToUnknownError,
|
|
193
201
|
Effect.withSpan('@livestore/sync-cf:durable-object:resetStore'),
|
|
194
202
|
)
|
|
195
203
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
// DO SQLite engine implementation
|
|
205
|
+
const getEventsDoSqlite = (
|
|
206
|
+
cursor: number | undefined,
|
|
207
|
+
): Effect.Effect<
|
|
208
|
+
{
|
|
209
|
+
total: number
|
|
210
|
+
stream: Stream.Stream<
|
|
211
|
+
{ eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> },
|
|
212
|
+
UnknownError
|
|
213
|
+
>
|
|
214
|
+
},
|
|
215
|
+
UnknownError
|
|
216
|
+
> =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const selectCountSql =
|
|
219
|
+
cursor === undefined
|
|
220
|
+
? `SELECT COUNT(*) as total FROM "${dbName}"`
|
|
221
|
+
: `SELECT COUNT(*) as total FROM "${dbName}" WHERE seqNum > ?`
|
|
222
|
+
|
|
223
|
+
const total = yield* Effect.try({
|
|
224
|
+
try: () => {
|
|
225
|
+
const cursorIter =
|
|
226
|
+
cursor === undefined ? ctx.storage.sql.exec(selectCountSql) : ctx.storage.sql.exec(selectCountSql, cursor)
|
|
227
|
+
let computed = 0
|
|
228
|
+
for (const row of cursorIter) {
|
|
229
|
+
computed = Number((row as any).total ?? 0)
|
|
230
|
+
}
|
|
231
|
+
return computed
|
|
232
|
+
},
|
|
233
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'count' } }),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
type State = { cursor: number | undefined }
|
|
237
|
+
type EmittedEvent = { eventEncoded: LiveStoreEvent.Global.Encoded; metadata: Option.Option<SyncMetadata> }
|
|
238
|
+
|
|
239
|
+
const DO_PAGE_SIZE = 256
|
|
240
|
+
const initialState: State = { cursor }
|
|
241
|
+
|
|
242
|
+
const fetchPage = (
|
|
243
|
+
state: State,
|
|
244
|
+
): Effect.Effect<Option.Option<readonly [Chunk.Chunk<EmittedEvent>, State]>, UnknownError> =>
|
|
245
|
+
Effect.try({
|
|
246
|
+
try: () => {
|
|
247
|
+
const sql =
|
|
248
|
+
state.cursor === undefined
|
|
249
|
+
? `SELECT * FROM "${dbName}" ORDER BY seqNum ASC LIMIT ?`
|
|
250
|
+
: `SELECT * FROM "${dbName}" WHERE seqNum > ? ORDER BY seqNum ASC LIMIT ?`
|
|
251
|
+
|
|
252
|
+
const iter =
|
|
253
|
+
state.cursor === undefined
|
|
254
|
+
? ctx.storage.sql.exec(sql, DO_PAGE_SIZE)
|
|
255
|
+
: ctx.storage.sql.exec(sql, state.cursor, DO_PAGE_SIZE)
|
|
256
|
+
|
|
257
|
+
const rows: any[] = []
|
|
258
|
+
for (const row of iter) rows.push(row)
|
|
259
|
+
|
|
260
|
+
if (rows.length === 0) {
|
|
261
|
+
return Option.none()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const decodedRows = Chunk.fromIterable(decodeEventlogRows(rows))
|
|
265
|
+
const eventsChunk = Chunk.map(decodedRows, ({ createdAt, ...eventEncoded }) => ({
|
|
266
|
+
eventEncoded,
|
|
267
|
+
metadata: Option.some(SyncMetadata.make({ createdAt })),
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const lastSeqNum = Chunk.unsafeLast(decodedRows).seqNum
|
|
271
|
+
const nextState: State = { cursor: lastSeqNum }
|
|
272
|
+
|
|
273
|
+
return Option.some([eventsChunk, nextState] as const)
|
|
274
|
+
},
|
|
275
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'select' } }),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const stream = Stream.unfoldChunkEffect(initialState, fetchPage)
|
|
279
|
+
|
|
280
|
+
return { total, stream }
|
|
281
|
+
}).pipe(
|
|
282
|
+
UnknownError.mapToUnknownError,
|
|
283
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:getEvents', {
|
|
284
|
+
attributes: { dbName, cursor, engine: engine._tag },
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const appendEventsDoSqlite: SyncStorage['appendEvents'] = (batch, createdAt) =>
|
|
289
|
+
Effect.try({
|
|
290
|
+
try: () => {
|
|
291
|
+
if (batch.length === 0) return
|
|
292
|
+
// Keep params per statement within conservative limits (align with D1 bound params ~100)
|
|
293
|
+
const CHUNK_SIZE = 14
|
|
294
|
+
for (let i = 0; i < batch.length; i += CHUNK_SIZE) {
|
|
295
|
+
const chunk = batch.slice(i, i + CHUNK_SIZE)
|
|
296
|
+
const placeholders = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
|
|
297
|
+
const sql = `INSERT INTO "${dbName}" (seqNum, parentSeqNum, args, name, createdAt, clientId, sessionId) VALUES ${placeholders}`
|
|
298
|
+
const params = chunk.flatMap((event) => [
|
|
299
|
+
event.seqNum,
|
|
300
|
+
event.parentSeqNum,
|
|
301
|
+
event.args === undefined ? null : JSON.stringify(event.args),
|
|
302
|
+
event.name,
|
|
303
|
+
createdAt,
|
|
304
|
+
event.clientId,
|
|
305
|
+
event.sessionId,
|
|
306
|
+
])
|
|
307
|
+
ctx.storage.sql.exec(sql, ...params)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
catch: (error) => new UnknownError({ cause: error, payload: { dbName, stage: 'insert' } }),
|
|
311
|
+
}).pipe(
|
|
312
|
+
Effect.withSpan('@livestore/sync-cf:durable-object:appendEvents', {
|
|
313
|
+
attributes: { dbName, batchLength: batch.length, engine: engine._tag },
|
|
314
|
+
}),
|
|
315
|
+
UnknownError.mapToUnknownError,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if (engine._tag === 'd1') {
|
|
319
|
+
return { dbName, getEvents: getEventsD1, appendEvents: appendEventsD1, resetStore }
|
|
202
320
|
}
|
|
321
|
+
|
|
322
|
+
return { dbName, getEvents: getEventsDoSqlite, appendEvents: appendEventsDoSqlite, resetStore }
|
|
203
323
|
}
|
|
204
324
|
|
|
205
325
|
const toValidTableName = (str: string) => str.replaceAll(/[^a-zA-Z0-9]/g, '_')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UnknownError } from '@livestore/common'
|
|
2
2
|
import { type CfTypes, toDurableObjectHandler } from '@livestore/common-cf'
|
|
3
3
|
import {
|
|
4
4
|
Effect,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
RpcSerialization,
|
|
12
12
|
Stream,
|
|
13
13
|
} from '@livestore/utils/effect'
|
|
14
|
+
|
|
14
15
|
import { SyncDoRpc } from '../../../common/do-rpc-schema.ts'
|
|
15
16
|
import { SyncMessage } from '../../../common/mod.ts'
|
|
16
17
|
import { DoCtx, type DoCtxInput } from '../layer.ts'
|
|
@@ -39,17 +40,18 @@ export const createDoRpcHandler = (
|
|
|
39
40
|
const { rpcSubscriptions } = yield* DoCtx
|
|
40
41
|
|
|
41
42
|
// TODO rename `req.rpcContext` to something more appropriate
|
|
42
|
-
if (req.rpcContext) {
|
|
43
|
+
if (req.rpcContext !== undefined) {
|
|
43
44
|
rpcSubscriptions.set(req.storeId, {
|
|
44
45
|
storeId: req.storeId,
|
|
45
|
-
payload: req.payload,
|
|
46
46
|
subscribedAt: Date.now(),
|
|
47
47
|
requestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
48
48
|
callerContext: req.rpcContext.callerContext,
|
|
49
|
+
...(req.payload !== undefined ? { payload: req.payload } : {}),
|
|
49
50
|
})
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
54
|
+
return makeEndingPullStream({ req, payload: req.payload, headers: undefined })
|
|
53
55
|
}).pipe(
|
|
54
56
|
Stream.unwrap,
|
|
55
57
|
Stream.map((res) => ({
|
|
@@ -57,18 +59,27 @@ export const createDoRpcHandler = (
|
|
|
57
59
|
rpcRequestId: Headers.get(headers, 'x-rpc-request-id').pipe(Option.getOrThrow),
|
|
58
60
|
})),
|
|
59
61
|
Stream.provideLayer(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
|
|
60
|
-
Stream.mapError((cause) =>
|
|
62
|
+
Stream.mapError((cause) =>
|
|
63
|
+
cause._tag === 'UnknownError' || cause._tag === 'BackendIdMismatchError'
|
|
64
|
+
? cause
|
|
65
|
+
: new UnknownError({ cause }),
|
|
66
|
+
),
|
|
61
67
|
Stream.tapErrorCause(Effect.log),
|
|
62
68
|
),
|
|
63
69
|
'SyncDoRpc.Push': (req) =>
|
|
64
70
|
Effect.gen(this, function* () {
|
|
65
71
|
const { doOptions, ctx, env, storeId } = yield* DoCtx
|
|
66
|
-
|
|
72
|
+
// DO-RPC doesn't have HTTP headers context - headers are undefined
|
|
73
|
+
const push = makePush({ storeId, payload: req.payload, headers: undefined, options: doOptions, ctx, env })
|
|
67
74
|
|
|
68
75
|
return yield* push(req)
|
|
69
76
|
}).pipe(
|
|
70
77
|
Effect.provide(DoCtx.Default({ ...input, from: { storeId: req.storeId } })),
|
|
71
|
-
Effect.mapError((cause) =>
|
|
78
|
+
Effect.mapError((cause) =>
|
|
79
|
+
cause._tag === 'UnknownError' || cause._tag === 'ServerAheadError' || cause._tag === 'BackendIdMismatchError'
|
|
80
|
+
? cause
|
|
81
|
+
: new UnknownError({ cause }),
|
|
82
|
+
),
|
|
72
83
|
Effect.tapCauseLogPretty,
|
|
73
84
|
),
|
|
74
85
|
})
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
import type { CfTypes } from '@livestore/common-cf'
|
|
2
2
|
import { Effect, HttpApp, Layer, RpcSerialization, RpcServer } from '@livestore/utils/effect'
|
|
3
|
+
|
|
3
4
|
import { SyncHttpRpc } from '../../../common/http-rpc-schema.ts'
|
|
4
5
|
import * as SyncMessage from '../../../common/sync-message-types.ts'
|
|
6
|
+
import { headersRecordToMap } from '../../shared.ts'
|
|
5
7
|
import { DoCtx } from '../layer.ts'
|
|
6
8
|
import { makeEndingPullStream } from '../pull.ts'
|
|
7
9
|
import { makePush } from '../push.ts'
|
|
8
10
|
|
|
9
|
-
export const createHttpRpcHandler = (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export const createHttpRpcHandler = Effect.fn('createHttpRpcHandler')(function* ({
|
|
12
|
+
request,
|
|
13
|
+
responseHeaders,
|
|
14
|
+
forwardedHeaders,
|
|
15
|
+
}: {
|
|
16
|
+
request: CfTypes.Request
|
|
17
|
+
responseHeaders?: Record<string, string>
|
|
18
|
+
forwardedHeaders?: Record<string, string>
|
|
19
|
+
}) {
|
|
20
|
+
const handlerLayer = createHttpRpcLayer(forwardedHeaders)
|
|
21
|
+
const httpApp = RpcServer.toHttpApp(SyncHttpRpc).pipe(Effect.provide(handlerLayer))
|
|
22
|
+
const webHandler = yield* httpApp.pipe(Effect.map(HttpApp.toWebHandler))
|
|
23
|
+
|
|
24
|
+
const response = yield* Effect.promise(
|
|
25
|
+
() => webHandler(request as TODO as Request) as TODO as Promise<CfTypes.Response>,
|
|
26
|
+
).pipe(Effect.timeout(10000))
|
|
27
|
+
|
|
28
|
+
if (responseHeaders !== undefined) {
|
|
29
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
30
|
+
response.headers.set(key, value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response
|
|
35
|
+
})
|
|
14
36
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
).pipe(Effect.timeout(10000))
|
|
18
|
-
}).pipe(Effect.withSpan('createHttpRpcHandler'))
|
|
37
|
+
const createHttpRpcLayer = (forwardedHeaders: Record<string, string> | undefined) => {
|
|
38
|
+
const headers = headersRecordToMap(forwardedHeaders)
|
|
19
39
|
|
|
20
|
-
const createHttpRpcLayer =
|
|
21
40
|
// TODO implement admin requests
|
|
22
|
-
SyncHttpRpc.toLayer({
|
|
23
|
-
'SyncHttpRpc.Pull': (req) => makeEndingPullStream(req, req.payload),
|
|
41
|
+
return SyncHttpRpc.toLayer({
|
|
42
|
+
'SyncHttpRpc.Pull': (req) => makeEndingPullStream({ req, payload: req.payload, headers }),
|
|
24
43
|
|
|
25
44
|
'SyncHttpRpc.Push': (req) =>
|
|
26
45
|
Effect.gen(function* () {
|
|
27
46
|
const { ctx, env, doOptions, storeId } = yield* DoCtx
|
|
28
|
-
const push = makePush({ payload: undefined, options: doOptions, storeId, ctx, env })
|
|
47
|
+
const push = makePush({ payload: undefined, headers, options: doOptions, storeId, ctx, env })
|
|
29
48
|
|
|
30
49
|
return yield* push(req)
|
|
31
50
|
}),
|
|
@@ -35,3 +54,4 @@ const createHttpRpcLayer =
|
|
|
35
54
|
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: '/http-rpc' })),
|
|
36
55
|
Layer.provideMerge(RpcSerialization.layerJson),
|
|
37
56
|
)
|
|
57
|
+
}
|