@livestore/common 0.0.58-dev.0 → 0.0.58-dev.10

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 (142) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +48 -6
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js +16 -1
  5. package/dist/adapter-types.js.map +1 -1
  6. package/dist/bounded-collections.js.map +1 -1
  7. package/dist/derived-mutations.d.ts +5 -5
  8. package/dist/derived-mutations.d.ts.map +1 -1
  9. package/dist/derived-mutations.js +4 -2
  10. package/dist/derived-mutations.js.map +1 -1
  11. package/dist/derived-mutations.test.js +1 -0
  12. package/dist/derived-mutations.test.js.map +1 -1
  13. package/dist/devtools/devtools-bridge.d.ts +1 -1
  14. package/dist/devtools/devtools-bridge.d.ts.map +1 -1
  15. package/dist/devtools/devtools-messages.d.ts +91 -13
  16. package/dist/devtools/devtools-messages.d.ts.map +1 -1
  17. package/dist/devtools/devtools-messages.js +13 -4
  18. package/dist/devtools/devtools-messages.js.map +1 -1
  19. package/dist/devtools/index.d.ts +1 -0
  20. package/dist/devtools/index.d.ts.map +1 -1
  21. package/dist/devtools/index.js +2 -0
  22. package/dist/devtools/index.js.map +1 -1
  23. package/dist/index.d.ts +4 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -1
  26. package/dist/rehydrate-from-mutationlog.js +11 -5
  27. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  28. package/dist/schema/index.d.ts +2 -2
  29. package/dist/schema/index.d.ts.map +1 -1
  30. package/dist/schema/index.js +1 -1
  31. package/dist/schema/index.js.map +1 -1
  32. package/dist/schema/mutations.d.ts +137 -17
  33. package/dist/schema/mutations.d.ts.map +1 -1
  34. package/dist/schema/mutations.js +42 -16
  35. package/dist/schema/mutations.js.map +1 -1
  36. package/dist/schema/schema-helpers.js +1 -1
  37. package/dist/schema/schema-helpers.js.map +1 -1
  38. package/dist/schema/system-tables.d.ts +119 -6
  39. package/dist/schema/system-tables.d.ts.map +1 -1
  40. package/dist/schema/system-tables.js +22 -9
  41. package/dist/schema/system-tables.js.map +1 -1
  42. package/dist/schema/table-def.d.ts +3 -3
  43. package/dist/schema/table-def.d.ts.map +1 -1
  44. package/dist/schema/table-def.js +1 -1
  45. package/dist/schema/table-def.js.map +1 -1
  46. package/dist/schema-management/migrations.d.ts +1 -1
  47. package/dist/schema-management/migrations.d.ts.map +1 -1
  48. package/dist/schema-management/migrations.js +2 -2
  49. package/dist/schema-management/migrations.js.map +1 -1
  50. package/dist/sql-queries/sql-queries.d.ts +10 -3
  51. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  52. package/dist/sql-queries/sql-queries.js +8 -7
  53. package/dist/sql-queries/sql-queries.js.map +1 -1
  54. package/dist/sql-queries/sql-query-builder.d.ts +1 -1
  55. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  56. package/dist/sql-queries/types.d.ts +2 -2
  57. package/dist/sql-queries/types.d.ts.map +1 -1
  58. package/dist/sync/next/compact-events.d.ts +15 -0
  59. package/dist/sync/next/compact-events.d.ts.map +1 -0
  60. package/dist/sync/next/compact-events.js +176 -0
  61. package/dist/sync/next/compact-events.js.map +1 -0
  62. package/dist/sync/next/facts.d.ts +37 -0
  63. package/dist/sync/next/facts.d.ts.map +1 -0
  64. package/dist/sync/next/facts.js +156 -0
  65. package/dist/sync/next/facts.js.map +1 -0
  66. package/dist/sync/next/graphology.d.ts +8 -0
  67. package/dist/sync/next/graphology.d.ts.map +1 -0
  68. package/dist/sync/next/graphology.js +36 -0
  69. package/dist/sync/next/graphology.js.map +1 -0
  70. package/dist/sync/next/graphology_.d.ts +3 -0
  71. package/dist/sync/next/graphology_.d.ts.map +1 -0
  72. package/dist/sync/next/graphology_.js +3 -0
  73. package/dist/sync/next/graphology_.js.map +1 -0
  74. package/dist/sync/next/history-dag.d.ts +30 -0
  75. package/dist/sync/next/history-dag.d.ts.map +1 -0
  76. package/dist/sync/next/history-dag.js +69 -0
  77. package/dist/sync/next/history-dag.js.map +1 -0
  78. package/dist/sync/next/mod.d.ts +5 -0
  79. package/dist/sync/next/mod.d.ts.map +1 -0
  80. package/dist/sync/next/mod.js +5 -0
  81. package/dist/sync/next/mod.js.map +1 -0
  82. package/dist/sync/next/rebase-events.d.ts +27 -0
  83. package/dist/sync/next/rebase-events.d.ts.map +1 -0
  84. package/dist/sync/next/rebase-events.js +41 -0
  85. package/dist/sync/next/rebase-events.js.map +1 -0
  86. package/dist/sync/next/test/compact-events.calculator.test.d.ts +2 -0
  87. package/dist/sync/next/test/compact-events.calculator.test.d.ts.map +1 -0
  88. package/dist/sync/next/test/compact-events.calculator.test.js +101 -0
  89. package/dist/sync/next/test/compact-events.calculator.test.js.map +1 -0
  90. package/dist/sync/next/test/compact-events.test.d.ts +2 -0
  91. package/dist/sync/next/test/compact-events.test.d.ts.map +1 -0
  92. package/dist/sync/next/test/compact-events.test.js +201 -0
  93. package/dist/sync/next/test/compact-events.test.js.map +1 -0
  94. package/dist/sync/next/test/mod.d.ts +2 -0
  95. package/dist/sync/next/test/mod.d.ts.map +1 -0
  96. package/dist/sync/next/test/mod.js +2 -0
  97. package/dist/sync/next/test/mod.js.map +1 -0
  98. package/dist/sync/next/test/mutation-fixtures.d.ts +73 -0
  99. package/dist/sync/next/test/mutation-fixtures.d.ts.map +1 -0
  100. package/dist/sync/next/test/mutation-fixtures.js +161 -0
  101. package/dist/sync/next/test/mutation-fixtures.js.map +1 -0
  102. package/dist/sync/sync.d.ts +19 -6
  103. package/dist/sync/sync.d.ts.map +1 -1
  104. package/dist/sync/sync.js.map +1 -1
  105. package/dist/version.d.ts +8 -0
  106. package/dist/version.d.ts.map +1 -1
  107. package/dist/version.js +9 -0
  108. package/dist/version.js.map +1 -1
  109. package/package.json +21 -4
  110. package/src/adapter-types.ts +46 -7
  111. package/src/bounded-collections.ts +1 -1
  112. package/src/derived-mutations.test.ts +2 -1
  113. package/src/derived-mutations.ts +10 -10
  114. package/src/devtools/devtools-bridge.ts +1 -1
  115. package/src/devtools/devtools-messages.ts +12 -2
  116. package/src/devtools/index.ts +2 -0
  117. package/src/index.ts +6 -0
  118. package/src/rehydrate-from-mutationlog.ts +16 -7
  119. package/src/schema/index.ts +4 -3
  120. package/src/schema/mutations.ts +175 -30
  121. package/src/schema/schema-helpers.ts +1 -1
  122. package/src/schema/system-tables.ts +30 -9
  123. package/src/schema/table-def.ts +3 -3
  124. package/src/schema-management/migrations.ts +2 -2
  125. package/src/sql-queries/sql-queries.ts +21 -10
  126. package/src/sql-queries/sql-query-builder.ts +1 -1
  127. package/src/sql-queries/types.ts +2 -2
  128. package/src/sync/next/ambient.d.ts +3 -0
  129. package/src/sync/next/compact-events.ts +218 -0
  130. package/src/sync/next/facts.ts +229 -0
  131. package/src/sync/next/graphology.ts +49 -0
  132. package/src/sync/next/graphology_.ts +2 -0
  133. package/src/sync/next/history-dag.ts +109 -0
  134. package/src/sync/next/mod.ts +4 -0
  135. package/src/sync/next/rebase-events.ts +97 -0
  136. package/src/sync/next/test/compact-events.calculator.test.ts +121 -0
  137. package/src/sync/next/test/compact-events.test.ts +232 -0
  138. package/src/sync/next/test/mod.ts +1 -0
  139. package/src/sync/next/test/mutation-fixtures.ts +230 -0
  140. package/src/sync/sync.ts +30 -6
  141. package/src/version.ts +10 -0
  142. package/tsconfig.json +1 -1
