@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.
- package/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/dist/holster.js +4 -0
- package/dist/holster.js.map +7 -0
- package/package.json +22 -5
- package/src/dup.js +4 -5
- package/src/get.js +10 -19
- package/src/ham.js +22 -49
- package/src/holster.js +172 -58
- package/src/index.js +1 -1
- package/src/radisk.js +89 -86
- package/src/sea-utils.js +1 -1
- package/src/sea.js +0 -52
- package/src/store.js +8 -8
- package/src/user.js +0 -4
- package/src/wire.js +209 -79
- package/ts/src/array.ts +78 -0
- package/ts/src/buffer.ts +441 -0
- package/ts/src/dup.ts +38 -0
- package/ts/src/get.ts +83 -0
- package/ts/src/ham.ts +258 -0
- package/ts/src/holster.ts +968 -0
- package/ts/src/index.ts +35 -0
- package/ts/src/radisk.ts +591 -0
- package/ts/src/radix.ts +157 -0
- package/ts/src/schemas.ts +515 -0
- package/ts/src/sea-utils.ts +103 -0
- package/ts/src/sea.ts +388 -0
- package/ts/src/store.ts +321 -0
- package/ts/src/user.ts +377 -0
- package/ts/src/utils.ts +232 -0
- package/ts/src/wire.ts +961 -0
- package/.dockerignore +0 -9
- package/.github/workflows/node.js.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -33
- package/Dockerfile +0 -20
- package/examples/index.html +0 -33
- package/examples/index.js +0 -49
- package/examples/user.html +0 -31
- package/examples/user.js +0 -93
- package/examples/wire.html +0 -27
- package/examples/wire.js +0 -55
- package/test/holster.get.test.js +0 -94
- package/test/holster.lex.test.js +0 -196
- package/test/holster.off.test.js +0 -53
- package/test/holster.on.test.js +0 -264
- package/test/holster.put.test.js +0 -358
- package/test/holster.secure.test.js +0 -202
- package/test/holster.user.get.test.js +0 -114
- package/test/holster.user.lex.test.js +0 -149
- package/test/holster.user.off.test.js +0 -73
- package/test/holster.user.on.test.js +0 -300
- package/test/holster.user.put.test.js +0 -463
- package/test/kitty/! +0 -9
- package/test/names.js +0 -225
- package/test/radisk.cache.test.js +0 -213
- package/test/radisk.test.js +0 -331
- package/test/radix.test.js +0 -117
- package/test/sea.test.js +0 -284
- package/test/split.test.js +0 -317
- package/test/store.test.js +0 -316
- package/test/system/basic.test.js +0 -29
- package/test/system/chained-on.test.js +0 -41
- package/test/system/concurrent-listeners.test.js +0 -125
- package/test/system/immediate-read.test.js +0 -30
- package/test/system/listener-before-data.test.js +0 -51
- package/test/system/promise-wrapper.test.js +0 -47
- package/test/system/user-chained-on.test.js +0 -60
- package/test/system/user-concurrent-listeners.test.js +0 -142
- package/test/system/user-listener-before-data.test.js +0 -66
- package/test/user.test.js +0 -173
- 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
|
+
|