@mblaney/holster 1.0.34 → 1.0.36

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