@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.
Files changed (51) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  3. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  4. package/dist/devtools/devtools-messages-leader.d.ts +24 -24
  5. package/dist/leader-thread/LeaderSyncProcessor.d.ts +4 -1
  6. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  7. package/dist/leader-thread/LeaderSyncProcessor.js +40 -14
  8. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  9. package/dist/leader-thread/eventlog.js +1 -1
  10. package/dist/leader-thread/eventlog.js.map +1 -1
  11. package/dist/leader-thread/types.d.ts +1 -0
  12. package/dist/leader-thread/types.d.ts.map +1 -1
  13. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  14. package/dist/schema/state/sqlite/client-document-def.js +2 -2
  15. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  16. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  17. package/dist/schema/state/sqlite/column-annotations.js +14 -6
  18. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  19. package/dist/schema/state/sqlite/query-builder/impl.test.js +81 -0
  20. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  21. package/dist/schema/state/sqlite/table-def.d.ts +4 -4
  22. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  23. package/dist/schema/state/sqlite/table-def.js +2 -2
  24. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  25. package/dist/schema/state/sqlite/table-def.test.js +44 -0
  26. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  27. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  28. package/dist/sync/ClientSessionSyncProcessor.js +7 -3
  29. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  30. package/dist/sync/mock-sync-backend.d.ts +11 -2
  31. package/dist/sync/mock-sync-backend.d.ts.map +1 -1
  32. package/dist/sync/mock-sync-backend.js +59 -7
  33. package/dist/sync/mock-sync-backend.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +4 -4
  37. package/src/leader-thread/LeaderSyncProcessor.ts +60 -17
  38. package/src/leader-thread/eventlog.ts +1 -1
  39. package/src/leader-thread/types.ts +1 -0
  40. package/src/schema/state/sqlite/client-document-def.ts +2 -2
  41. package/src/schema/state/sqlite/column-annotations.ts +16 -6
  42. package/src/schema/state/sqlite/query-builder/impl.test.ts +121 -0
  43. package/src/schema/state/sqlite/table-def.test.ts +53 -0
  44. package/src/schema/state/sqlite/table-def.ts +9 -8
  45. package/src/sync/ClientSessionSyncProcessor.ts +9 -3
  46. package/src/sync/mock-sync-backend.ts +104 -16
  47. package/src/version.ts +1 -1
  48. package/dist/schema-management/migrations.test.d.ts +0 -2
  49. package/dist/schema-management/migrations.test.d.ts.map +0 -1
  50. package/dist/schema-management/migrations.test.js +0 -52
  51. 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.annotations({ [PrimaryKeyId]: true }) as T
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.annotations({ [ColumnType]: type }) as T
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.annotations({ [AutoIncrement]: true }) as T
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.annotations({ [Unique]: true }) as T
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.annotations({ [Default]: value }) as T)
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, type Schema, SchemaAST, type Types } from '@livestore/utils/effect'
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(SchemaAST.getPropertySignatures(args.schema.ast))
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> = TType extends Record<string, any>
385
- ? TEncoded extends Record<string, any>
386
- ? {
387
- [K in keyof TType & keyof TEncoded]: ColumnDefForType<TEncoded[K], TType[K]>
388
- }
389
- : SqliteDsl.Columns
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) return
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 type { UnexpectedError } from '../errors.ts'
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 const makeMockSyncBackend: Effect.Effect<MockSyncBackend, UnexpectedError, Scope.Scope> = Effect.gen(
17
- function* () {
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(true)
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: Effect.void,
58
+ connect: SubscriptionRef.set(syncIsConnectedRef, true),
31
59
  ping: Effect.void,
32
- pull: () =>
33
- Stream.fromQueue(syncPullQueue).pipe(
34
- Stream.chunks,
35
- Stream.map((chunk) => ({
36
- batch: [...chunk].map((eventEncoded) => ({ eventEncoded, metadata: Option.none() })),
37
- pageInfo: SyncBackend.pageInfoNoMore,
38
- })),
39
- Stream.withSpan('MockSyncBackend:pull', { parent: span }),
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.6' as const
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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=migrations.test.d.ts.map
@@ -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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"}