@mblaney/holster 1.0.35 → 2.0.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.
Files changed (72) hide show
  1. package/build/holster.js +3 -3
  2. package/build/holster.js.map +3 -3
  3. package/dist/holster.js +4 -0
  4. package/dist/holster.js.map +7 -0
  5. package/package.json +22 -5
  6. package/src/dup.js +4 -5
  7. package/src/get.js +10 -19
  8. package/src/ham.js +22 -49
  9. package/src/holster.js +172 -58
  10. package/src/index.js +1 -1
  11. package/src/radisk.js +89 -86
  12. package/src/sea-utils.js +1 -1
  13. package/src/sea.js +0 -52
  14. package/src/store.js +8 -8
  15. package/src/user.js +0 -4
  16. package/src/wire.js +209 -79
  17. package/ts/src/array.ts +78 -0
  18. package/ts/src/buffer.ts +441 -0
  19. package/ts/src/dup.ts +38 -0
  20. package/ts/src/get.ts +83 -0
  21. package/ts/src/ham.ts +258 -0
  22. package/ts/src/holster.ts +968 -0
  23. package/ts/src/index.ts +35 -0
  24. package/ts/src/radisk.ts +591 -0
  25. package/ts/src/radix.ts +157 -0
  26. package/ts/src/schemas.ts +515 -0
  27. package/ts/src/sea-utils.ts +103 -0
  28. package/ts/src/sea.ts +388 -0
  29. package/ts/src/store.ts +321 -0
  30. package/ts/src/user.ts +377 -0
  31. package/ts/src/utils.ts +232 -0
  32. package/ts/src/wire.ts +961 -0
  33. package/.dockerignore +0 -9
  34. package/.github/workflows/node.js.yml +0 -31
  35. package/.github/workflows/npm-publish.yml +0 -33
  36. package/Dockerfile +0 -20
  37. package/examples/index.html +0 -33
  38. package/examples/index.js +0 -49
  39. package/examples/user.html +0 -31
  40. package/examples/user.js +0 -93
  41. package/examples/wire.html +0 -27
  42. package/examples/wire.js +0 -55
  43. package/test/holster.get.test.js +0 -94
  44. package/test/holster.lex.test.js +0 -196
  45. package/test/holster.off.test.js +0 -53
  46. package/test/holster.on.test.js +0 -264
  47. package/test/holster.put.test.js +0 -358
  48. package/test/holster.secure.test.js +0 -202
  49. package/test/holster.user.get.test.js +0 -114
  50. package/test/holster.user.lex.test.js +0 -149
  51. package/test/holster.user.off.test.js +0 -73
  52. package/test/holster.user.on.test.js +0 -300
  53. package/test/holster.user.put.test.js +0 -463
  54. package/test/kitty/! +0 -9
  55. package/test/names.js +0 -225
  56. package/test/radisk.cache.test.js +0 -213
  57. package/test/radisk.test.js +0 -331
  58. package/test/radix.test.js +0 -117
  59. package/test/sea.test.js +0 -284
  60. package/test/split.test.js +0 -317
  61. package/test/store.test.js +0 -316
  62. package/test/system/basic.test.js +0 -29
  63. package/test/system/chained-on.test.js +0 -41
  64. package/test/system/concurrent-listeners.test.js +0 -125
  65. package/test/system/immediate-read.test.js +0 -30
  66. package/test/system/listener-before-data.test.js +0 -51
  67. package/test/system/promise-wrapper.test.js +0 -47
  68. package/test/system/user-chained-on.test.js +0 -60
  69. package/test/system/user-concurrent-listeners.test.js +0 -142
  70. package/test/system/user-listener-before-data.test.js +0 -66
  71. package/test/user.test.js +0 -173
  72. package/test/wire.test.js +0 -158
