@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

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 (266) hide show
  1. package/AGENTS.md +91 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2238 -144
  4. package/dist/index.js +18149 -2475
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/pure.d.ts +401 -0
  9. package/dist/pure.js +24 -0
  10. package/dist/sqlite-open.js +392 -0
  11. package/dist/sqlite-worker.d.ts +1 -0
  12. package/dist/sqlite-worker.js +439 -0
  13. package/dist/sw.d.ts +207 -0
  14. package/dist/sw.js +393 -0
  15. package/dist/tabs-broker-worker.d.ts +8 -0
  16. package/dist/tabs-broker-worker.js +477 -0
  17. package/dist/types.d.ts +2082 -39
  18. package/dist/worker-edges.js +498 -0
  19. package/dist/worker-impersonation.js +60 -0
  20. package/dist/worker-ref-tables.d.ts +48 -0
  21. package/dist/worker-types.d.ts +112 -0
  22. package/dist/worker-types.js +73 -0
  23. package/package.json +28 -7
  24. package/scripts/check-broker-bundle.mjs +33 -0
  25. package/scripts/check-worker-entries.mjs +173 -0
  26. package/skills/sp00ky-core/SKILL.md +42 -8
  27. package/skills/sp00ky-core/references/config.md +8 -2
  28. package/src/boot/auth-flip.saga.test.ts +174 -0
  29. package/src/boot/auth-flip.saga.ts +77 -0
  30. package/src/boot/boot.saga.test.ts +203 -0
  31. package/src/boot/boot.saga.ts +146 -0
  32. package/src/boot/bucket-switch.saga.test.ts +191 -0
  33. package/src/boot/bucket-switch.saga.ts +151 -0
  34. package/src/boot/preload.saga.test.ts +96 -0
  35. package/src/boot/preload.saga.ts +45 -0
  36. package/src/bucket-blurhash.test.ts +148 -0
  37. package/src/build-globals.d.ts +12 -0
  38. package/src/client/bucket-handle.test.ts +195 -0
  39. package/src/client/bucket-handle.ts +352 -0
  40. package/src/client/router.test.ts +59 -0
  41. package/src/client/router.ts +104 -0
  42. package/src/client/runtime.test.ts +319 -0
  43. package/src/client/runtime.ts +307 -0
  44. package/src/client/services.test.ts +74 -0
  45. package/src/client/services.ts +437 -0
  46. package/src/client/sp00ky-client.test.ts +428 -0
  47. package/src/client/sp00ky-client.ts +737 -0
  48. package/src/events/index.ts +3 -0
  49. package/src/index.ts +86 -1
  50. package/src/kernel/constants.test.ts +19 -0
  51. package/src/kernel/constants.ts +94 -0
  52. package/src/kernel/effects.test.ts +40 -0
  53. package/src/kernel/effects.ts +171 -0
  54. package/src/kernel/events.ts +60 -0
  55. package/src/kernel/interpreter.test.ts +116 -0
  56. package/src/kernel/interpreter.ts +148 -0
  57. package/src/kernel/saga.test.ts +89 -0
  58. package/src/kernel/saga.ts +73 -0
  59. package/src/live/index.test.ts +512 -0
  60. package/src/live/index.ts +1059 -0
  61. package/src/modules/app-release/index.test.ts +125 -0
  62. package/src/modules/app-release/index.ts +209 -0
  63. package/src/modules/auth/auth.local-first.test.ts +228 -0
  64. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  65. package/src/modules/auth/events/index.ts +7 -1
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.integration.test.ts +209 -0
  68. package/src/modules/auth/impersonation.test.ts +377 -0
  69. package/src/modules/auth/impersonation.ts +179 -0
  70. package/src/modules/auth/index.ts +538 -14
  71. package/src/modules/channel/channel.test.ts +752 -0
  72. package/src/modules/channel/index.ts +1153 -0
  73. package/src/modules/channel/webrtc.ts +472 -0
  74. package/src/modules/crdt/acceptance.ts +75 -0
  75. package/src/modules/crdt/bytes.ts +50 -0
  76. package/src/modules/crdt/crdt-field.ts +125 -0
  77. package/src/modules/crdt/crdt.test.ts +546 -0
  78. package/src/modules/crdt/hash.ts +103 -0
  79. package/src/modules/crdt/index.ts +821 -0
  80. package/src/modules/crdt/loro-loader.ts +25 -0
  81. package/src/modules/crdt/mirror.ts +37 -0
  82. package/src/modules/devtools/flags.ts +349 -0
  83. package/src/modules/devtools/index.ts +746 -93
  84. package/src/modules/devtools/mutations.test.ts +92 -0
  85. package/src/modules/devtools/mutations.ts +160 -0
  86. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  87. package/src/modules/devtools/state-shape.test.ts +263 -0
  88. package/src/modules/devtools/storage-info.test.ts +79 -0
  89. package/src/modules/devtools/storage-info.ts +168 -0
  90. package/src/modules/devtools/versions.test.ts +74 -0
  91. package/src/modules/devtools/versions.ts +110 -0
  92. package/src/modules/feature-flag/index.test.ts +298 -0
  93. package/src/modules/feature-flag/index.ts +316 -0
  94. package/src/modules/ref-tables.test.ts +93 -0
  95. package/src/modules/ref-tables.ts +91 -0
  96. package/src/modules/web-push/index.test.ts +824 -0
  97. package/src/modules/web-push/index.ts +1027 -0
  98. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  99. package/src/mutation/jobs.test.ts +25 -0
  100. package/src/mutation/jobs.ts +41 -0
  101. package/src/mutation/mutation-id.test.ts +37 -0
  102. package/src/mutation/mutation-id.ts +35 -0
  103. package/src/mutation/pending-writes.integration.test.ts +175 -0
  104. package/src/mutation/push.saga.test.ts +361 -0
  105. package/src/mutation/push.saga.ts +245 -0
  106. package/src/mutation/rows.test.ts +278 -0
  107. package/src/mutation/rows.ts +515 -0
  108. package/src/mutation/tray.saga.test.ts +63 -0
  109. package/src/mutation/tray.saga.ts +48 -0
  110. package/src/mutation/write.saga.test.ts +436 -0
  111. package/src/mutation/write.saga.ts +384 -0
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +200 -0
  126. package/src/query/materialize.saga.ts +102 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +514 -0
  130. package/src/query/membership.saga.ts +339 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +151 -56
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/index.ts +7 -0
  158. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  159. package/src/services/database/local-migrator.integration.test.ts +110 -0
  160. package/src/services/database/local-migrator.ts +54 -9
  161. package/src/services/database/local.test.ts +150 -0
  162. package/src/services/database/local.ts +503 -59
  163. package/src/services/database/plan-render.test.ts +159 -0
  164. package/src/services/database/plan-render.ts +108 -0
  165. package/src/services/database/relation-resolver.test.ts +484 -0
  166. package/src/services/database/relation-resolver.ts +0 -0
  167. package/src/services/database/remote.ts +283 -4
  168. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  169. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  170. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  171. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  172. package/src/services/database/sqlite-column-reset.ts +91 -0
  173. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  174. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  175. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  176. package/src/services/database/sqlite-lock-verify.ts +45 -0
  177. package/src/services/database/sqlite-open.test.ts +150 -0
  178. package/src/services/database/sqlite-open.ts +164 -0
  179. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.ts +196 -0
  181. package/src/services/database/sqlite-projection.test.ts +121 -0
  182. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  183. package/src/services/database/sqlite-select.test.ts +261 -0
  184. package/src/services/database/sqlite-select.ts +131 -0
  185. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  186. package/src/services/database/sqlite-transport.ts +224 -0
  187. package/src/services/database/sqlite-worker.ts +437 -0
  188. package/src/services/database/surql-translate.ts +417 -0
  189. package/src/services/database/surreal-cache-engine.ts +163 -0
  190. package/src/services/logger/index.test.ts +132 -0
  191. package/src/services/logger/index.ts +187 -6
  192. package/src/services/persistence/resilient.ts +8 -1
  193. package/src/services/persistence/surrealdb.ts +2 -2
  194. package/src/services/stream-processor/index.ts +801 -63
  195. package/src/services/stream-processor/permissions.test.ts +47 -0
  196. package/src/services/stream-processor/permissions.ts +53 -0
  197. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  198. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  199. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  200. package/src/services/stream-processor/wasm-types.ts +57 -1
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -394
  211. package/src/state/client-state.ts +231 -0
  212. package/src/state/lifecycle.test.ts +184 -0
  213. package/src/state/lifecycle.ts +127 -0
  214. package/src/state/reducers.test.ts +337 -0
  215. package/src/state/reducers.ts +571 -0
  216. package/src/state/selectors.test.ts +203 -0
  217. package/src/state/selectors.ts +264 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +229 -0
  224. package/src/sync/live.saga.ts +116 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +694 -7
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +57 -0
  241. package/src/utils/error-classification.test.ts +116 -0
  242. package/src/utils/error-classification.ts +74 -0
  243. package/src/utils/index.ts +83 -6
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +63 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +22 -11
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -882
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -78
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -90
  262. package/src/modules/sync/queue/queue-up.ts +0 -224
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -422
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -169
@@ -0,0 +1,307 @@
1
+ import type { Logger } from '../services/logger/index';
2
+ import type { QueryAuthorityCallback, QueryHash, QueryStatus, QueryStatusCallback, QueryUpdateCallback } from '../types';
3
+ import type { OutEvent, RuntimeEvent } from '../kernel/events';
4
+ import type { Adapters, Interpreter } from '../kernel/interpreter';
5
+ import { createInterpreter } from '../kernel/interpreter';
6
+ import type { Lane, Saga } from '../kernel/saga';
7
+ import { runSaga } from '../kernel/saga';
8
+ import type { ClientState } from '../state/client-state';
9
+ import { emptyState } from '../state/client-state';
10
+ import { isAnswered } from '../state/lifecycle';
11
+ import * as R from '../state/reducers';
12
+ import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
13
+ import type { SagaEnv } from '../query/env';
14
+ import { route } from './router';
15
+
16
+ interface Waiter {
17
+ until: (s: ClientState) => boolean;
18
+ resolve: () => void;
19
+ reject: (e: unknown) => void;
20
+ }
21
+
22
+ export interface RuntimeOptions {
23
+ env: SagaEnv;
24
+ adapters: Adapters;
25
+ logger: Logger;
26
+ tabId: string;
27
+ initialState?: ClientState;
28
+ }
29
+
30
+ /**
31
+ * The one effectful object of the engine: holds the state, runs sagas on
32
+ * lanes, fires timers, fans events out to subscribers and schedules
33
+ * materialization for dirty queries. No decision logic lives here.
34
+ */
35
+ export class Runtime {
36
+ private stateValue: ClientState;
37
+ private readonly interpret: Interpreter;
38
+ private readonly env: SagaEnv;
39
+ private readonly adapters: Adapters;
40
+ private readonly logger: Logger;
41
+ private readonly serialLanes = new Map<string, Promise<unknown>>();
42
+ private readonly dedupeLanes = new Map<string, Promise<unknown>>();
43
+ private readonly waiters = new Set<Waiter>();
44
+ private readonly recordSubs = new Map<QueryHash, Set<QueryUpdateCallback>>();
45
+ private readonly statusSubs = new Map<QueryHash, Set<QueryStatusCallback>>();
46
+ private readonly authoritySubs = new Map<QueryHash, Set<QueryAuthorityCallback>>();
47
+ private readonly lastStatus = new Map<QueryHash, QueryStatus>();
48
+ private readonly listeners = new Map<string, Set<(event: OutEvent) => void>>();
49
+ private readonly scheduledMaterialize = new Set<QueryHash>();
50
+ /** When each query last materialized, for the leading-edge throttle in `setState`. */
51
+ private readonly materializedAt = new Map<QueryHash, number>();
52
+ private readonly timerKeys = new Set<string>();
53
+ private lastActivity = { fetching: 0, pending: 0 };
54
+ private lastHealth: ClientState['sync']['health'];
55
+ private disposed = false;
56
+
57
+ constructor(opts: RuntimeOptions) {
58
+ this.env = opts.env;
59
+ this.logger = opts.logger;
60
+ this.stateValue = opts.initialState ?? emptyState({ tabId: opts.tabId });
61
+ this.lastHealth = this.stateValue.sync.health;
62
+ const timers = opts.adapters.timers;
63
+ this.adapters = {
64
+ ...opts.adapters,
65
+ timers: {
66
+ set: (key, ms, fire) => {
67
+ this.timerKeys.add(key);
68
+ timers.set(key, ms, () => {
69
+ this.timerKeys.delete(key);
70
+ if (!this.disposed) fire();
71
+ });
72
+ },
73
+ clear: (key) => {
74
+ this.timerKeys.delete(key);
75
+ timers.clear(key);
76
+ },
77
+ },
78
+ };
79
+ this.interpret = createInterpreter(this.adapters, {
80
+ getState: () => this.stateValue,
81
+ setState: (next) => this.setState(next),
82
+ waitFor: (until, signal) => this.waitFor(until, signal),
83
+ emit: (event) => this.emit(event),
84
+ dispatch: (event) => void this.dispatch(event),
85
+ });
86
+ }
87
+
88
+ get state(): ClientState {
89
+ return this.stateValue;
90
+ }
91
+
92
+ /** Run a saga, optionally on a lane. Rejections propagate to the caller. */
93
+ run<R>(saga: Saga<R>, opts: { lane?: Lane; signal?: AbortSignal } = {}): Promise<R> {
94
+ const exec = () => runSaga(saga, (effect) => this.interpret(effect, { signal: opts.signal }));
95
+ const lane = opts.lane;
96
+ if (!lane) return exec();
97
+ if (lane.kind === 'dedupe') {
98
+ const running = this.dedupeLanes.get(lane.key);
99
+ if (running) return running as Promise<R>;
100
+ const p = exec().finally(() => {
101
+ if (this.dedupeLanes.get(lane.key) === p) this.dedupeLanes.delete(lane.key);
102
+ });
103
+ this.dedupeLanes.set(lane.key, p);
104
+ return p;
105
+ }
106
+ const prev = this.serialLanes.get(lane.key) ?? Promise.resolve();
107
+ const p = prev.then(exec, exec);
108
+ const tail = p.catch(() => undefined);
109
+ this.serialLanes.set(lane.key, tail);
110
+ void tail.then(() => {
111
+ if (this.serialLanes.get(lane.key) === tail) this.serialLanes.delete(lane.key);
112
+ });
113
+ return p;
114
+ }
115
+
116
+ /** Route an event to its saga. Never rejects: failures are logged. */
117
+ dispatch(event: RuntimeEvent): Promise<void> {
118
+ if (this.disposed) return Promise.resolve();
119
+ const target = route(this.env, event);
120
+ return this.run(target.saga, { lane: target.lane }).then(
121
+ () => undefined,
122
+ (error) => {
123
+ this.logger.error({ error, event: event.type, Category: 'sp00ky-client::Runtime::dispatch' }, 'saga failed');
124
+ }
125
+ );
126
+ }
127
+
128
+ private setState(next: ClientState): void {
129
+ const prev = this.stateValue;
130
+ if (next === prev) return;
131
+ this.stateValue = next;
132
+ for (const w of [...this.waiters]) {
133
+ let ok = false;
134
+ try {
135
+ ok = w.until(next);
136
+ } catch (error) {
137
+ this.waiters.delete(w);
138
+ w.reject(error);
139
+ continue;
140
+ }
141
+ if (ok) {
142
+ this.waiters.delete(w);
143
+ w.resolve();
144
+ }
145
+ }
146
+ // Throttle with a leading edge: the first change after a quiet spell
147
+ // materializes on the next tick (a zero delay still folds every state
148
+ // update of the current turn into it), later ones inside the window fold
149
+ // into one run at its end. A trailing-only debounce put the whole window
150
+ // in front of every lone remote edit.
151
+ const now = this.adapters.clock.now();
152
+ for (const hash of next.dirty) {
153
+ if (this.scheduledMaterialize.has(hash)) continue;
154
+ this.scheduledMaterialize.add(hash);
155
+ const last = this.materializedAt.get(hash);
156
+ const delay = last === undefined ? 0 : Math.max(0, last + this.env.materializeDebounceMs - now);
157
+ this.adapters.timers.set(`mat:${hash}`, delay, () => {
158
+ this.scheduledMaterialize.delete(hash);
159
+ this.materializedAt.set(hash, this.adapters.clock.now());
160
+ void this.dispatch({ type: 'Materialize', hash });
161
+ });
162
+ }
163
+ for (const [hash, subs] of this.statusSubs) {
164
+ const entry = next.queries.get(hash);
165
+ if (!entry) continue;
166
+ const status = queryStatus(next, hash) ?? 'idle';
167
+ if (this.lastStatus.get(hash) === status) continue;
168
+ this.lastStatus.set(hash, status);
169
+ for (const cb of subs) this.safely(() => cb(status));
170
+ this.notify({ type: 'query:status', hash, status });
171
+ }
172
+ const activity = { fetching: fetchingQueryCount(next), pending: pendingMutationCount(next) };
173
+ if (activity.fetching !== this.lastActivity.fetching || activity.pending !== this.lastActivity.pending) {
174
+ this.lastActivity = activity;
175
+ this.notify({ type: 'activity:changed', ...activity });
176
+ }
177
+ // Health is notified from here, not from the saga that folds a sync round:
178
+ // `connection` moves through `setConnection` on a transport event with no
179
+ // round attached, and a subscriber that only heard the round would show a
180
+ // stale socket state forever. `setHealth` keeps the identity stable while
181
+ // the values are unchanged, so this fires exactly on real transitions.
182
+ if (next.sync.health !== this.lastHealth) {
183
+ this.lastHealth = next.sync.health;
184
+ this.notify({ type: 'health:changed', health: next.sync.health });
185
+ }
186
+ }
187
+
188
+ waitFor(until: (s: ClientState) => boolean, signal?: AbortSignal): Promise<void> {
189
+ try {
190
+ if (until(this.stateValue)) return Promise.resolve();
191
+ } catch (error) {
192
+ return Promise.reject(error);
193
+ }
194
+ if (signal?.aborted) return Promise.reject(new Error('aborted'));
195
+ return new Promise<void>((resolve, reject) => {
196
+ const waiter: Waiter = { until, resolve, reject };
197
+ this.waiters.add(waiter);
198
+ signal?.addEventListener(
199
+ 'abort',
200
+ () => {
201
+ if (this.waiters.delete(waiter)) reject(new Error('aborted'));
202
+ },
203
+ { once: true }
204
+ );
205
+ });
206
+ }
207
+
208
+ /** Deliver an outbound event to its subscribers and listeners. */
209
+ emit(event: OutEvent): void {
210
+ switch (event.type) {
211
+ case 'query:records':
212
+ for (const cb of this.recordSubs.get(event.hash) ?? []) this.safely(() => cb(event.records as Record<string, any>[]));
213
+ break;
214
+ case 'query:authority':
215
+ for (const cb of this.authoritySubs.get(event.hash) ?? []) this.safely(() => cb(event.known));
216
+ break;
217
+ case 'log':
218
+ this.logger[event.level](Object.assign({}, event.data as object | undefined, { Category: 'sp00ky-client::saga' }), event.message);
219
+ break;
220
+ default:
221
+ break;
222
+ }
223
+ this.notify(event);
224
+ }
225
+
226
+ private notify(event: OutEvent): void {
227
+ for (const cb of this.listeners.get(event.type) ?? []) this.safely(() => cb(event));
228
+ for (const cb of this.listeners.get('*') ?? []) this.safely(() => cb(event));
229
+ }
230
+
231
+ private safely(fn: () => void): void {
232
+ try {
233
+ fn();
234
+ } catch (error) {
235
+ this.logger.error({ error, Category: 'sp00ky-client::Runtime::subscriber' }, 'subscriber threw');
236
+ }
237
+ }
238
+
239
+ /** Observe outbound events by type (`'*'` for all). */
240
+ on(type: OutEvent['type'] | '*', cb: (event: OutEvent) => void): () => void {
241
+ const set = this.listeners.get(type) ?? new Set();
242
+ set.add(cb);
243
+ this.listeners.set(type, set);
244
+ return () => void set.delete(cb);
245
+ }
246
+
247
+ subscribe(hash: QueryHash, cb: QueryUpdateCallback, options: { immediate?: boolean } = {}): () => void {
248
+ const set = this.recordSubs.get(hash) ?? new Set();
249
+ set.add(cb);
250
+ this.recordSubs.set(hash, set);
251
+ this.setState(R.subscribe(hash)(this.stateValue));
252
+ if (options.immediate) {
253
+ const entry = this.stateValue.queries.get(hash);
254
+ if (entry) this.safely(() => cb(entry.records as Record<string, any>[]));
255
+ }
256
+ return () => {
257
+ if (!set.delete(cb)) return;
258
+ if (set.size === 0) this.recordSubs.delete(hash);
259
+ this.setState(R.unsubscribe(hash, this.adapters.clock.now())(this.stateValue));
260
+ };
261
+ }
262
+
263
+ subscribeStatus(hash: QueryHash, cb: QueryStatusCallback, options: { immediate?: boolean } = {}): () => void {
264
+ const set = this.statusSubs.get(hash) ?? new Set();
265
+ set.add(cb);
266
+ this.statusSubs.set(hash, set);
267
+ const entry = this.stateValue.queries.get(hash);
268
+ if (entry) {
269
+ const status = queryStatus(this.stateValue, hash) ?? 'idle';
270
+ this.lastStatus.set(hash, status);
271
+ if (options.immediate) this.safely(() => cb(status));
272
+ }
273
+ return () => {
274
+ set.delete(cb);
275
+ if (set.size === 0) {
276
+ this.statusSubs.delete(hash);
277
+ this.lastStatus.delete(hash);
278
+ }
279
+ };
280
+ }
281
+
282
+ subscribeAuthority(hash: QueryHash, cb: QueryAuthorityCallback, options: { immediate?: boolean } = {}): () => void {
283
+ const set = this.authoritySubs.get(hash) ?? new Set();
284
+ set.add(cb);
285
+ this.authoritySubs.set(hash, set);
286
+ if (options.immediate) {
287
+ const entry = this.stateValue.queries.get(hash);
288
+ if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
289
+ }
290
+ return () => {
291
+ set.delete(cb);
292
+ if (set.size === 0) this.authoritySubs.delete(hash);
293
+ };
294
+ }
295
+
296
+ /** Apply a reducer outside a saga (facade conveniences such as timing reports). */
297
+ update(reducer: (s: ClientState) => ClientState): void {
298
+ this.setState(reducer(this.stateValue));
299
+ }
300
+
301
+ dispose(): void {
302
+ this.disposed = true;
303
+ for (const key of [...this.timerKeys]) this.adapters.timers.clear(key);
304
+ for (const w of this.waiters) w.reject(new Error('runtime disposed'));
305
+ this.waiters.clear();
306
+ }
307
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { leaveReloadSalt, takeReloadSalt } from './services';
3
+
4
+ class MemoryStorage {
5
+ private items = new Map<string, string>();
6
+ getItem(key: string): string | null {
7
+ return this.items.get(key) ?? null;
8
+ }
9
+ setItem(key: string, value: string): void {
10
+ this.items.set(key, value);
11
+ }
12
+ removeItem(key: string): void {
13
+ this.items.delete(key);
14
+ }
15
+ }
16
+
17
+ describe('reload salt', () => {
18
+ const g = globalThis as { sessionStorage?: unknown };
19
+ beforeEach(() => {
20
+ g.sessionStorage = new MemoryStorage();
21
+ });
22
+ afterEach(() => {
23
+ delete g.sessionStorage;
24
+ });
25
+
26
+ it('a salt left on the way out is taken once, by the same principal', () => {
27
+ leaveReloadSalt('s1', 'user:a');
28
+ expect(takeReloadSalt('user:a')).toEqual({ salt: 's1', sessionIds: [], owner: null });
29
+ // Taken, not read: a tab duplicated from this page finds nothing.
30
+ expect(takeReloadSalt('user:a')).toBeNull();
31
+ });
32
+
33
+ it("the page's socket session ids travel with the salt", () => {
34
+ // The reloaded page's views keep edges stamped by these sockets, and the
35
+ // anonymous LIVE only hears sessions it knows.
36
+ leaveReloadSalt('s1', null, ['sid-1', 'sid-2']);
37
+ expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: ['sid-1', 'sid-2'], owner: null });
38
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's2', authId: null, sessionIds: ['ok', 7, null] }));
39
+ expect(takeReloadSalt(null)).toEqual({ salt: 's2', sessionIds: ['ok'], owner: null });
40
+ });
41
+
42
+ it("the page's owner secret travels with the salt, so the reloaded page still owns its anonymous views", () => {
43
+ leaveReloadSalt('s1', null, [], 'secret-1');
44
+ expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: [], owner: 'secret-1' });
45
+ // Gone with the salt: a duplicated tab mints its own.
46
+ expect(takeReloadSalt(null)).toBeNull();
47
+ // A different principal re-salts, and the secret is not handed over either.
48
+ leaveReloadSalt('s2', 'user:a', [], 'secret-2');
49
+ expect(takeReloadSalt(null)).toBeNull();
50
+ for (const owner of ['', 7, null]) {
51
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's3', authId: null, owner }));
52
+ expect(takeReloadSalt(null)?.owner).toBeNull();
53
+ }
54
+ });
55
+
56
+ it('another principal, or none, mints a fresh salt', () => {
57
+ leaveReloadSalt('s1', 'user:a');
58
+ expect(takeReloadSalt('user:b')).toBeNull();
59
+ leaveReloadSalt('s2', null);
60
+ expect(takeReloadSalt(null)?.salt).toBe('s2');
61
+ leaveReloadSalt('s3', null);
62
+ expect(takeReloadSalt('user:a')).toBeNull();
63
+ });
64
+
65
+ it('garbage or missing storage is a fresh salt, never a throw', () => {
66
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', '{not json');
67
+ expect(takeReloadSalt(null)).toBeNull();
68
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: '', authId: null }));
69
+ expect(takeReloadSalt(null)).toBeNull();
70
+ delete g.sessionStorage;
71
+ expect(takeReloadSalt(null)).toBeNull();
72
+ expect(() => leaveReloadSalt('s1', null)).not.toThrow();
73
+ });
74
+ });