@livestore/sync-s2 0.4.0-dev.9 → 0.5.0-dev.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/dist/.tsbuildinfo +1 -1
- package/dist/api-schema.d.ts +19 -19
- package/dist/api-schema.d.ts.map +1 -1
- package/dist/api-schema.js +4 -4
- package/dist/api-schema.js.map +1 -1
- package/dist/api-schema.test.js +15 -10
- package/dist/api-schema.test.js.map +1 -1
- package/dist/decode.d.ts +1 -1
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +3 -3
- package/dist/decode.js.map +1 -1
- package/dist/http-client-generated.d.ts +337 -1051
- package/dist/http-client-generated.d.ts.map +1 -1
- package/dist/http-client-generated.js +276 -231
- package/dist/http-client-generated.js.map +1 -1
- package/dist/limits.d.ts +39 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +89 -0
- package/dist/limits.js.map +1 -0
- package/dist/limits.test.d.ts +2 -0
- package/dist/limits.test.d.ts.map +1 -0
- package/dist/limits.test.js +30 -0
- package/dist/limits.test.js.map +1 -0
- package/dist/make-s2-url.d.ts.map +1 -1
- package/dist/make-s2-url.js +4 -4
- package/dist/make-s2-url.js.map +1 -1
- package/dist/mod.d.ts +1 -0
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +1 -0
- package/dist/mod.js.map +1 -1
- package/dist/s2-proxy-helpers.d.ts +26 -22
- package/dist/s2-proxy-helpers.d.ts.map +1 -1
- package/dist/s2-proxy-helpers.js +35 -33
- package/dist/s2-proxy-helpers.js.map +1 -1
- package/dist/sync-provider.d.ts +7 -7
- package/dist/sync-provider.d.ts.map +1 -1
- package/dist/sync-provider.js +55 -22
- package/dist/sync-provider.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +57 -13
- package/src/api-schema.test.ts +16 -10
- package/src/api-schema.ts +5 -4
- package/src/decode.ts +5 -4
- package/src/http-client-generated.ts +187 -258
- package/src/limits.test.ts +43 -0
- package/src/limits.ts +133 -0
- package/src/make-s2-url.ts +5 -4
- package/src/mod.ts +1 -0
- package/src/s2-proxy-helpers.ts +57 -47
- package/src/sync-provider.ts +88 -40
- package/src/types.ts +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { EventSequenceNumber, LiveStoreEvent } from '@livestore/common/schema'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
chunkEventsForS2,
|
|
6
|
+
computeRecordMeteredBytes,
|
|
7
|
+
MAX_BATCH_METERED_BYTES,
|
|
8
|
+
MAX_RECORD_METERED_BYTES,
|
|
9
|
+
S2LimitExceededError,
|
|
10
|
+
} from './limits.ts'
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder()
|
|
13
|
+
|
|
14
|
+
const makeEvent = (payloadLength: number, index = 0): LiveStoreEvent.Global.Encoded => ({
|
|
15
|
+
name: `event-${index}`,
|
|
16
|
+
args: { payload: 'x'.repeat(payloadLength) },
|
|
17
|
+
seqNum: index as EventSequenceNumber.Global.Type,
|
|
18
|
+
parentSeqNum: index as EventSequenceNumber.Global.Type,
|
|
19
|
+
clientId: 'client',
|
|
20
|
+
sessionId: 'session',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('S2 limits helpers', () => {
|
|
24
|
+
it('computes metered bytes for record bodies', () => {
|
|
25
|
+
const record = { body: JSON.stringify({ hello: 'world' }) }
|
|
26
|
+
const expected = 8 + encoder.encode(record.body ?? '').byteLength
|
|
27
|
+
expect(computeRecordMeteredBytes(record)).toBe(expected)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('splits large batches while respecting metered byte limits', () => {
|
|
31
|
+
const events = [makeEvent(400_000, 1), makeEvent(400_000, 2), makeEvent(400_000, 3)]
|
|
32
|
+
const chunks = chunkEventsForS2(events)
|
|
33
|
+
|
|
34
|
+
expect(chunks).toHaveLength(2)
|
|
35
|
+
expect(chunks.map((chunk) => chunk.events.length)).toStrictEqual([2, 1])
|
|
36
|
+
expect(chunks.every((chunk) => chunk.meteredBytes <= MAX_BATCH_METERED_BYTES)).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('throws when a single record exceeds the metered byte cap', () => {
|
|
40
|
+
const oversize = makeEvent(MAX_RECORD_METERED_BYTES, 1)
|
|
41
|
+
expect(() => chunkEventsForS2([oversize])).toThrow(S2LimitExceededError)
|
|
42
|
+
})
|
|
43
|
+
})
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { LiveStoreEvent } from '@livestore/common/schema'
|
|
2
|
+
import { splitArrayBySize } from '@livestore/common/sync'
|
|
3
|
+
import { Effect, ReadonlyArray as EffectArray, Schema } from '@livestore/utils/effect'
|
|
4
|
+
|
|
5
|
+
const textEncoder = new TextEncoder()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maximum metered size of a single record (docs: https://s2.dev/docs/limits#records).
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_RECORD_METERED_BYTES = 1_048_576 // 1 MiB
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maximum combined metered size of a batch append (docs: https://s2.dev/docs/limits#records).
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_BATCH_METERED_BYTES = 1_048_576 // 1 MiB
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of records per append (docs: https://s2.dev/docs/limits#records).
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_RECORDS_PER_BATCH = 1_000
|
|
21
|
+
|
|
22
|
+
const LimitType = Schema.Literals(['record-metered-bytes', 'batch-metered-bytes', 'batch-count'])
|
|
23
|
+
|
|
24
|
+
export class S2LimitExceededError extends Schema.TaggedError<S2LimitExceededError>(
|
|
25
|
+
'~@livestore/sync-s2/S2LimitExceededError',
|
|
26
|
+
)('S2LimitExceededError', {
|
|
27
|
+
limitType: LimitType,
|
|
28
|
+
max: Schema.Number,
|
|
29
|
+
actual: Schema.Number,
|
|
30
|
+
recordIndex: Schema.optional(Schema.Number),
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
export interface AppendRecordBody {
|
|
34
|
+
readonly body?: string
|
|
35
|
+
readonly headers?: ReadonlyArray<{ readonly name: string; readonly value: string }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// S2 measures bodies/headers in UTF‑8 bytes; centralising this helper keeps the
|
|
39
|
+
// formula readable and consistent with the docs.
|
|
40
|
+
const utf8ByteLength = (value: string): number => textEncoder.encode(value).byteLength
|
|
41
|
+
|
|
42
|
+
export const computeRecordMeteredBytes = (record: AppendRecordBody): number => {
|
|
43
|
+
const headers = record.headers ?? []
|
|
44
|
+
const headerCount = headers.length
|
|
45
|
+
const headerBytes = headers.reduce(
|
|
46
|
+
(acc, header) => acc + utf8ByteLength(header.name) + utf8ByteLength(header.value),
|
|
47
|
+
0,
|
|
48
|
+
)
|
|
49
|
+
const bodyBytes = record.body === undefined ? 0 : utf8ByteLength(record.body)
|
|
50
|
+
return 8 + 2 * headerCount + headerBytes + bodyBytes
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const computeBatchMeteredBytes = (records: ReadonlyArray<AppendRecordBody>): number =>
|
|
54
|
+
records.reduce((acc, record) => acc + computeRecordMeteredBytes(record), 0)
|
|
55
|
+
|
|
56
|
+
interface PreparedEvent {
|
|
57
|
+
readonly event: LiveStoreEvent.Global.Encoded
|
|
58
|
+
readonly record: AppendRecordBody
|
|
59
|
+
readonly meteredBytes: number
|
|
60
|
+
readonly index: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface S2Chunk {
|
|
64
|
+
readonly events: ReadonlyArray<LiveStoreEvent.Global.Encoded>
|
|
65
|
+
readonly records: ReadonlyArray<AppendRecordBody>
|
|
66
|
+
readonly meteredBytes: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pre-stringify events and pre-compute per-record metered bytes so we only pay
|
|
70
|
+
// the JSON cost once when chunking large batches.
|
|
71
|
+
const convertEventsToPrepared = (events: ReadonlyArray<LiveStoreEvent.Global.Encoded>): PreparedEvent[] =>
|
|
72
|
+
events.map((event, index) => {
|
|
73
|
+
const body = JSON.stringify(event)
|
|
74
|
+
const record: AppendRecordBody = { body }
|
|
75
|
+
const meteredBytes = computeRecordMeteredBytes(record)
|
|
76
|
+
|
|
77
|
+
if (meteredBytes > MAX_RECORD_METERED_BYTES) {
|
|
78
|
+
throw new S2LimitExceededError({
|
|
79
|
+
limitType: 'record-metered-bytes',
|
|
80
|
+
max: MAX_RECORD_METERED_BYTES,
|
|
81
|
+
actual: meteredBytes,
|
|
82
|
+
recordIndex: index,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { event, record, meteredBytes, index }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Summarises a chunk’s metered bytes. Passed to splitChunkBySize so we enforce
|
|
90
|
+
// S2 limits directly instead of relying on JSON size heuristics.
|
|
91
|
+
const makeChunkMeasure = (items: ReadonlyArray<PreparedEvent>): number =>
|
|
92
|
+
items.reduce((acc, item) => acc + item.meteredBytes, 0)
|
|
93
|
+
|
|
94
|
+
const mapPreparedChunks = (chunks: ReadonlyArray<ReadonlyArray<PreparedEvent>>): ReadonlyArray<S2Chunk> =>
|
|
95
|
+
chunks.map((chunkItems) => {
|
|
96
|
+
const events = chunkItems.map((item) => item.event)
|
|
97
|
+
const records = chunkItems.map((item) => item.record)
|
|
98
|
+
return {
|
|
99
|
+
events,
|
|
100
|
+
records,
|
|
101
|
+
meteredBytes: makeChunkMeasure(chunkItems),
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export const chunkEventsForS2 = (events: ReadonlyArray<LiveStoreEvent.Global.Encoded>): ReadonlyArray<S2Chunk> => {
|
|
106
|
+
const prepared = convertEventsToPrepared(events)
|
|
107
|
+
|
|
108
|
+
if (EffectArray.isReadonlyArrayNonEmpty(prepared) === false) {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const chunks = splitArrayBySize<PreparedEvent>({
|
|
114
|
+
maxItems: MAX_RECORDS_PER_BATCH,
|
|
115
|
+
maxBytes: MAX_BATCH_METERED_BYTES,
|
|
116
|
+
encode: (items) => ({ records: items.map((item) => item.record) }),
|
|
117
|
+
measure: makeChunkMeasure,
|
|
118
|
+
})(prepared).pipe(Effect.runSync)
|
|
119
|
+
|
|
120
|
+
return mapPreparedChunks(chunks)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error !== undefined && typeof error === 'object' && (error as any)._tag === 'OversizeChunkItemError') {
|
|
123
|
+
const oversize = error as { size: number; maxBytes: number; _tag: string }
|
|
124
|
+
throw new S2LimitExceededError({
|
|
125
|
+
limitType: 'record-metered-bytes',
|
|
126
|
+
max: oversize.maxBytes,
|
|
127
|
+
actual: oversize.size,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/make-s2-url.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
2
|
-
import { Schema } from '@livestore/utils/effect'
|
|
2
|
+
import { Result, Schema } from '@livestore/utils/effect'
|
|
3
|
+
|
|
3
4
|
import * as ApiSchema from './api-schema.ts'
|
|
4
5
|
|
|
5
6
|
export const makeS2StreamName = (storeId: string) => storeId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 100)
|
|
@@ -9,9 +10,9 @@ export const makeS2StreamName = (storeId: string) => storeId.replace(/[^a-zA-Z0-
|
|
|
9
10
|
*/
|
|
10
11
|
export const decodePullArgsFromSearchParams = (searchParams: URLSearchParams): typeof ApiSchema.PullArgs.Type => {
|
|
11
12
|
const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
|
|
12
|
-
const argsResult = Schema.
|
|
13
|
+
const argsResult = Schema.decodeUnknownResult(UrlParamsSchema)(Object.fromEntries(searchParams.entries()))
|
|
13
14
|
|
|
14
|
-
if (argsResult
|
|
15
|
+
if (Result.isFailure(argsResult) === true) {
|
|
15
16
|
return shouldNeverHappen(
|
|
16
17
|
'Invalid search params provided to decodePullArgsFromSearchParams',
|
|
17
18
|
searchParams,
|
|
@@ -19,5 +20,5 @@ export const decodePullArgsFromSearchParams = (searchParams: URLSearchParams): t
|
|
|
19
20
|
)
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
return argsResult.
|
|
23
|
+
return argsResult.success.args
|
|
23
24
|
}
|
package/src/mod.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * as ApiSchema from './api-schema.ts'
|
|
2
2
|
export * as HttpClientGenerated from './http-client-generated.ts'
|
|
3
|
+
export * from './limits.ts'
|
|
3
4
|
export * from './make-s2-url.ts'
|
|
4
5
|
export * from './s2-proxy-helpers.ts'
|
|
5
6
|
export { makeSyncBackend, type SyncS2Options } from './sync-provider.ts'
|
package/src/s2-proxy-helpers.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { LiveStoreEvent } from '@livestore/livestore'
|
|
7
|
+
|
|
7
8
|
import type { PullArgs } from './api-schema.ts'
|
|
9
|
+
import { chunkEventsForS2 } from './limits.ts'
|
|
8
10
|
import { makeS2StreamName } from './make-s2-url.ts'
|
|
9
11
|
|
|
10
12
|
/** Configuration for S2 connections */
|
|
@@ -15,8 +17,20 @@ export interface S2Config {
|
|
|
15
17
|
accountBase?: string
|
|
16
18
|
/** @default 'https://{basin}.b.aws.s2.dev/v1' */
|
|
17
19
|
basinBase?: string
|
|
20
|
+
/**
|
|
21
|
+
* When true, adds `S2-Basin` header to requests. This is required for s2-lite
|
|
22
|
+
* (the open-source self-hosted S2) which uses header-based basin routing instead
|
|
23
|
+
* of subdomain-based routing used by hosted S2.
|
|
24
|
+
* @see https://github.com/s2-streamstore/s2-lite
|
|
25
|
+
*/
|
|
26
|
+
lite?: boolean
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
export const isLiteMode = (config: S2Config): boolean => config.lite === true
|
|
30
|
+
|
|
31
|
+
const getBasinHeader = (config: S2Config): Record<string, string> =>
|
|
32
|
+
isLiteMode(config) === true ? { 's2-basin': config.basin } : {}
|
|
33
|
+
|
|
20
34
|
// URL construction helpers
|
|
21
35
|
export const getBasinUrl = (config: S2Config, path: string): string => {
|
|
22
36
|
const base = config.basinBase ?? `https://${config.basin}.b.aws.s2.dev/v1`
|
|
@@ -31,10 +45,10 @@ export const getAccountUrl = (config: S2Config, path: string): string => {
|
|
|
31
45
|
export const getStreamRecordsUrl = (
|
|
32
46
|
config: S2Config,
|
|
33
47
|
stream: string,
|
|
34
|
-
params?: { seq_num?: number; count?: number; clamp?: boolean },
|
|
48
|
+
params?: { seq_num?: number; count?: number; clamp?: boolean; wait?: number },
|
|
35
49
|
): string => {
|
|
36
50
|
const base = getBasinUrl(config, `/streams/${encodeURIComponent(stream)}/records`)
|
|
37
|
-
if (
|
|
51
|
+
if (params == null) return base
|
|
38
52
|
|
|
39
53
|
const searchParams = new URLSearchParams()
|
|
40
54
|
/** seq_num - The sequence number to start from. See: https://docs.s2.dev/api#seq_num */
|
|
@@ -43,8 +57,11 @@ export const getStreamRecordsUrl = (
|
|
|
43
57
|
if (params.count !== undefined) searchParams.append('count', params.count.toString())
|
|
44
58
|
/** clamp - Whether to clamp the response to the requested count. See: https://docs.s2.dev/api#clamp */
|
|
45
59
|
if (params.clamp !== undefined) searchParams.append('clamp', params.clamp.toString())
|
|
60
|
+
/** wait - How long to wait for new records before returning. See: https://docs.s2.dev/api#wait */
|
|
61
|
+
if (params.wait !== undefined) searchParams.append('wait', params.wait.toString())
|
|
46
62
|
|
|
47
|
-
|
|
63
|
+
const searchParamsString = searchParams.toString()
|
|
64
|
+
return searchParamsString.length > 0 ? `${base}?${searchParams}` : base
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
// Header helpers
|
|
@@ -52,14 +69,16 @@ export const getAuthHeaders = (token: string): Record<string, string> => ({
|
|
|
52
69
|
Authorization: `Bearer ${token}`,
|
|
53
70
|
})
|
|
54
71
|
|
|
55
|
-
export const getSSEHeaders = (
|
|
56
|
-
...getAuthHeaders(token),
|
|
72
|
+
export const getSSEHeaders = (config: S2Config): Record<string, string> => ({
|
|
73
|
+
...getAuthHeaders(config.token),
|
|
74
|
+
...getBasinHeader(config),
|
|
57
75
|
accept: 'text/event-stream',
|
|
58
76
|
's2-format': 'raw',
|
|
59
77
|
})
|
|
60
78
|
|
|
61
|
-
export const getPushHeaders = (
|
|
62
|
-
...getAuthHeaders(token),
|
|
79
|
+
export const getPushHeaders = (config: S2Config): Record<string, string> => ({
|
|
80
|
+
...getAuthHeaders(config.token),
|
|
81
|
+
...getBasinHeader(config),
|
|
63
82
|
'content-type': 'application/json',
|
|
64
83
|
's2-format': 'raw',
|
|
65
84
|
})
|
|
@@ -86,6 +105,7 @@ export const ensureStream = async (config: S2Config, stream: string): Promise<vo
|
|
|
86
105
|
method: 'POST',
|
|
87
106
|
headers: {
|
|
88
107
|
...getAuthHeaders(config.token),
|
|
108
|
+
...getBasinHeader(config),
|
|
89
109
|
'content-type': 'application/json',
|
|
90
110
|
},
|
|
91
111
|
body: JSON.stringify({ stream }),
|
|
@@ -111,42 +131,50 @@ export const buildPullRequest = ({
|
|
|
111
131
|
// cursor points to last processed record, seq_num needs to be the next record
|
|
112
132
|
const seq_num = args.s2SeqNum === 'from-start' ? 0 : args.s2SeqNum + 1
|
|
113
133
|
|
|
114
|
-
if (args.live) {
|
|
134
|
+
if (args.live === true) {
|
|
115
135
|
const url = getStreamRecordsUrl(config, streamName, { seq_num, clamp: true })
|
|
116
|
-
return { url, headers: getSSEHeaders(config
|
|
136
|
+
return { url, headers: getSSEHeaders(config) }
|
|
117
137
|
} else {
|
|
118
|
-
// Non-live pulls also stream over SSE
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return { url, headers: getSSEHeaders(config.token) }
|
|
138
|
+
// Non-live pulls also stream over SSE. We ask S2 to return immediately when
|
|
139
|
+
// the tail is reached by setting wait=0 which gives us an explicit
|
|
140
|
+
// end-of-stream without requesting an arbitrarily large page size.
|
|
141
|
+
const url = getStreamRecordsUrl(config, streamName, { seq_num, wait: 0, clamp: true })
|
|
142
|
+
return { url, headers: getSSEHeaders(config) }
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
export
|
|
146
|
+
export interface S2PushRequest {
|
|
147
|
+
readonly url: string
|
|
148
|
+
readonly method: 'POST'
|
|
149
|
+
readonly headers: Record<string, string>
|
|
150
|
+
readonly body: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Builds one or more append requests against S2. The helper applies the
|
|
155
|
+
* documented 1 MiB / 1000-record limits via `chunkEventsForS2`, so callers
|
|
156
|
+
* receive a request per compliant chunk instead of hitting 413 responses at
|
|
157
|
+
* runtime.
|
|
158
|
+
*/
|
|
159
|
+
export const buildPushRequests = ({
|
|
128
160
|
config,
|
|
129
161
|
storeId,
|
|
130
162
|
batch,
|
|
131
163
|
}: {
|
|
132
164
|
config: S2Config
|
|
133
165
|
storeId: string
|
|
134
|
-
batch: readonly LiveStoreEvent.
|
|
135
|
-
}): {
|
|
136
|
-
url: string
|
|
137
|
-
method: 'POST'
|
|
138
|
-
headers: Record<string, string>
|
|
139
|
-
/** JSON-encoded batch */
|
|
140
|
-
body: string
|
|
141
|
-
} => {
|
|
166
|
+
batch: readonly LiveStoreEvent.Global.Encoded[]
|
|
167
|
+
}): ReadonlyArray<S2PushRequest> => {
|
|
142
168
|
const streamName = makeS2StreamName(storeId)
|
|
143
169
|
const url = getBasinUrl(config, `/streams/${encodeURIComponent(streamName)}/records`)
|
|
144
|
-
|
|
170
|
+
const chunks = chunkEventsForS2(batch)
|
|
171
|
+
|
|
172
|
+
return chunks.map((chunk) => ({
|
|
145
173
|
url,
|
|
146
|
-
method: 'POST',
|
|
147
|
-
headers: getPushHeaders(config
|
|
148
|
-
body: JSON.stringify(
|
|
149
|
-
}
|
|
174
|
+
method: 'POST' as const,
|
|
175
|
+
headers: getPushHeaders(config),
|
|
176
|
+
body: JSON.stringify({ records: chunk.records }),
|
|
177
|
+
}))
|
|
150
178
|
}
|
|
151
179
|
|
|
152
180
|
// Response helpers
|
|
@@ -176,21 +204,3 @@ export const errorResponse = (message: string, status = 500): Response => {
|
|
|
176
204
|
headers: { 'content-type': 'application/json' },
|
|
177
205
|
})
|
|
178
206
|
}
|
|
179
|
-
|
|
180
|
-
// Batch formatting helper
|
|
181
|
-
export const formatBatchForS2 = (
|
|
182
|
-
batch: readonly LiveStoreEvent.AnyEncodedGlobal[],
|
|
183
|
-
): { records: { body: string }[] } => {
|
|
184
|
-
return {
|
|
185
|
-
records: batch.map((ev) => ({ body: JSON.stringify(ev) })),
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export const asCurl = (request: { url: string; method: string; headers: Record<string, string>; body?: string }) => {
|
|
190
|
-
const url = request.url
|
|
191
|
-
const method = request.method
|
|
192
|
-
const headers = Object.entries(request.headers).map(([key, value]) => `-H "${key}: ${value}"`)
|
|
193
|
-
const body = request.body
|
|
194
|
-
const headersStr = headers.join(' ')
|
|
195
|
-
return `curl -X ${method} ${url} ${headersStr} ${body ? `-d '${body}'` : ''}`
|
|
196
|
-
}
|
package/src/sync-provider.ts
CHANGED
|
@@ -28,28 +28,31 @@
|
|
|
28
28
|
* DO NOT couple these systems together or assume 1:1 correspondence.
|
|
29
29
|
*
|
|
30
30
|
* Errors
|
|
31
|
-
* - push →
|
|
31
|
+
* - push → UnknownError on non‑2xx; pull → UnknownError on non‑2xx; ping/connect map timeouts to offline.
|
|
32
32
|
* - The proxy should surface helpful status codes and error bodies.
|
|
33
33
|
*/
|
|
34
|
-
import {
|
|
34
|
+
import { SyncBackend, UnknownError } from '@livestore/common'
|
|
35
35
|
import type { EventSequenceNumber } from '@livestore/common/schema'
|
|
36
36
|
import { shouldNeverHappen } from '@livestore/utils'
|
|
37
37
|
import {
|
|
38
38
|
type Duration,
|
|
39
39
|
Effect,
|
|
40
|
+
Filter,
|
|
40
41
|
HttpClient,
|
|
41
42
|
HttpClientRequest,
|
|
42
43
|
HttpClientResponse,
|
|
43
44
|
Option,
|
|
44
45
|
Schedule,
|
|
45
46
|
Schema,
|
|
46
|
-
Sse,
|
|
47
47
|
Stream,
|
|
48
48
|
SubscriptionRef,
|
|
49
49
|
} from '@livestore/utils/effect'
|
|
50
|
+
import { Sse } from 'effect/unstable/encoding'
|
|
51
|
+
|
|
50
52
|
import * as ApiSchema from './api-schema.ts'
|
|
51
53
|
import { decodeReadBatch } from './decode.ts'
|
|
52
54
|
import * as HttpClientGenerated from './http-client-generated.ts'
|
|
55
|
+
import { chunkEventsForS2, S2LimitExceededError } from './limits.ts'
|
|
53
56
|
import type { SyncMetadata } from './types.ts'
|
|
54
57
|
|
|
55
58
|
export interface SyncS2Options {
|
|
@@ -64,19 +67,31 @@ export interface SyncS2Options {
|
|
|
64
67
|
/** Enable periodic ping; default true */
|
|
65
68
|
enabled?: boolean
|
|
66
69
|
/** Timeout for individual ping request; default 10s */
|
|
67
|
-
requestTimeout?: Duration.
|
|
70
|
+
requestTimeout?: Duration.Input
|
|
68
71
|
/** Interval between ping requests; default 10s */
|
|
69
|
-
requestInterval?: Duration.
|
|
72
|
+
requestInterval?: Duration.Input
|
|
70
73
|
}
|
|
71
74
|
retry?: {
|
|
72
75
|
/** Custom retry schedule for non-live pulls (default: 2 recurs, 100ms spaced) */
|
|
73
|
-
pull?: Schedule.Schedule<number,
|
|
76
|
+
pull?: Schedule.Schedule<number, UnknownError>
|
|
74
77
|
/** Custom retry schedule for pushes (default: 2 recurs, 100ms spaced) */
|
|
75
|
-
push?: Schedule.Schedule<number,
|
|
78
|
+
push?: Schedule.Schedule<number, UnknownError>
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
export const defaultRetry = Schedule.
|
|
82
|
+
export const defaultRetry = Schedule.recurs(2).pipe(Schedule.addDelay(() => Effect.succeed(100)))
|
|
83
|
+
|
|
84
|
+
const getBrowserOrigin = () => {
|
|
85
|
+
if (typeof globalThis !== 'object' || globalThis === null || !('location' in globalThis)) {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { location } = globalThis as typeof globalThis & {
|
|
90
|
+
location?: { origin?: unknown } | undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return typeof location?.origin === 'string' ? location.origin : undefined
|
|
94
|
+
}
|
|
80
95
|
|
|
81
96
|
export const makeSyncBackend =
|
|
82
97
|
({ endpoint, ping: pingOptions, retry }: SyncS2Options): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
|
|
@@ -89,9 +104,9 @@ export const makeSyncBackend =
|
|
|
89
104
|
|
|
90
105
|
const httpClient = yield* HttpClient.HttpClient
|
|
91
106
|
|
|
107
|
+
const browserOrigin = getBrowserOrigin()
|
|
92
108
|
const pullEndpointHasSameOrigin =
|
|
93
|
-
pullEndpoint.startsWith('/') ||
|
|
94
|
-
(globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
|
|
109
|
+
pullEndpoint.startsWith('/') || (browserOrigin !== undefined && browserOrigin === new URL(pullEndpoint).origin)
|
|
95
110
|
|
|
96
111
|
const pingTimeout = pingOptions?.requestTimeout ?? 10_000
|
|
97
112
|
|
|
@@ -99,9 +114,9 @@ export const makeSyncBackend =
|
|
|
99
114
|
yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
|
|
100
115
|
yield* SubscriptionRef.set(isConnected, true)
|
|
101
116
|
}).pipe(
|
|
102
|
-
|
|
117
|
+
UnknownError.mapToUnknownError,
|
|
103
118
|
Effect.timeout(pingTimeout),
|
|
104
|
-
Effect.catchTag('
|
|
119
|
+
Effect.catchTag('TimeoutError', () => SubscriptionRef.set(isConnected, false)),
|
|
105
120
|
)
|
|
106
121
|
|
|
107
122
|
const pingInterval = pingOptions?.requestInterval ?? 10_000
|
|
@@ -110,17 +125,16 @@ export const makeSyncBackend =
|
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
// No need to connect if the pull endpoint has the same origin as the current page
|
|
113
|
-
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
|
|
114
|
-
? Effect.void
|
|
115
|
-
: ping.pipe(UnexpectedError.mapToUnexpectedError)
|
|
128
|
+
const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] =
|
|
129
|
+
pullEndpointHasSameOrigin === true ? Effect.void : ping.pipe(UnknownError.mapToUnknownError)
|
|
116
130
|
|
|
117
131
|
const runPullSse = (
|
|
118
132
|
cursor: Option.Option<{
|
|
119
|
-
eventSequenceNumber: EventSequenceNumber.
|
|
133
|
+
eventSequenceNumber: EventSequenceNumber.Global.Type
|
|
120
134
|
metadata: Option.Option<SyncMetadata>
|
|
121
135
|
}>,
|
|
122
136
|
live: boolean,
|
|
123
|
-
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>,
|
|
137
|
+
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
|
|
124
138
|
// Extract S2 seqNum from metadata for SSE cursor
|
|
125
139
|
const s2SeqNum = cursor.pipe(
|
|
126
140
|
Option.flatMap((_) => _.metadata),
|
|
@@ -136,18 +150,20 @@ export const makeSyncBackend =
|
|
|
136
150
|
.pipe(
|
|
137
151
|
HttpClientResponse.stream,
|
|
138
152
|
// decode text and split into lines
|
|
139
|
-
Stream.decodeText('utf8'),
|
|
140
|
-
Stream.pipeThroughChannel(Sse.
|
|
153
|
+
Stream.decodeText({ encoding: 'utf8' }),
|
|
154
|
+
Stream.pipeThroughChannel(Sse.decode()),
|
|
141
155
|
// Filter out pings, map errors to stream failures
|
|
142
156
|
Stream.mapEffect(
|
|
143
157
|
Effect.fnUntraced(function* (msg) {
|
|
144
158
|
const evt = msg.event.toLowerCase()
|
|
145
159
|
if (evt === 'ping') return Option.none()
|
|
146
160
|
if (evt === 'error') {
|
|
147
|
-
return yield* new
|
|
161
|
+
return yield* new UnknownError({ cause: new Error(`SSE error: ${msg.data}`) })
|
|
148
162
|
}
|
|
149
163
|
if (evt === 'batch') {
|
|
150
|
-
const readBatch = yield* Schema.
|
|
164
|
+
const readBatch = yield* Schema.decodeEffect(Schema.fromJsonString(HttpClientGenerated.ReadBatch))(
|
|
165
|
+
msg.data,
|
|
166
|
+
)
|
|
151
167
|
const batch = decodeReadBatch(readBatch)
|
|
152
168
|
|
|
153
169
|
const lastS2SeqNum = batch.at(-1)?.metadata.pipe(
|
|
@@ -175,29 +191,29 @@ export const makeSyncBackend =
|
|
|
175
191
|
return shouldNeverHappen(`Unexpected SSE event: ${evt}`, msg)
|
|
176
192
|
}),
|
|
177
193
|
),
|
|
178
|
-
Stream.filterMap((_) => _), // filter out Option.none()
|
|
179
|
-
Stream.mapError((cause) => (cause._tag === '
|
|
194
|
+
Stream.filterMap(Filter.fromPredicateOption((_) => _)), // filter out Option.none()
|
|
195
|
+
Stream.mapError((cause) => (cause._tag === 'UnknownError' ? cause : new UnknownError({ cause }))),
|
|
180
196
|
Stream.retry(retry?.pull ?? defaultRetry),
|
|
181
197
|
)
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
const ssePull = (
|
|
185
201
|
startCursor: Option.Option<{
|
|
186
|
-
eventSequenceNumber: EventSequenceNumber.
|
|
202
|
+
eventSequenceNumber: EventSequenceNumber.Global.Type
|
|
187
203
|
metadata: Option.Option<SyncMetadata>
|
|
188
204
|
}>,
|
|
189
|
-
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>,
|
|
205
|
+
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
|
|
190
206
|
const computeNextCursor = (
|
|
191
207
|
lastItem: Option.Option<SyncBackend.PullResItem<SyncMetadata>>,
|
|
192
208
|
current: Option.Option<{
|
|
193
|
-
eventSequenceNumber: EventSequenceNumber.
|
|
209
|
+
eventSequenceNumber: EventSequenceNumber.Global.Type
|
|
194
210
|
metadata: Option.Option<SyncMetadata>
|
|
195
211
|
}>,
|
|
196
212
|
) =>
|
|
197
213
|
lastItem.pipe(
|
|
198
214
|
Option.flatMap((item) => {
|
|
199
215
|
const lastBatchItem = item.batch.at(-1)
|
|
200
|
-
if (
|
|
216
|
+
if (lastBatchItem == null) return Option.none()
|
|
201
217
|
return Option.some({
|
|
202
218
|
eventSequenceNumber: lastBatchItem.eventEncoded.seqNum,
|
|
203
219
|
metadata: lastBatchItem.metadata,
|
|
@@ -208,11 +224,11 @@ export const makeSyncBackend =
|
|
|
208
224
|
|
|
209
225
|
const loop = (
|
|
210
226
|
cursor: Option.Option<{
|
|
211
|
-
eventSequenceNumber: EventSequenceNumber.
|
|
227
|
+
eventSequenceNumber: EventSequenceNumber.Global.Type
|
|
212
228
|
metadata: Option.Option<SyncMetadata>
|
|
213
229
|
}>,
|
|
214
230
|
isFirst: boolean,
|
|
215
|
-
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>,
|
|
231
|
+
): Stream.Stream<SyncBackend.PullResItem<SyncMetadata>, UnknownError> => {
|
|
216
232
|
const sseStream = (live: boolean) =>
|
|
217
233
|
runPullSse(cursor, live).pipe(
|
|
218
234
|
Stream.emitIfEmpty({
|
|
@@ -221,7 +237,7 @@ export const makeSyncBackend =
|
|
|
221
237
|
} as SyncBackend.PullResItem<SyncMetadata>),
|
|
222
238
|
)
|
|
223
239
|
|
|
224
|
-
const stream = isFirst ? sseStream(false) : sseStream(true)
|
|
240
|
+
const stream = isFirst === true ? sseStream(false) : sseStream(true)
|
|
225
241
|
|
|
226
242
|
return stream.pipe(
|
|
227
243
|
// Reconnect from last item if stream
|
|
@@ -235,7 +251,7 @@ export const makeSyncBackend =
|
|
|
235
251
|
return SyncBackend.of({
|
|
236
252
|
connect,
|
|
237
253
|
pull: (cursor, options) => {
|
|
238
|
-
if (options?.live) {
|
|
254
|
+
if (options?.live === true) {
|
|
239
255
|
return ssePull(cursor)
|
|
240
256
|
} else {
|
|
241
257
|
return runPullSse(cursor, false).pipe(
|
|
@@ -247,15 +263,47 @@ export const makeSyncBackend =
|
|
|
247
263
|
}
|
|
248
264
|
},
|
|
249
265
|
push: (batch) =>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
Effect.gen(function* () {
|
|
267
|
+
const toUnknownError = (cause: unknown): UnknownError => {
|
|
268
|
+
if (cause instanceof UnknownError) {
|
|
269
|
+
return cause
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (cause instanceof S2LimitExceededError) {
|
|
273
|
+
const note =
|
|
274
|
+
cause.limitType === 'record-metered-bytes'
|
|
275
|
+
? `S2 record exceeded ${cause.max} metered bytes (actual: ${cause.actual})`
|
|
276
|
+
: `S2 batch exceeded ${cause.max} (type: ${cause.limitType}, actual: ${cause.actual})`
|
|
277
|
+
|
|
278
|
+
return new UnknownError({
|
|
279
|
+
cause,
|
|
280
|
+
note,
|
|
281
|
+
payload: {
|
|
282
|
+
limitType: cause.limitType,
|
|
283
|
+
max: cause.max,
|
|
284
|
+
actual: cause.actual,
|
|
285
|
+
recordIndex: cause.recordIndex,
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return new UnknownError({ cause })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const chunks = yield* Effect.sync(() => chunkEventsForS2(batch)).pipe(Effect.mapError(toUnknownError))
|
|
294
|
+
|
|
295
|
+
for (const chunk of chunks) {
|
|
296
|
+
yield* HttpClientRequest.schemaBodyJson(ApiSchema.PushPayload)(HttpClientRequest.post(pushEndpoint), {
|
|
297
|
+
storeId,
|
|
298
|
+
batch: chunk.events,
|
|
299
|
+
}).pipe(
|
|
300
|
+
Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
|
|
301
|
+
Effect.andThen(HttpClientResponse.schemaBodyJson(ApiSchema.PushResponse)),
|
|
302
|
+
Effect.mapError(toUnknownError),
|
|
303
|
+
Effect.retry(retry?.push ?? defaultRetry),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
}),
|
|
259
307
|
ping,
|
|
260
308
|
isConnected,
|
|
261
309
|
metadata: {
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { Brand, Schema } from '@livestore/utils/effect'
|
|
|
12
12
|
*/
|
|
13
13
|
export type S2SeqNum = Brand.Branded<number, 'S2SeqNum'>
|
|
14
14
|
export const s2SeqNum = Brand.nominal<S2SeqNum>()
|
|
15
|
-
export const S2SeqNum = Schema.fromBrand(s2SeqNum)(Schema.Int.
|
|
15
|
+
export const S2SeqNum = Schema.fromBrand('S2SeqNum', s2SeqNum)(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Metadata for tracking S2-specific cursor information.
|