package/ts/src/ham.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * HAM - Hypothetical Amnesia Machine
3
+ * Conflict resolution algorithm for distributed data synchronization
4
+ */
5
+
6
+ import * as utils from "./utils.ts"
7
+ import SEA from "./sea.ts"
8
+ import type {
9
+ Graph,
10
+ GraphNode,
11
+ GraphValue,
12
+ HamResult,
13
+ HamMixResult,
14
+ ListenMap,
15
+ Timestamp,
16
+ } from "./schemas.ts"
17
+
18
+ // Maximum number of souls to keep in memory
19
+ const MAX_GRAPH_SIZE = 10000
20
+
21
+ /**
22
+ * Compare incoming state with current state to determine which value wins
23
+ * @param state - Incoming timestamp
24
+ * @param currentState - Current timestamp
25
+ * @param value - Incoming value
26
+ * @param currentValue - Current value
27
+ * @param signed - Whether the update has a verified signature
28
+ */
29
+ const Ham = (
30
+ state: Timestamp,
31
+ currentState: Timestamp,
32
+ value: GraphValue,
33
+ currentValue: GraphValue,
34
+ signed = false
35
+ ): HamResult => {
36
+ if (state < currentState) return { historical: true }
37
+
38
+ if (state > currentState) return { incoming: true }
39
+
40
+ // state is equal to currentState. Old data is expected to propagate through
41
+ // the network, but conflicting values with same signed timestamp are ignored
42
+ // since the owner can only create one value per timestamp.
43
+ if (signed && value !== currentValue) {
44
+ return { historical: true }
45
+ }
46
+
47
+ // Lexically compare to resolve conflict (for unsigned or matching values)
48
+ let valueStr = typeof value !== "string" ? JSON.stringify(value) || "" : value
49
+ let currentValueStr =
50
+ typeof currentValue !== "string"
51
+ ? JSON.stringify(currentValue) || ""
52
+ : currentValue
53
+
54
+ // No update required
55
+ if (valueStr === currentValueStr) return { state: true }
56
+
57
+ // Keep the current value
58
+ if (valueStr < currentValueStr) return { current: true }
59
+
60
+ // Otherwise update using the incoming value
61
+ return { incoming: true }
62
+ }
63
+
64
+ /**
65
+ * Mix incoming changes into the graph with conflict resolution
66
+ */
67
+ Ham.mix = async (
68
+ change: Graph,
69
+ graph: Graph,
70
+ hasNode: Set<string> | undefined,
71
+ secure: boolean,
72
+ listen: ListenMap
73
+ ): Promise<HamMixResult> => {
74
+ if (!change || typeof change !== "object") {
75
+ throw new TypeError("change must be an object")
76
+ }
77
+ if (!graph || typeof graph !== "object") {
78
+ throw new TypeError("graph must be an object")
79
+ }
80
+ if (!listen || typeof listen !== "object") {
81
+ throw new TypeError("listen must be an object")
82
+ }
83
+
84
+ const machine = Date.now()
85
+ const now: Graph = {}
86
+ const defer: Graph = {}
87
+ const listeners: Array<() => void> = []
88
+ const validProperties = new Map<string, Set<string>>()
89
+ const validTimestamps = new Map<string, Set<number>>()
90
+ let wait = 0
91
+
92
+ for (const soul of Object.keys(change)) {
93
+ const node = change[soul]
94
+ if (!node || !node._) continue
95
+
96
+ let updated = false
97
+ let alias = false
98
+ let verify = secure
99
+
100
+ const pub = node[utils.userPublicKey] as string | undefined
101
+ // If timestamp signatures and public key are provided then always verify
102
+ if (node._["s"] && pub) verify = true
103
+
104
+ // Special case if soul starts with "~". Node must be system data ie,
105
+ // ~@alias or ~publickey. For aliases, key and value must be a self
106
+ // identifying rel. For public keys, data needs to be signed and verified.
107
+ if (soul.length > 1 && soul[0] === "~") {
108
+ if (soul[1] === "@") {
109
+ alias = true
110
+ verify = false
111
+ } else {
112
+ if (pub && soul !== "~" + pub) {
113
+ console.log(`error public key does not match for soul: ${soul}`)
114
+ continue
115
+ }
116
+ verify = true
117
+ }
118
+ }
119
+
120
+ if (verify) {
121
+ if (!pub || !node._) {
122
+ continue
123
+ }
124
+
125
+ const signedTimestamps = new Set<number>()
126
+ const signedProperties = new Set<string>()
127
+
128
+ const incomingTimestamps = Object.entries(node._[">"] || {})
129
+
130
+ for (const [key, sig] of Object.entries(node._["s"] || {})) {
131
+ const asNumber = Number(key)
132
+ if (!isNaN(asNumber) && asNumber > 0) {
133
+ const propsForTimestamp = incomingTimestamps.filter(
134
+ ([, ts]) => Number(ts) === asNumber,
135
+ )
136
+ const allNewerInGraph =
137
+ propsForTimestamp.length > 0 &&
138
+ propsForTimestamp.every(([prop]) => {
139
+ const graphState =
140
+ graph[soul] && graph[soul]!._[">"] ? graph[soul]!._[">"][prop] : 0
141
+ return graphState !== undefined && graphState > asNumber
142
+ })
143
+
144
+ if (allNewerInGraph) {
145
+ continue
146
+ }
147
+
148
+ const isValid = await SEA.verifyTimestamp(asNumber, sig as string, pub)
149
+ if (isValid) {
150
+ signedTimestamps.add(asNumber)
151
+ for (const [prop, ts] of incomingTimestamps) {
152
+ if (Number(ts) === asNumber) {
153
+ signedProperties.add(prop)
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ if (signedProperties.size === 0) {
161
+ continue
162
+ }
163
+
164
+ validProperties.set(soul, signedProperties)
165
+ validTimestamps.set(soul, signedTimestamps)
166
+ }
167
+
168
+ for (const key of Object.keys(node)) {
169
+ if (key === "_") continue
170
+
171
+ if (validProperties.has(soul) && !validProperties.get(soul)!.has(key)) {
172
+ continue
173
+ }
174
+
175
+ const value = node[key]!
176
+ const state = (node._ && node._[">"] ? node._[">"][key] : 0) || 0
177
+ const currentValue = graph[soul] ? graph[soul][key] : undefined
178
+ const currentState = graph[soul] ? (graph[soul]._[">"][key] || 0) : 0
179
+
180
+ if (alias && key !== utils.rel.is(value)) {
181
+ console.log(`error alias ${alias}: ${key} !== ${utils.rel.is(value)}`)
182
+ continue
183
+ }
184
+
185
+ const skew = state - machine
186
+ if (skew > 0) {
187
+ if (skew > 86400000) continue
188
+
189
+ if (wait === 0 || skew < wait) wait = skew
190
+ if (!defer[soul]) {
191
+ const stateVector: Record<string, number> = {}
192
+ const signatures: Record<string, string> | undefined = {}
193
+ defer[soul] = { _: { "#": soul, ">": stateVector, s: signatures } } as GraphNode
194
+ }
195
+ defer[soul]![key] = value
196
+ defer[soul]!._[">"][key] = state
197
+ if (node._["s"] && node._["s"][state]) {
198
+ defer[soul]!._["s"]![state] = node._["s"][state]
199
+ }
200
+ } else {
201
+ const isSigned =
202
+ validTimestamps.has(soul) && validTimestamps.get(soul)!.has(state)
203
+ const result = Ham(state, currentState, value, currentValue ?? null, isSigned)
204
+ if (result.incoming) {
205
+ if (!now[soul]) {
206
+ const stateVector1: Record<string, number> = {}
207
+ const signatures1: Record<string, string> | undefined = {}
208
+ now[soul] = { _: { "#": soul, ">": stateVector1, s: signatures1 } } as GraphNode
209
+ }
210
+ if (!graph[soul]) {
211
+ const stateVector2: Record<string, number> = {}
212
+ const signatures2: Record<string, string> | undefined = {}
213
+ graph[soul] = { _: { "#": soul, ">": stateVector2, s: signatures2 } } as GraphNode
214
+ }
215
+ graph[soul]![key] = now[soul]![key] = value
216
+ graph[soul]!._[">"][key] = now[soul]!._[">"][key] = state
217
+ if (node._["s"] && node._["s"][state]) {
218
+ graph[soul]!._["s"]![state] = now[soul]!._["s"]![state] = node._["s"][state]
219
+ }
220
+
221
+ if (listen[soul]) {
222
+ listen[soul]!
223
+ .filter(l => utils.match(l["."], key))
224
+ .forEach(l => listeners.push(l.cb as () => void))
225
+ }
226
+ updated = true
227
+ }
228
+ }
229
+ }
230
+
231
+ if (updated && listen[soul]) {
232
+ listen[soul]!
233
+ .filter(l => utils.match(l["."]))
234
+ .forEach(l => listeners.push(l.cb as () => void))
235
+ }
236
+ }
237
+
238
+ const souls = Object.keys(graph)
239
+ if (souls.length > MAX_GRAPH_SIZE) {
240
+ const soulsByAge = souls
241
+ .map(soul => {
242
+ const states = graph[soul]!._ && graph[soul]!._[">"]
243
+ const maxState = states ? Math.max(...Object.values(states)) : 0
244
+ return { soul, maxState }
245
+ })
246
+ .sort((a, b) => a.maxState - b.maxState)
247
+ const remove = soulsByAge.slice(0, souls.length - MAX_GRAPH_SIZE)
248
+ remove.forEach(({ soul }) => {
249
+ delete graph[soul]
250
+ if (hasNode) hasNode.delete(soul)
251
+ })
252
+ }
253
+
254
+ return { now, defer, wait, listeners }
255
+ }
256
+
257
+ export default Ham
258
+