@livestore/common 0.4.0-dev.6 → 0.4.0-dev.8
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/devtools/devtools-messages-client-session.d.ts +21 -21
- package/dist/devtools/devtools-messages-common.d.ts +6 -6
- package/dist/devtools/devtools-messages-leader.d.ts +24 -24
- package/dist/leader-thread/LeaderSyncProcessor.d.ts +4 -1
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +40 -14
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/eventlog.js +1 -1
- package/dist/leader-thread/eventlog.js.map +1 -1
- package/dist/leader-thread/types.d.ts +1 -0
- package/dist/leader-thread/types.d.ts.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.js +2 -2
- package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
- package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
- package/dist/schema/state/sqlite/column-annotations.js +14 -6
- package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js +81 -0
- package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
- package/dist/schema/state/sqlite/table-def.d.ts +4 -4
- package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
- package/dist/schema/state/sqlite/table-def.js +2 -2
- package/dist/schema/state/sqlite/table-def.js.map +1 -1
- package/dist/schema/state/sqlite/table-def.test.js +44 -0
- package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +7 -3
- package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
- package/dist/sync/mock-sync-backend.d.ts +11 -2
- package/dist/sync/mock-sync-backend.d.ts.map +1 -1
- package/dist/sync/mock-sync-backend.js +59 -7
- package/dist/sync/mock-sync-backend.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/src/leader-thread/LeaderSyncProcessor.ts +60 -17
- package/src/leader-thread/eventlog.ts +1 -1
- package/src/leader-thread/types.ts +1 -0
- package/src/schema/state/sqlite/client-document-def.ts +2 -2
- package/src/schema/state/sqlite/column-annotations.ts +16 -6
- package/src/schema/state/sqlite/query-builder/impl.test.ts +121 -0
- package/src/schema/state/sqlite/table-def.test.ts +53 -0
- package/src/schema/state/sqlite/table-def.ts +9 -8
- package/src/sync/ClientSessionSyncProcessor.ts +9 -3
- package/src/sync/mock-sync-backend.ts +104 -16
- package/src/version.ts +1 -1
- package/dist/schema-management/migrations.test.d.ts +0 -2
- package/dist/schema-management/migrations.test.d.ts.map +0 -1
- package/dist/schema-management/migrations.test.js +0 -52
- package/dist/schema-management/migrations.test.js.map +0 -1
@@ -1,5 +1,5 @@
|
|
1
1
|
import type { Schema } from '@livestore/utils/effect'
|
2
|
-
import { dual } from '@livestore/utils/effect'
|
2
|
+
import { dual, Option, SchemaAST } from '@livestore/utils/effect'
|
3
3
|
import type { SqliteDsl } from './db-schema/mod.ts'
|
4
4
|
|
5
5
|
export const PrimaryKeyId = Symbol.for('livestore/state/sqlite/annotations/primary-key')
|
@@ -32,7 +32,7 @@ Here are the knobs you can turn per-column when you CREATE TABLE (or ALTER TABLE
|
|
32
32
|
* Adds a primary key annotation to a schema.
|
33
33
|
*/
|
34
34
|
export const withPrimaryKey = <T extends Schema.Schema.All>(schema: T) =>
|
35
|
-
schema
|
35
|
+
applyAnnotations(schema, { [PrimaryKeyId]: true })
|
36
36
|
|
37
37
|
/**
|
38
38
|
* Adds a column type annotation to a schema.
|
@@ -43,19 +43,19 @@ export const withColumnType: {
|
|
43
43
|
<T extends Schema.Schema.All>(schema: T, type: SqliteDsl.FieldColumnType): T
|
44
44
|
} = dual(2, <T extends Schema.Schema.All>(schema: T, type: SqliteDsl.FieldColumnType) => {
|
45
45
|
validateSchemaColumnTypeCompatibility(schema, type)
|
46
|
-
return schema
|
46
|
+
return applyAnnotations(schema, { [ColumnType]: type })
|
47
47
|
})
|
48
48
|
|
49
49
|
/**
|
50
50
|
* Adds an auto-increment annotation to a schema.
|
51
51
|
*/
|
52
52
|
export const withAutoIncrement = <T extends Schema.Schema.All>(schema: T) =>
|
53
|
-
schema
|
53
|
+
applyAnnotations(schema, { [AutoIncrement]: true })
|
54
54
|
|
55
55
|
/**
|
56
56
|
* Adds a unique constraint annotation to a schema.
|
57
57
|
*/
|
58
|
-
export const withUnique = <T extends Schema.Schema.All>(schema: T) => schema
|
58
|
+
export const withUnique = <T extends Schema.Schema.All>(schema: T) => applyAnnotations(schema, { [Unique]: true })
|
59
59
|
|
60
60
|
/**
|
61
61
|
* Adds a default value annotation to a schema.
|
@@ -64,7 +64,7 @@ export const withDefault: {
|
|
64
64
|
// TODO make type safe
|
65
65
|
<T extends Schema.Schema.All>(schema: T, value: unknown): T
|
66
66
|
(value: unknown): <T extends Schema.Schema.All>(schema: T) => T
|
67
|
-
} = dual(2, <T extends Schema.Schema.All>(schema: T, value: unknown) => schema
|
67
|
+
} = dual(2, <T extends Schema.Schema.All>(schema: T, value: unknown) => applyAnnotations(schema, { [Default]: value }))
|
68
68
|
|
69
69
|
/**
|
70
70
|
* Validates that a schema is compatible with the specified SQLite column type
|
@@ -75,3 +75,13 @@ const validateSchemaColumnTypeCompatibility = (
|
|
75
75
|
): void => {
|
76
76
|
// TODO actually implement this
|
77
77
|
}
|
78
|
+
|
79
|
+
const applyAnnotations = <T extends Schema.Schema.All>(schema: T, overrides: Record<PropertyKey, unknown>): T => {
|
80
|
+
const identifier = SchemaAST.getIdentifierAnnotation(schema.ast)
|
81
|
+
const shouldPreserveIdentifier = Option.isSome(identifier) && !(SchemaAST.IdentifierAnnotationId in overrides)
|
82
|
+
const annotations: Record<PropertyKey, unknown> = shouldPreserveIdentifier
|
83
|
+
? { ...overrides, [SchemaAST.IdentifierAnnotationId]: identifier.value }
|
84
|
+
: overrides
|
85
|
+
|
86
|
+
return schema.annotations(annotations) as T
|
87
|
+
}
|
@@ -683,6 +683,127 @@ describe('query builder', () => {
|
|
683
683
|
expect(pattern1).toEqual(pattern2)
|
684
684
|
})
|
685
685
|
})
|
686
|
+
|
687
|
+
describe('schema transforms', () => {
|
688
|
+
const Flat = Schema.Struct({
|
689
|
+
id: Schema.String.pipe(State.SQLite.withPrimaryKey),
|
690
|
+
contactFirstName: Schema.String,
|
691
|
+
contactLastName: Schema.String,
|
692
|
+
contactEmail: Schema.String.pipe(State.SQLite.withUnique),
|
693
|
+
})
|
694
|
+
|
695
|
+
const Nested = Schema.transform(
|
696
|
+
Flat,
|
697
|
+
Schema.Struct({
|
698
|
+
id: Schema.String,
|
699
|
+
contact: Schema.Struct({
|
700
|
+
firstName: Schema.String,
|
701
|
+
lastName: Schema.String,
|
702
|
+
email: Schema.String,
|
703
|
+
}),
|
704
|
+
}),
|
705
|
+
{
|
706
|
+
decode: ({ id, contactFirstName, contactLastName, contactEmail }) => ({
|
707
|
+
id,
|
708
|
+
contact: {
|
709
|
+
firstName: contactFirstName,
|
710
|
+
lastName: contactLastName,
|
711
|
+
email: contactEmail,
|
712
|
+
},
|
713
|
+
}),
|
714
|
+
encode: ({ id, contact }) => ({
|
715
|
+
id,
|
716
|
+
contactFirstName: contact.firstName,
|
717
|
+
contactLastName: contact.lastName,
|
718
|
+
contactEmail: contact.email,
|
719
|
+
}),
|
720
|
+
},
|
721
|
+
)
|
722
|
+
|
723
|
+
const makeContactsTable = () =>
|
724
|
+
State.SQLite.table({
|
725
|
+
name: 'contacts',
|
726
|
+
schema: Nested,
|
727
|
+
// schema: Flat,
|
728
|
+
})
|
729
|
+
|
730
|
+
it('exposes flattened insert type while schema type is nested', () => {
|
731
|
+
const contactsTable = makeContactsTable()
|
732
|
+
|
733
|
+
type InsertInput = Parameters<(typeof contactsTable)['insert']>[0]
|
734
|
+
type NestedType = Schema.Schema.Type<typeof Nested>
|
735
|
+
|
736
|
+
type Assert<T extends true> = T
|
737
|
+
|
738
|
+
type InsertKeys = keyof InsertInput
|
739
|
+
type NestedKeys = keyof NestedType
|
740
|
+
|
741
|
+
type _InsertHasFlattenedColumns = Assert<
|
742
|
+
'contactFirstName' extends InsertKeys
|
743
|
+
? 'contactLastName' extends InsertKeys
|
744
|
+
? 'contactEmail' extends InsertKeys
|
745
|
+
? true
|
746
|
+
: false
|
747
|
+
: false
|
748
|
+
: false
|
749
|
+
>
|
750
|
+
|
751
|
+
type _InsertDoesNotExposeNested = Assert<Extract<'contact', InsertKeys> extends never ? true : false>
|
752
|
+
|
753
|
+
type _SchemaTypeIsNested = Assert<'contact' extends NestedKeys ? true : false>
|
754
|
+
|
755
|
+
void contactsTable
|
756
|
+
})
|
757
|
+
|
758
|
+
it('fails to encode nested inserts because flat columns are required', () => {
|
759
|
+
const contactsTable = makeContactsTable()
|
760
|
+
|
761
|
+
expect(
|
762
|
+
contactsTable
|
763
|
+
// TODO in the future we should use decoded types here instead of encoded
|
764
|
+
.insert({
|
765
|
+
id: 'person-1',
|
766
|
+
contactFirstName: 'Ada',
|
767
|
+
contactLastName: 'Lovelace',
|
768
|
+
contactEmail: 'ada@example.com',
|
769
|
+
})
|
770
|
+
.asSql(),
|
771
|
+
).toMatchInlineSnapshot(`
|
772
|
+
{
|
773
|
+
"bindValues": [
|
774
|
+
"person-1",
|
775
|
+
"Ada",
|
776
|
+
"Lovelace",
|
777
|
+
"ada@example.com",
|
778
|
+
],
|
779
|
+
"query": "INSERT INTO 'contacts' (id, contactFirstName, contactLastName, contactEmail) VALUES (?, ?, ?, ?)",
|
780
|
+
"usedTables": Set {
|
781
|
+
"contacts",
|
782
|
+
},
|
783
|
+
}
|
784
|
+
`)
|
785
|
+
})
|
786
|
+
|
787
|
+
it('fails to encode nested inserts because flat columns are required', () => {
|
788
|
+
const contactsTable = makeContactsTable()
|
789
|
+
|
790
|
+
expect(() =>
|
791
|
+
contactsTable
|
792
|
+
.insert({
|
793
|
+
id: 'person-1',
|
794
|
+
// @ts-expect-error
|
795
|
+
contact: {
|
796
|
+
firstName: 'Ada',
|
797
|
+
lastName: 'Lovelace',
|
798
|
+
email: 'ada@example.com',
|
799
|
+
},
|
800
|
+
})
|
801
|
+
.asSql(),
|
802
|
+
).toThrowErrorMatchingInlineSnapshot(`
|
803
|
+
[ParseError: contacts\n└─ ["contactFirstName"]\n └─ is missing]
|
804
|
+
`)
|
805
|
+
})
|
806
|
+
})
|
686
807
|
})
|
687
808
|
|
688
809
|
// TODO nested queries
|
@@ -178,6 +178,59 @@ describe('table function overloads', () => {
|
|
178
178
|
expect(userTable.sqliteDef.columns.age.columnType).toBe('integer')
|
179
179
|
})
|
180
180
|
|
181
|
+
it('should support schemas that transform flat columns into nested types', () => {
|
182
|
+
const Flat = Schema.Struct({
|
183
|
+
id: Schema.String.pipe(State.SQLite.withPrimaryKey),
|
184
|
+
contactFirstName: Schema.String,
|
185
|
+
contactLastName: Schema.String,
|
186
|
+
contactEmail: Schema.String.pipe(State.SQLite.withUnique),
|
187
|
+
})
|
188
|
+
|
189
|
+
const Nested = Schema.transform(
|
190
|
+
Flat,
|
191
|
+
Schema.Struct({
|
192
|
+
id: Schema.String,
|
193
|
+
contact: Schema.Struct({
|
194
|
+
firstName: Schema.String,
|
195
|
+
lastName: Schema.String,
|
196
|
+
email: Schema.String,
|
197
|
+
}),
|
198
|
+
}),
|
199
|
+
{
|
200
|
+
decode: ({ id, contactFirstName, contactLastName, contactEmail }) => ({
|
201
|
+
id,
|
202
|
+
contact: {
|
203
|
+
firstName: contactFirstName,
|
204
|
+
lastName: contactLastName,
|
205
|
+
email: contactEmail,
|
206
|
+
},
|
207
|
+
}),
|
208
|
+
encode: ({ id, contact }) => ({
|
209
|
+
id,
|
210
|
+
contactFirstName: contact.firstName,
|
211
|
+
contactLastName: contact.lastName,
|
212
|
+
contactEmail: contact.email,
|
213
|
+
}),
|
214
|
+
},
|
215
|
+
)
|
216
|
+
|
217
|
+
const contactsTable = State.SQLite.table({
|
218
|
+
name: 'contacts',
|
219
|
+
schema: Nested,
|
220
|
+
})
|
221
|
+
|
222
|
+
const columns = contactsTable.sqliteDef.columns
|
223
|
+
|
224
|
+
expect(Object.keys(columns)).toEqual(['id', 'contactFirstName', 'contactLastName', 'contactEmail'])
|
225
|
+
expect(columns.id.primaryKey).toBe(true)
|
226
|
+
expect(columns.contactEmail.columnType).toBe('text')
|
227
|
+
expect(contactsTable.sqliteDef.indexes).toContainEqual({
|
228
|
+
name: 'idx_contacts_contactEmail_unique',
|
229
|
+
columns: ['contactEmail'],
|
230
|
+
isUnique: true,
|
231
|
+
})
|
232
|
+
})
|
233
|
+
|
181
234
|
it('should extract table name from Schema.Class identifier', () => {
|
182
235
|
class TodoItem extends Schema.Class<TodoItem>('TodoItem')({
|
183
236
|
id: Schema.String,
|
@@ -1,5 +1,5 @@
|
|
1
1
|
import { type Nullable, shouldNeverHappen } from '@livestore/utils'
|
2
|
-
import { Option,
|
2
|
+
import { Option, Schema, SchemaAST, type Types } from '@livestore/utils/effect'
|
3
3
|
|
4
4
|
import { getColumnDefForSchema, schemaFieldsToColumns } from './column-def.ts'
|
5
5
|
import { SqliteDsl } from './db-schema/mod.ts'
|
@@ -221,7 +221,7 @@ export function table<
|
|
221
221
|
) as SqliteDsl.Columns
|
222
222
|
additionalIndexes = []
|
223
223
|
} else if ('schema' in args) {
|
224
|
-
const result = schemaFieldsToColumns(
|
224
|
+
const result = schemaFieldsToColumns(Schema.getResolvedPropertySignatures(args.schema))
|
225
225
|
columns = result.columns
|
226
226
|
|
227
227
|
// We'll set tableName first, then use it for index names
|
@@ -381,12 +381,13 @@ export declare namespace SchemaToColumns {
|
|
381
381
|
export type ColumnDefForType<TEncoded, TType> = SqliteDsl.ColumnDefinition<TEncoded, TType>
|
382
382
|
|
383
383
|
// Create columns type from schema Type and Encoded
|
384
|
-
export type FromTypes<TType, TEncoded> =
|
385
|
-
?
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
384
|
+
export type FromTypes<TType, TEncoded> = TEncoded extends Record<string, any>
|
385
|
+
? {
|
386
|
+
[K in keyof TEncoded]-?: ColumnDefForType<
|
387
|
+
TEncoded[K],
|
388
|
+
TType extends Record<string, any> ? (K extends keyof TType ? TType[K] : TEncoded[K]) : TEncoded[K]
|
389
|
+
>
|
390
|
+
}
|
390
391
|
: SqliteDsl.Columns
|
391
392
|
}
|
392
393
|
|
@@ -94,7 +94,7 @@ export const makeClientSessionSyncProcessor = ({
|
|
94
94
|
}),
|
95
95
|
}
|
96
96
|
|
97
|
-
/** Only used for debugging / observability, it's not relied upon for correctness of the sync processor. */
|
97
|
+
/** Only used for debugging / observability / testing, it's not relied upon for correctness of the sync processor. */
|
98
98
|
const syncStateUpdateQueue = Queue.unbounded<SyncState.SyncState>().pipe(Effect.runSync)
|
99
99
|
const isClientEvent = (eventEncoded: LiveStoreEvent.EncodedWithMeta) =>
|
100
100
|
getEventDef(schema, eventEncoded.name).eventDef.options.clientOnly
|
@@ -240,7 +240,6 @@ export const makeClientSessionSyncProcessor = ({
|
|
240
240
|
}
|
241
241
|
|
242
242
|
syncStateRef.current = mergeResult.newSyncState
|
243
|
-
yield* syncStateUpdateQueue.offer(mergeResult.newSyncState)
|
244
243
|
|
245
244
|
if (mergeResult._tag === 'rebase') {
|
246
245
|
span.addEvent('merge:pull:rebase', {
|
@@ -298,7 +297,11 @@ export const makeClientSessionSyncProcessor = ({
|
|
298
297
|
debugInfo.advanceCount++
|
299
298
|
}
|
300
299
|
|
301
|
-
if (mergeResult.newEvents.length === 0)
|
300
|
+
if (mergeResult.newEvents.length === 0) {
|
301
|
+
// If there are no new events, we need to update the sync state as well
|
302
|
+
yield* syncStateUpdateQueue.offer(mergeResult.newSyncState)
|
303
|
+
return
|
304
|
+
}
|
302
305
|
|
303
306
|
const writeTables = new Set<string>()
|
304
307
|
for (const event of mergeResult.newEvents) {
|
@@ -321,6 +324,9 @@ export const makeClientSessionSyncProcessor = ({
|
|
321
324
|
}
|
322
325
|
|
323
326
|
refreshTables(writeTables)
|
327
|
+
|
328
|
+
// We're only triggering the sync state update after all events have been materialized
|
329
|
+
yield* syncStateUpdateQueue.offer(mergeResult.newSyncState)
|
324
330
|
}).pipe(
|
325
331
|
Effect.tapCauseLogPretty,
|
326
332
|
Effect.catchAllCause((cause) => clientSession.shutdown(Exit.failCause(cause))),
|
@@ -1,7 +1,8 @@
|
|
1
1
|
import type { Schema, Scope } from '@livestore/utils/effect'
|
2
2
|
import { Effect, Mailbox, Option, Queue, Stream, SubscriptionRef } from '@livestore/utils/effect'
|
3
|
-
import
|
3
|
+
import { UnexpectedError } from '../errors.ts'
|
4
4
|
import { EventSequenceNumber, type LiveStoreEvent } from '../schema/mod.ts'
|
5
|
+
import { InvalidPushError } from './errors.ts'
|
5
6
|
import * as SyncBackend from './sync-backend.ts'
|
6
7
|
import { validatePushPayload } from './validate-push-payload.ts'
|
7
8
|
|
@@ -11,41 +12,118 @@ export interface MockSyncBackend {
|
|
11
12
|
disconnect: Effect.Effect<void>
|
12
13
|
makeSyncBackend: Effect.Effect<SyncBackend.SyncBackend, UnexpectedError, Scope.Scope>
|
13
14
|
advance: (...batch: LiveStoreEvent.AnyEncodedGlobal[]) => Effect.Effect<void>
|
15
|
+
/** Fail the next N push calls with an InvalidPushError (or custom error) */
|
16
|
+
failNextPushes: (
|
17
|
+
count: number,
|
18
|
+
error?: (batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>) => Effect.Effect<never, InvalidPushError>,
|
19
|
+
) => Effect.Effect<void>
|
14
20
|
}
|
15
21
|
|
16
|
-
export
|
17
|
-
|
22
|
+
export interface MockSyncBackendOptions {
|
23
|
+
/** Chunk size for non-live pulls; defaults to 100 */
|
24
|
+
nonLiveChunkSize?: number
|
25
|
+
/** Initial connected state; defaults to false */
|
26
|
+
startConnected?: boolean
|
27
|
+
// TODO add a "flaky" mode to simulate transient network / server failures for pull/push
|
28
|
+
}
|
29
|
+
|
30
|
+
export const makeMockSyncBackend = (
|
31
|
+
options?: MockSyncBackendOptions,
|
32
|
+
): Effect.Effect<MockSyncBackend, UnexpectedError, Scope.Scope> =>
|
33
|
+
Effect.gen(function* () {
|
18
34
|
const syncEventSequenceNumberRef = { current: EventSequenceNumber.ROOT.global }
|
19
35
|
const syncPullQueue = yield* Queue.unbounded<LiveStoreEvent.AnyEncodedGlobal>()
|
20
36
|
const pushedEventsQueue = yield* Mailbox.make<LiveStoreEvent.AnyEncodedGlobal>()
|
21
|
-
const syncIsConnectedRef = yield* SubscriptionRef.make(
|
37
|
+
const syncIsConnectedRef = yield* SubscriptionRef.make(options?.startConnected ?? false)
|
38
|
+
const allEventsRef: { current: LiveStoreEvent.AnyEncodedGlobal[] } = { current: [] }
|
22
39
|
|
23
40
|
const span = yield* Effect.currentSpan.pipe(Effect.orDie)
|
24
41
|
|
25
42
|
const semaphore = yield* Effect.makeSemaphore(1)
|
26
43
|
|
44
|
+
// TODO improve the API and implementation of simulating errors
|
45
|
+
const failCounterRef = yield* SubscriptionRef.make(0)
|
46
|
+
const failEffectRef = yield* SubscriptionRef.make<
|
47
|
+
((batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>) => Effect.Effect<never, InvalidPushError>) | undefined
|
48
|
+
>(undefined)
|
49
|
+
|
27
50
|
const makeSyncBackend = Effect.gen(function* () {
|
51
|
+
const nonLiveChunkSize = Math.max(1, options?.nonLiveChunkSize ?? 100)
|
52
|
+
|
53
|
+
// TODO consider making offline state actively error pull/push.
|
54
|
+
// Currently, offline only reflects in `isConnected`, while operations still succeed,
|
55
|
+
// mirroring how some real providers behave during transient disconnects.
|
28
56
|
return SyncBackend.of<Schema.JsonValue>({
|
29
57
|
isConnected: syncIsConnectedRef,
|
30
|
-
connect:
|
58
|
+
connect: SubscriptionRef.set(syncIsConnectedRef, true),
|
31
59
|
ping: Effect.void,
|
32
|
-
pull: () =>
|
33
|
-
|
34
|
-
Stream.
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
60
|
+
pull: (cursor, options) =>
|
61
|
+
(options?.live
|
62
|
+
? Stream.concat(
|
63
|
+
Stream.make(SyncBackend.pullResItemEmpty()),
|
64
|
+
Stream.fromQueue(syncPullQueue).pipe(
|
65
|
+
Stream.chunks,
|
66
|
+
Stream.map((chunk) => ({
|
67
|
+
batch: [...chunk].map((eventEncoded) => ({ eventEncoded, metadata: Option.none() })),
|
68
|
+
pageInfo: SyncBackend.pageInfoNoMore,
|
69
|
+
})),
|
70
|
+
),
|
71
|
+
)
|
72
|
+
: Stream.fromEffect(
|
73
|
+
Effect.sync(() => {
|
74
|
+
const lastSeen = cursor.pipe(
|
75
|
+
Option.match({
|
76
|
+
onNone: () => EventSequenceNumber.ROOT.global,
|
77
|
+
onSome: (_) => _.eventSequenceNumber,
|
78
|
+
}),
|
79
|
+
)
|
80
|
+
// All events with seqNum greater than lastSeen
|
81
|
+
const slice = allEventsRef.current.filter((e) => e.seqNum > lastSeen)
|
82
|
+
// Split into configured chunk size
|
83
|
+
const chunks: { events: LiveStoreEvent.AnyEncodedGlobal[]; remaining: number }[] = []
|
84
|
+
for (let i = 0; i < slice.length; i += nonLiveChunkSize) {
|
85
|
+
const end = Math.min(i + nonLiveChunkSize, slice.length)
|
86
|
+
const remaining = Math.max(slice.length - end, 0)
|
87
|
+
chunks.push({ events: slice.slice(i, end), remaining })
|
88
|
+
}
|
89
|
+
if (chunks.length === 0) {
|
90
|
+
chunks.push({ events: [], remaining: 0 })
|
91
|
+
}
|
92
|
+
return chunks
|
93
|
+
}),
|
94
|
+
).pipe(
|
95
|
+
Stream.flatMap((chunks) =>
|
96
|
+
Stream.fromIterable(chunks).pipe(
|
97
|
+
Stream.map(({ events, remaining }) => ({
|
98
|
+
batch: events.map((eventEncoded) => ({ eventEncoded, metadata: Option.none() })),
|
99
|
+
pageInfo: remaining > 0 ? SyncBackend.pageInfoMoreKnown(remaining) : SyncBackend.pageInfoNoMore,
|
100
|
+
})),
|
101
|
+
),
|
102
|
+
),
|
103
|
+
)
|
104
|
+
).pipe(Stream.withSpan('MockSyncBackend:pull', { parent: span })),
|
41
105
|
push: (batch) =>
|
42
106
|
Effect.gen(function* () {
|
43
107
|
yield* validatePushPayload(batch, syncEventSequenceNumberRef.current)
|
44
108
|
|
109
|
+
const remaining = yield* SubscriptionRef.get(failCounterRef)
|
110
|
+
if (remaining > 0) {
|
111
|
+
const maybeFail = yield* SubscriptionRef.get(failEffectRef)
|
112
|
+
// decrement counter first
|
113
|
+
yield* SubscriptionRef.set(failCounterRef, remaining - 1)
|
114
|
+
if (maybeFail) {
|
115
|
+
return yield* maybeFail(batch)
|
116
|
+
}
|
117
|
+
return yield* new InvalidPushError({
|
118
|
+
cause: new UnexpectedError({ cause: new Error('MockSyncBackend: simulated push failure') }),
|
119
|
+
})
|
120
|
+
}
|
121
|
+
|
45
122
|
yield* Effect.sleep(10).pipe(Effect.withSpan('MockSyncBackend:push:sleep')) // Simulate network latency
|
46
123
|
|
47
124
|
yield* pushedEventsQueue.offerAll(batch)
|
48
125
|
yield* syncPullQueue.offerAll(batch)
|
126
|
+
allEventsRef.current = allEventsRef.current.concat(batch)
|
49
127
|
|
50
128
|
syncEventSequenceNumberRef.current = batch.at(-1)!.seqNum
|
51
129
|
}).pipe(
|
@@ -71,6 +149,7 @@ export const makeMockSyncBackend: Effect.Effect<MockSyncBackend, UnexpectedError
|
|
71
149
|
const advance = (...batch: LiveStoreEvent.AnyEncodedGlobal[]) =>
|
72
150
|
Effect.gen(function* () {
|
73
151
|
syncEventSequenceNumberRef.current = batch.at(-1)!.seqNum
|
152
|
+
allEventsRef.current = allEventsRef.current.concat(batch)
|
74
153
|
yield* syncPullQueue.offerAll(batch)
|
75
154
|
}).pipe(
|
76
155
|
Effect.withSpan('MockSyncBackend:advance', {
|
@@ -83,6 +162,15 @@ export const makeMockSyncBackend: Effect.Effect<MockSyncBackend, UnexpectedError
|
|
83
162
|
const connect = SubscriptionRef.set(syncIsConnectedRef, true)
|
84
163
|
const disconnect = SubscriptionRef.set(syncIsConnectedRef, false)
|
85
164
|
|
165
|
+
const failNextPushes = (
|
166
|
+
count: number,
|
167
|
+
error?: (batch: ReadonlyArray<LiveStoreEvent.AnyEncodedGlobal>) => Effect.Effect<never, InvalidPushError>,
|
168
|
+
) =>
|
169
|
+
Effect.gen(function* () {
|
170
|
+
yield* SubscriptionRef.set(failCounterRef, count)
|
171
|
+
yield* SubscriptionRef.set(failEffectRef, error)
|
172
|
+
})
|
173
|
+
|
86
174
|
return {
|
87
175
|
syncEventSequenceNumberRef,
|
88
176
|
syncPullQueue,
|
@@ -91,6 +179,6 @@ export const makeMockSyncBackend: Effect.Effect<MockSyncBackend, UnexpectedError
|
|
91
179
|
disconnect,
|
92
180
|
makeSyncBackend,
|
93
181
|
advance,
|
182
|
+
failNextPushes,
|
94
183
|
}
|
95
|
-
}
|
96
|
-
).pipe(Effect.withSpanScoped('MockSyncBackend'))
|
184
|
+
}).pipe(Effect.withSpanScoped('MockSyncBackend'))
|
package/src/version.ts
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
// import packageJson from '../package.json' with { type: 'json' }
|
3
3
|
// export const liveStoreVersion = packageJson.version
|
4
4
|
|
5
|
-
export const liveStoreVersion = '0.4.0-dev.
|
5
|
+
export const liveStoreVersion = '0.4.0-dev.8' as const
|
6
6
|
|
7
7
|
/**
|
8
8
|
* This version number is incremented whenever the internal storage format changes in a breaking way.
|
@@ -1 +0,0 @@
|
|
1
|
-
{"version":3,"file":"migrations.test.d.ts","sourceRoot":"","sources":["../../src/schema-management/migrations.test.ts"],"names":[],"mappings":""}
|
@@ -1,52 +0,0 @@
|
|
1
|
-
import { Option, Schema } from '@livestore/utils/effect';
|
2
|
-
import { describe, expect, it } from 'vitest';
|
3
|
-
import { makeColumnSpec } from './migrations.js';
|
4
|
-
const createColumn = (name, type, options = {}) => ({
|
5
|
-
_tag: 'column',
|
6
|
-
name,
|
7
|
-
type: { _tag: type },
|
8
|
-
nullable: options.nullable ?? true,
|
9
|
-
primaryKey: options.primaryKey ?? false,
|
10
|
-
default: Option.none(),
|
11
|
-
schema: type === 'text' ? Schema.String : Schema.Number,
|
12
|
-
});
|
13
|
-
describe('makeColumnSpec', () => {
|
14
|
-
it('should quote column names properly for reserved keywords', () => {
|
15
|
-
const table = {
|
16
|
-
_tag: 'table',
|
17
|
-
name: 'blocks',
|
18
|
-
columns: [createColumn('order', 'integer', { nullable: false }), createColumn('group', 'text')],
|
19
|
-
indexes: [],
|
20
|
-
};
|
21
|
-
const result = makeColumnSpec(table);
|
22
|
-
expect(result).toMatchInlineSnapshot(`"'order' integer not null , 'group' text "`);
|
23
|
-
expect(result).toContain("'order'");
|
24
|
-
expect(result).toContain("'group'");
|
25
|
-
});
|
26
|
-
it('should handle basic columns with primary keys', () => {
|
27
|
-
const table = {
|
28
|
-
_tag: 'table',
|
29
|
-
name: 'users',
|
30
|
-
columns: [createColumn('id', 'text', { nullable: false, primaryKey: true }), createColumn('name', 'text')],
|
31
|
-
indexes: [],
|
32
|
-
};
|
33
|
-
const result = makeColumnSpec(table);
|
34
|
-
expect(result).toMatchInlineSnapshot(`"'id' text not null , 'name' text , PRIMARY KEY ('id')"`);
|
35
|
-
expect(result).toContain("PRIMARY KEY ('id')");
|
36
|
-
});
|
37
|
-
it('should handle multi-column primary keys', () => {
|
38
|
-
const table = {
|
39
|
-
_tag: 'table',
|
40
|
-
name: 'composite',
|
41
|
-
columns: [
|
42
|
-
createColumn('tenant_id', 'text', { nullable: false, primaryKey: true }),
|
43
|
-
createColumn('user_id', 'text', { nullable: false, primaryKey: true }),
|
44
|
-
],
|
45
|
-
indexes: [],
|
46
|
-
};
|
47
|
-
const result = makeColumnSpec(table);
|
48
|
-
expect(result).toMatchInlineSnapshot(`"'tenant_id' text not null , 'user_id' text not null , PRIMARY KEY ('tenant_id', 'user_id')"`);
|
49
|
-
expect(result).toContain("PRIMARY KEY ('tenant_id', 'user_id')");
|
50
|
-
});
|
51
|
-
});
|
52
|
-
//# sourceMappingURL=migrations.test.js.map
|
@@ -1 +0,0 @@
|
|
1
|
-
{"version":3,"file":"migrations.test.js","sourceRoot":"","sources":["../../src/schema-management/migrations.test.ts"],"names":[],"mappings":"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"}
|