@@ -0,0 +1,97 @@
1
+ import type { EventId } from '../../adapter-types.js'
2
+ import type { MutationDef, MutationEvent, MutationEventFactsSnapshot } from '../../schema/mutations.js'
3
+ import {
4
+ applyFactGroups,
5
+ factsIntersect,
6
+ type FactValidationResult,
7
+ getFactsGroupForMutationArgs,
8
+ validateFacts,
9
+ } from './facts.js'
10
+ import type { HistoryDagNode } from './history-dag.js'
11
+
12
+ export type RebaseEventWithConflict = HistoryDagNode & {
13
+ conflictType: 'overlap' | 'missing-requirement'
14
+ conflictingEvents: HistoryDagNode[]
15
+ }
16
+
17
+ export type RebaseInput = {
18
+ newRemoteEvents: RebaseEventWithConflict[]
19
+ pendingLocalEvents: RebaseEventWithConflict[]
20
+ validate: (args: {
21
+ rebasedLocalEvents: MutationEvent.PartialAny[]
22
+ mutationDefs: Record<string, MutationDef.Any>
23
+ }) => FactValidationResult
24
+ }
25
+
26
+ export type RebaseOutput = {
27
+ rebasedLocalEvents: MutationEvent.PartialAny[]
28
+ }
29
+
30
+ export type RebaseFn = (input: RebaseInput) => RebaseOutput
31
+
32
+ export const defaultRebaseFn: RebaseFn = ({ pendingLocalEvents }) => {
33
+ if (pendingLocalEvents.some((_) => _.conflictType === 'missing-requirement')) {
34
+ throw new Error('missing-requirement conflicts must be resolved before rebasing')
35
+ }
36
+
37
+ return { rebasedLocalEvents: pendingLocalEvents }
38
+ }
39
+
40
+ export const rebaseEvents = ({
41
+ rebaseFn,
42
+ pendingLocalEvents,
43
+ newRemoteEvents,
44
+ currentFactsSnapshot,
45
+ }: {
46
+ pendingLocalEvents: HistoryDagNode[]
47
+ newRemoteEvents: HistoryDagNode[]
48
+ rebaseFn: RebaseFn
49
+ currentFactsSnapshot: MutationEventFactsSnapshot
50
+ }): MutationEvent.Any[] => {
51
+ const initialSnapshot = new Map(currentFactsSnapshot)
52
+ applyFactGroups(
53
+ newRemoteEvents.map((event) => event.factsGroup),
54
+ initialSnapshot,
55
+ )
56
+
57
+ // TODO detect and set actual conflict type (overlap or missing-requirement)
58
+ // TODO bring back validateFacts
59
+ const { rebasedLocalEvents } = rebaseFn({
60
+ pendingLocalEvents: pendingLocalEvents.map((pending) => ({
61
+ ...pending,
62
+ conflictType: 'overlap',
63
+ conflictingEvents: newRemoteEvents.filter((remote) =>
64
+ factsIntersect(remote.factsGroup.modifySet, pending.factsGroup.modifySet),
65
+ ),
66
+ })),
67
+ newRemoteEvents: newRemoteEvents.map((remote) => ({
68
+ ...remote,
69
+ conflictType: 'overlap',
70
+ conflictingEvents: pendingLocalEvents.filter((pending) =>
71
+ factsIntersect(pending.factsGroup.modifySet, remote.factsGroup.modifySet),
72
+ ),
73
+ })),
74
+ validate: ({ rebasedLocalEvents, mutationDefs }) =>
75
+ validateFacts({
76
+ factGroups: rebasedLocalEvents.map((event) =>
77
+ getFactsGroupForMutationArgs({
78
+ factsCallback: mutationDefs[event.mutation]!.options.facts,
79
+ args: event.args,
80
+ currentFacts: new Map(),
81
+ }),
82
+ ),
83
+ initialSnapshot,
84
+ }),
85
+ })
86
+ const headGlobalId = newRemoteEvents.at(-1)!.id.global
87
+
88
+ return rebasedLocalEvents.map(
89
+ (event, index) =>
90
+ ({
91
+ id: { global: headGlobalId + index + 1, local: 0 } satisfies EventId,
92
+ parentId: { global: headGlobalId + index, local: 0 } satisfies EventId,
93
+ mutation: event.mutation,
94
+ args: event.args,
95
+ }) satisfies MutationEvent.Any,
96
+ )
97
+ }
@@ -0,0 +1,121 @@
1
+ import { defineMutation } from '@livestore/common/schema'
2
+ import { Schema } from '@livestore/utils/effect'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { compactEvents } from '../compact-events.js'
6
+ import { historyDagFromNodes } from '../history-dag.js'
7
+ import { customSerializer } from './compact-events.test.js'
8
+ import { toEventNodes } from './mutation-fixtures.js'
9
+
10
+ expect.addSnapshotSerializer(customSerializer)
11
+
12
+ const compact = (events: any[]) => {
13
+ const dag = historyDagFromNodes(toEventNodes(events, mutations))
14
+ const compacted = compactEvents(dag)
15
+
16
+ return Array.from(compacted.dag.nodeEntries())
17
+ .map((_) => _.attributes)
18
+ .map(({ factsGroup, ...rest }) => ({ ...rest, facts: factsGroup }))
19
+ .slice(1)
20
+ }
21
+
22
+ const facts = {
23
+ multiplyByZero: `multiplyByZero`,
24
+ }
25
+
26
+ const mutations = {
27
+ add: defineMutation('add', Schema.Struct({ value: Schema.Number }), 'UPDATE values SET value = value + $value', {}),
28
+ multiply: defineMutation(
29
+ 'multiply',
30
+ Schema.Struct({ value: Schema.Number }),
31
+ 'UPDATE values SET value = value * $value',
32
+ {
33
+ facts: ({ value }, currentFacts) => ({
34
+ modify: {
35
+ set: value === 0 || currentFacts.has(facts.multiplyByZero) ? [facts.multiplyByZero] : [],
36
+ unset: value === 0 ? [] : [facts.multiplyByZero],
37
+ },
38
+ }),
39
+ },
40
+ ),
41
+ // TODO divide by zero
42
+ }
43
+
44
+ describe('compactEvents calculator', () => {
45
+ it('1 + 1', () => {
46
+ const expected = compact([
47
+ mutations.add({ value: 1 }), // 0
48
+ mutations.add({ value: 1 }), // 1
49
+ ])
50
+
51
+ expect(expected).toMatchInlineSnapshot(`
52
+ [
53
+ { id: 0, parentId: -1, mutation: "add", args: { value: 1 }, facts: "" }
54
+ { id: 1, parentId: 0, mutation: "add", args: { value: 1 }, facts: "" }
55
+ ]
56
+ `)
57
+ })
58
+
59
+ it('2 * 2', () => {
60
+ const expected = compact([
61
+ mutations.multiply({ value: 2 }), // 0
62
+ mutations.multiply({ value: 2 }), // 1
63
+ ])
64
+
65
+ expect(expected).toMatchInlineSnapshot(`
66
+ [
67
+ { id: 0, parentId: -1, mutation: "multiply", args: { value: 2 }, facts: "?multiplyByZero -multiplyByZero" }
68
+ { id: 1, parentId: 0, mutation: "multiply", args: { value: 2 }, facts: "?multiplyByZero -multiplyByZero" }
69
+ ]
70
+ `)
71
+ })
72
+
73
+ it('2 * 2 * 0', () => {
74
+ const expected = compact([
75
+ mutations.multiply({ value: 2 }), // 0
76
+ mutations.multiply({ value: 2 }), // 1
77
+ mutations.multiply({ value: 0 }), // 2
78
+ ])
79
+
80
+ expect(expected).toMatchInlineSnapshot(`
81
+ [
82
+ { id: 2, parentId: -1, mutation: "multiply", args: { value: 0 }, facts: "+multiplyByZero" }
83
+ ]
84
+ `)
85
+ })
86
+
87
+ it('2 * 2 * 0 + 1', () => {
88
+ const expected = compact([
89
+ mutations.multiply({ value: 2 }), // 0
90
+ mutations.multiply({ value: 2 }), // 1
91
+ mutations.multiply({ value: 0 }), // 2
92
+ mutations.add({ value: 1 }), // 3
93
+ ])
94
+
95
+ expect(expected).toMatchInlineSnapshot(`
96
+ [
97
+ { id: 2, parentId: -1, mutation: "multiply", args: { value: 0 }, facts: "+multiplyByZero" }
98
+ { id: 3, parentId: 2, mutation: "add", args: { value: 1 }, facts: "" }
99
+ ]
100
+ `)
101
+ })
102
+
103
+ it('1 + 2 * 0 * 2 + 1', () => {
104
+ const expected = compact([
105
+ mutations.add({ value: 1 }), // 0
106
+ mutations.multiply({ value: 2 }), // 1
107
+ mutations.multiply({ value: 0 }), // 2
108
+ mutations.multiply({ value: 2 }), // 3
109
+ mutations.add({ value: 1 }), // 4
110
+ ])
111
+
112
+ expect(expected).toMatchInlineSnapshot(`
113
+ [
114
+ { id: 0, parentId: -1, mutation: "add", args: { value: 1 }, facts: "" }
115
+ { id: 2, parentId: 0, mutation: "multiply", args: { value: 0 }, facts: "+multiplyByZero" }
116
+ { id: 3, parentId: 2, mutation: "multiply", args: { value: 2 }, facts: "?multiplyByZero +multiplyByZero -multiplyByZero" }
117
+ { id: 4, parentId: 3, mutation: "add", args: { value: 1 }, facts: "" }
118
+ ]
119
+ `)
120
+ })
121
+ })
@@ -0,0 +1,232 @@
1
+ import type { MutationEventFacts } from '@livestore/common/schema'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { compactEvents } from '../compact-events.js'
5
+ import type { HistoryDagNode } from '../history-dag.js'
6
+ import { EMPTY_FACT_VALUE, historyDagFromNodes } from '../history-dag.js'
7
+ import { mutations, toEventNodes } from './mutation-fixtures.js'
8
+
9
+ const customStringify = (value: any): string => {
10
+ if (value === null) {
11
+ return 'null'
12
+ }
13
+ const type = typeof value
14
+
15
+ if (type === 'string') {
16
+ return JSON.stringify(value)
17
+ }
18
+ if (type === 'number' || type === 'boolean') {
19
+ return String(value)
20
+ }
21
+ if (Array.isArray(value)) {
22
+ const elements = value.map((el) => customStringify(el))
23
+ return `[${elements.join(', ')}]`
24
+ }
25
+ if (value instanceof Set) {
26
+ const elements = Array.from(value).map((el) => customStringify(el))
27
+ return `[${elements.join(', ')}]`
28
+ }
29
+ if (value instanceof Map) {
30
+ const keys = Array.from(value.keys()).map(customStringify).join(', ')
31
+ return `[${keys}]`
32
+ }
33
+ if (type === 'object') {
34
+ const entries = Object.keys(value).map((key) => {
35
+ const val = value[key]
36
+ const valStr =
37
+ key === 'facts'
38
+ ? `"${factsToString(val)}"`
39
+ : (key === 'id' || key === 'parentId') && Object.keys(val).length === 2 && val.local === 0
40
+ ? val.global
41
+ : customStringify(val)
42
+
43
+ return `${key}: ${valStr}`
44
+ })
45
+ return `{ ${entries.join(', ')} }`
46
+ }
47
+ return String(value)
48
+ }
49
+
50
+ const factsToString = (facts: HistoryDagNode['factsGroup']) =>
51
+ [
52
+ factsSetToString(facts.depRequire, '↖'),
53
+ factsSetToString(facts.depRead, '?'),
54
+ factsSetToString(facts.modifySet, '+'),
55
+ factsSetToString(facts.modifyUnset, '-'),
56
+ ]
57
+ .flat()
58
+ .join(' ')
59
+
60
+ const factsSetToString = (facts: MutationEventFacts, prefix: string) =>
61
+ Array.from(facts.entries()).map(([key, value]) => prefix + key + (value === EMPTY_FACT_VALUE ? '' : `=${value}`))
62
+
63
+ export const customSerializer = {
64
+ test: (val: unknown) => Array.isArray(val),
65
+ print: (val: unknown[], _serialize: (item: unknown) => string) => {
66
+ return '[\n' + (val as any[]).map((item) => ' ' + customStringify(item)).join('\n') + '\n]'
67
+ },
68
+ } as any
69
+
70
+ expect.addSnapshotSerializer(customSerializer)
71
+
72
+ const compact = (events: any[]) => {
73
+ const dag = historyDagFromNodes(toEventNodes(events, mutations))
74
+ const compacted = compactEvents(dag)
75
+
76
+ return Array.from(compacted.dag.nodeEntries())
77
+ .map((_) => _.attributes)
78
+ .map(({ factsGroup, ...rest }) => ({ ...rest, facts: factsGroup }))
79
+ .slice(1)
80
+ }
81
+
82
+ describe('compactEvents todo app', () => {
83
+ it('completeTodo', () => {
84
+ const expected = compact([
85
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
86
+ mutations.completeTodo({ id: 'A' }), // 1
87
+ mutations.completeTodo({ id: 'A' }), // 2
88
+ ])
89
+
90
+ expect(expected).toMatchInlineSnapshot(`
91
+ [
92
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
93
+ { id: 2, parentId: 0, mutation: "completeTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-completed-A=true" }
94
+ ]
95
+ `)
96
+ })
97
+
98
+ it('toggleTodo', () => {
99
+ const expected = compact([
100
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
101
+ mutations.toggleTodo({ id: 'A' }), // 1
102
+ mutations.toggleTodo({ id: 'A' }), // 2
103
+ mutations.toggleTodo({ id: 'A' }), // 3
104
+ ])
105
+
106
+ expect(expected).toMatchInlineSnapshot(`
107
+ [
108
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
109
+ { id: 1, parentId: 0, mutation: "toggleTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ?todo-completed-A +todo-completed-A=true" }
110
+ { id: 2, parentId: 1, mutation: "toggleTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ?todo-completed-A +todo-completed-A=false" }
111
+ { id: 3, parentId: 2, mutation: "toggleTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ?todo-completed-A +todo-completed-A=true" }
112
+ ]
113
+ `)
114
+ })
115
+
116
+ it('completeTodo / toggleTodo', () => {
117
+ const expected = compact([
118
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
119
+ mutations.toggleTodo({ id: 'A' }), // 1
120
+ mutations.toggleTodo({ id: 'A' }), // 2
121
+ mutations.completeTodo({ id: 'A' }), // 3
122
+ mutations.completeTodo({ id: 'A' }), // 4
123
+ mutations.toggleTodo({ id: 'A' }), // 5
124
+ ])
125
+
126
+ expect(expected).toMatchInlineSnapshot(`
127
+ [
128
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
129
+ { id: 4, parentId: 0, mutation: "completeTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-completed-A=true" }
130
+ { id: 5, parentId: 4, mutation: "toggleTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ?todo-completed-A +todo-completed-A=false" }
131
+ ]
132
+ `)
133
+ })
134
+
135
+ it('readonly setTextTodo', () => {
136
+ const expected = compact([
137
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
138
+ mutations.setReadonlyTodo({ id: 'A', readonly: false }), // 1
139
+ mutations.setTextTodo({ id: 'A', text: 'buy soy milk' }), // 2
140
+ mutations.setReadonlyTodo({ id: 'A', readonly: true }), // 3
141
+ ])
142
+
143
+ expect(expected).toMatchInlineSnapshot(`
144
+ [
145
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
146
+ { id: 1, parentId: 0, mutation: "setReadonlyTodo", args: { id: "A", readonly: false }, facts: "↖todo-exists-A +todo-is-writeable-A=true" }
147
+ { id: 2, parentId: 1, mutation: "setTextTodo", args: { id: "A", text: "buy soy milk" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-text-updated-A" }
148
+ { id: 3, parentId: 2, mutation: "setReadonlyTodo", args: { id: "A", readonly: true }, facts: "↖todo-exists-A +todo-is-writeable-A=false" }
149
+ ]
150
+ `)
151
+ })
152
+
153
+ it('readonly setTextTodo 2', () => {
154
+ const expected = compact([
155
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
156
+ mutations.setReadonlyTodo({ id: 'A', readonly: false }), // 1
157
+ mutations.completeTodo({ id: 'A' }), // 2
158
+ mutations.setTextTodo({ id: 'A', text: 'buy soy milk' }), // 3
159
+ mutations.setReadonlyTodo({ id: 'A', readonly: true }), // 4
160
+ ])
161
+
162
+ expect(expected).toMatchInlineSnapshot(`
163
+ [
164
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
165
+ { id: 1, parentId: 0, mutation: "setReadonlyTodo", args: { id: "A", readonly: false }, facts: "↖todo-exists-A +todo-is-writeable-A=true" }
166
+ { id: 2, parentId: 1, mutation: "completeTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-completed-A=true" }
167
+ { id: 3, parentId: 2, mutation: "setTextTodo", args: { id: "A", text: "buy soy milk" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-text-updated-A" }
168
+ { id: 4, parentId: 3, mutation: "setReadonlyTodo", args: { id: "A", readonly: true }, facts: "↖todo-exists-A +todo-is-writeable-A=false" }
169
+ ]
170
+ `)
171
+ })
172
+
173
+ it('readonly setTextTodo - should fail', () => {
174
+ const expected = () =>
175
+ compact([
176
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
177
+ mutations.setReadonlyTodo({ id: 'A', readonly: false }), // 1
178
+ mutations.setTextTodo({ id: 'A', text: 'buy soy milk' }), // 2
179
+ mutations.setReadonlyTodo({ id: 'A', readonly: true }), // 3
180
+ mutations.setTextTodo({ id: 'A', text: 'buy oat milk' }), // 4
181
+ ])
182
+
183
+ expect(expected).toThrowErrorMatchingInlineSnapshot(`
184
+ [Error: Mutation setTextTodo requires facts that have not been set yet.
185
+ Requires: todo-exists-A, todo-is-writeable-A=true
186
+ Facts Snapshot: todo-exists-A, todo-is-writeable-A=false, todo-completed-A=false, todo-text-updated-A]
187
+ `)
188
+ })
189
+
190
+ it('completeTodos', () => {
191
+ const expected = compact([
192
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
193
+ mutations.createTodo({ id: 'B', text: 'buy bread' }), // 1
194
+ mutations.createTodo({ id: 'C', text: 'buy cheese' }), // 2
195
+ mutations.completeTodos({ ids: ['A', 'B', 'C'] }), // 3
196
+ mutations.toggleTodo({ id: 'A' }), // 4
197
+ mutations.completeTodo({ id: 'A' }), // 5
198
+ ])
199
+
200
+ expect(expected).toMatchInlineSnapshot(`
201
+ [
202
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
203
+ { id: 1, parentId: 0, mutation: "createTodo", args: { id: "B", text: "buy bread" }, facts: "+todo-exists-B +todo-is-writeable-B=true +todo-completed-B=false" }
204
+ { id: 2, parentId: 1, mutation: "createTodo", args: { id: "C", text: "buy cheese" }, facts: "+todo-exists-C +todo-is-writeable-C=true +todo-completed-C=false" }
205
+ { id: 3, parentId: 2, mutation: "completeTodos", args: { ids: ["A", "B", "C"] }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ↖todo-exists-B ↖todo-is-writeable-B=true ↖todo-exists-C ↖todo-is-writeable-C=true +todo-completed-A=true +todo-completed-B=true +todo-completed-C=true" }
206
+ { id: 5, parentId: 3, mutation: "completeTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-completed-A=true" }
207
+ ]
208
+ `)
209
+ })
210
+
211
+ it('completeTodos 2', () => {
212
+ const expected = compact([
213
+ mutations.createTodo({ id: 'A', text: 'buy milk' }), // 0
214
+ mutations.createTodo({ id: 'B', text: 'buy bread' }), // 1
215
+ mutations.createTodo({ id: 'C', text: 'buy cheese' }), // 2
216
+ mutations.toggleTodo({ id: 'A' }), // 3
217
+ mutations.completeTodos({ ids: ['A', 'B', 'C'] }), // 4
218
+ mutations.toggleTodo({ id: 'A' }), // 5
219
+ mutations.completeTodo({ id: 'A' }), // 6
220
+ ])
221
+
222
+ expect(expected).toMatchInlineSnapshot(`
223
+ [
224
+ { id: 0, parentId: -1, mutation: "createTodo", args: { id: "A", text: "buy milk" }, facts: "+todo-exists-A +todo-is-writeable-A=true +todo-completed-A=false" }
225
+ { id: 1, parentId: 0, mutation: "createTodo", args: { id: "B", text: "buy bread" }, facts: "+todo-exists-B +todo-is-writeable-B=true +todo-completed-B=false" }
226
+ { id: 2, parentId: 1, mutation: "createTodo", args: { id: "C", text: "buy cheese" }, facts: "+todo-exists-C +todo-is-writeable-C=true +todo-completed-C=false" }
227
+ { id: 4, parentId: 2, mutation: "completeTodos", args: { ids: ["A", "B", "C"] }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true ↖todo-exists-B ↖todo-is-writeable-B=true ↖todo-exists-C ↖todo-is-writeable-C=true +todo-completed-A=true +todo-completed-B=true +todo-completed-C=true" }
228
+ { id: 6, parentId: 4, mutation: "completeTodo", args: { id: "A" }, facts: "↖todo-exists-A ↖todo-is-writeable-A=true +todo-completed-A=true" }
229
+ ]
230
+ `)
231
+ })
232
+ })
@@ -0,0 +1 @@
1
+ export * from './mutation-fixtures.js'
@@ -0,0 +1,230 @@
1
+ import { Schema } from '@livestore/utils/effect'
2
+
3
+ import { type EventId, ROOT_ID } from '../../../adapter-types.js'
4
+ import type { MutationDef } from '../../../schema/mutations.js'
5
+ import { defineFacts, defineMutation } from '../../../schema/mutations.js'
6
+ import { factsSnapshotForDag, getFactsGroupForMutationArgs } from '../facts.js'
7
+ import type { HistoryDagNode } from '../history-dag.js'
8
+ import { historyDagFromNodes, rootEventNode } from '../history-dag.js'
9
+
10
+ /** Used for conflict detection and event history compaction */
11
+ export const facts = defineFacts({
12
+ todoExists: (id: string) => `todo-exists-${id}`,
13
+ todoIsWriteable: (id: string, writeable: boolean) => [`todo-is-writeable-${id}`, writeable],
14
+ todoCompleted: (id: string, completed: boolean) => [`todo-completed-${id}`, completed],
15
+ todoTextUpdated: (id: string) => `todo-text-updated-${id}`,
16
+ inputValue: (id: string) => `input-value-${id}`,
17
+ })
18
+
19
+ export const mutations = {
20
+ createTodo: defineMutation(
21
+ 'createTodo',
22
+ Schema.Struct({ id: Schema.String, text: Schema.String }),
23
+ 'INSERT INTO todos (id, text) VALUES ($id, $text)',
24
+ {
25
+ facts: ({ id }) => ({
26
+ modify: {
27
+ set: [facts.todoExists(id), facts.todoIsWriteable(id, true), facts.todoCompleted(id, false)],
28
+ },
29
+ }),
30
+ },
31
+ ),
32
+ upsertTodo: defineMutation(
33
+ 'upsertTodo',
34
+ Schema.Struct({ id: Schema.String, text: Schema.optional(Schema.String) }),
35
+ 'INSERT INTO todos (id, text) VALUES ($id, $text) ON CONFLICT (id) DO UPDATE SET text = $text',
36
+ {
37
+ facts: ({ id }, currentFacts) =>
38
+ // TODO enable an API along the lines of `map.has(key, value)`
39
+ currentFacts.has(facts.todoExists(id)) && currentFacts.get(facts.todoIsWriteable(id, true)[0]) === false
40
+ ? { require: [facts.todoExists(id), facts.todoIsWriteable(id, true)] }
41
+ : { modify: { set: [facts.todoExists(id), facts.todoIsWriteable(id, true), facts.todoTextUpdated(id)] } },
42
+ },
43
+ ),
44
+ completeTodo: defineMutation(
45
+ 'completeTodo',
46
+ Schema.Struct({ id: Schema.String }),
47
+ // consider `RETURNING` to validate before applying facts
48
+ 'UPDATE todos SET completed = true WHERE id = $id',
49
+ {
50
+ // prewrite assertions from DB
51
+ // enables more concurrency
52
+ // turning database inside out
53
+ // similar to upsert semantics
54
+ facts: ({ id }) => ({
55
+ require: [facts.todoExists(id), facts.todoIsWriteable(id, true)],
56
+ modify: { set: [facts.todoCompleted(id, true)] },
57
+ }),
58
+ },
59
+ ),
60
+ uncompleteTodo: defineMutation(
61
+ 'uncompleteTodo',
62
+ Schema.Struct({ id: Schema.String }),
63
+ 'UPDATE todos SET completed = false WHERE id = $id',
64
+ {
65
+ facts: ({ id }) => ({
66
+ require: [facts.todoExists(id), facts.todoIsWriteable(id, true)],
67
+ modify: { set: [facts.todoCompleted(id, false)] },
68
+ }),
69
+ },
70
+ ),
71
+ completeTodos: defineMutation(
72
+ 'completeTodos',
73
+ Schema.Struct({ ids: Schema.Array(Schema.String) }),
74
+ 'UPDATE todos SET completed = true WHERE id IN ($ids:csv)',
75
+ {
76
+ facts: ({ ids }) => ({
77
+ require: ids.flatMap((id) => [facts.todoExists(id), facts.todoIsWriteable(id, true)]),
78
+ modify: { set: ids.map((id) => facts.todoCompleted(id, true)) },
79
+ }),
80
+ },
81
+ ),
82
+ toggleTodo: defineMutation(
83
+ 'toggleTodo',
84
+ Schema.Struct({ id: Schema.String }),
85
+ 'UPDATE todos SET completed = NOT completed WHERE id = $id',
86
+ {
87
+ facts: ({ id }, currentFacts) => {
88
+ const currentIsCompleted = currentFacts.get(facts.todoCompleted(id, true)[0]) === true
89
+ return {
90
+ require: [facts.todoExists(id), facts.todoIsWriteable(id, true)],
91
+ modify: {
92
+ // remove: [facts.todoCompleted(id, currentIsCompleted)],
93
+ set: [facts.todoCompleted(id, !currentIsCompleted)],
94
+ },
95
+ }
96
+ },
97
+ },
98
+ ),
99
+ setReadonlyTodo: defineMutation(
100
+ 'setReadonlyTodo',
101
+ Schema.Struct({ id: Schema.String, readonly: Schema.Boolean }),
102
+ 'UPDATE todos SET readonly = $readonly WHERE id = $id',
103
+ {
104
+ facts: ({ id, readonly }) => ({
105
+ require: [facts.todoExists(id)],
106
+ modify: { set: [facts.todoIsWriteable(id, !readonly)] },
107
+ }),
108
+ },
109
+ ),
110
+ setTextTodo: defineMutation(
111
+ 'setTextTodo',
112
+ Schema.Struct({ id: Schema.String, text: Schema.String }),
113
+ 'UPDATE todos SET text = $text WHERE id = $id',
114
+ {
115
+ facts: ({ id }) => ({
116
+ require: [facts.todoExists(id), facts.todoIsWriteable(id, true)],
117
+ modify: { set: [facts.todoTextUpdated(id)] },
118
+ }),
119
+ },
120
+ ),
121
+ setInputValue: defineMutation(
122
+ 'setInputValue',
123
+ Schema.Struct({ id: Schema.String, text: Schema.String }),
124
+ 'UPDATE todos SET text = $text WHERE id = $id',
125
+ {
126
+ localOnly: true,
127
+ facts: ({ id }) => ({ modify: { set: [facts.inputValue(id)] } }),
128
+ },
129
+ ),
130
+ }
131
+
132
+ export type PartialEvent = { mutation: string; args: any }
133
+
134
+ export const toEventNodes = (
135
+ partialEvents: PartialEvent[],
136
+ mutationDefs: Record<string, MutationDef.Any>,
137
+ ): HistoryDagNode[] => {
138
+ const nodesAcc: HistoryDagNode[] = [rootEventNode]
139
+
140
+ let currentEventId: EventId = ROOT_ID
141
+
142
+ const getNextEventId = (mutationDef: MutationDef.Any): EventId => {
143
+ if (mutationDef.options.localOnly) {
144
+ return { global: currentEventId.global, local: currentEventId.local + 1 }
145
+ }
146
+ return { global: currentEventId.global + 1, local: 0 }
147
+ }
148
+
149
+ const eventNodes = partialEvents.map((partialEvent) => {
150
+ const mutationDef = mutationDefs[partialEvent.mutation]!
151
+ const eventId = getNextEventId(mutationDef)
152
+ currentEventId = eventId
153
+
154
+ const factsSnapshot = factsSnapshotForDag(historyDagFromNodes(nodesAcc, { skipFactsCheck: true }), undefined)
155
+ // console.log('factsSnapshot', eventId, factsSnapshot)
156
+ // const depRead: MutationEventFactsSnapshot = new Map<string, any>()
157
+ // const factsSnapshotProxy = new Proxy(factsSnapshot, {
158
+ // get: (target, prop) => {
159
+ // if (prop === 'has') {
160
+ // return (key: string) => {
161
+ // depRead.set(key, EMPTY_FACT_VALUE)
162
+ // return target.has(key)
163
+ // }
164
+ // } else if (prop === 'get') {
165
+ // return (key: string) => {
166
+ // depRead.set(key, EMPTY_FACT_VALUE)
167
+ // return target.get(key)
168
+ // }
169
+ // }
170
+
171
+ // notYetImplemented(`toEventNodes: ${prop.toString()} is not yet implemented`)
172
+ // },
173
+ // })
174
+
175
+ // const factsRes = mutationDef.options.facts?.(partialEvent.args, factsSnapshotProxy)
176
+ // console.log('factsRes', factsRes?.modify, factsRes?.require)
177
+ // const iterableToMap = (iterable: Iterable<MutationEventFactInput>) => {
178
+ // const map = new Map()
179
+ // for (const item of iterable) {
180
+ // if (typeof item === 'string') {
181
+ // map.set(item, EMPTY_FACT_VALUE)
182
+ // } else {
183
+ // map.set(item[0], item[1])
184
+ // }
185
+ // }
186
+ // return map
187
+ // }
188
+ // const facts = {
189
+ // modifyAdd: factsRes?.modify.add ? iterableToMap(factsRes.modify.add) : new Map(),
190
+ // modifyRemove: factsRes?.modify.remove ? iterableToMap(factsRes.modify.remove) : new Map(),
191
+ // depRequire: factsRes?.require ? iterableToMap(factsRes.require) : new Map(),
192
+ // depRead,
193
+ // } satisfies MutationEventFactsGroup
194
+
195
+ // applyFactGroup(facts, factsSnapshot)
196
+
197
+ const facts = getFactsGroupForMutationArgs({
198
+ factsCallback: mutationDef.options.facts,
199
+ args: partialEvent.args,
200
+ currentFacts: factsSnapshot,
201
+ })
202
+
203
+ const node = {
204
+ id: eventId,
205
+ parentId: getParentId(eventId),
206
+ mutation: partialEvent.mutation,
207
+ args: partialEvent.args,
208
+ factsGroup: facts,
209
+ } satisfies HistoryDagNode
210
+ nodesAcc.push(node)
211
+ return node
212
+ })
213
+
214
+ eventNodes.unshift(rootEventNode as never)
215
+
216
+ // console.log('eventNodes', eventNodes)
217
+
218
+ return eventNodes
219
+ }
220
+
221
+ const getParentId = (eventId: EventId): EventId => {
222
+ const globalParentId = eventId.global
223
+ const localParentId = eventId.local - 1
224
+
225
+ if (localParentId < 0) {
226
+ return { global: globalParentId - 1, local: 0 }
227
+ }
228
+
229
+ return { global: globalParentId, local: localParentId }
230
+ }