@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.321

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 (261) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15236 -2467
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +733 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +365 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1821 -38
  19. package/dist/worker-edges.js +470 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +60 -0
  46. package/src/client/services.ts +418 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +501 -0
  62. package/src/live/index.ts +1039 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +93 -0
  90. package/src/modules/ref-tables.ts +91 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +66 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/anon-query-isolation.integration.test.ts +337 -0
  110. package/src/query/env.test.ts +23 -0
  111. package/src/query/env.ts +56 -0
  112. package/src/query/fetch.saga.test.ts +199 -0
  113. package/src/query/fetch.saga.ts +120 -0
  114. package/src/query/hash.test.ts +14 -0
  115. package/src/query/hash.ts +18 -0
  116. package/src/query/id-set-plan.test.ts +132 -0
  117. package/src/query/lifecycle.saga.test.ts +274 -0
  118. package/src/query/lifecycle.saga.ts +159 -0
  119. package/src/query/list-ref-live.integration.test.ts +203 -0
  120. package/src/query/materialize.saga.test.ts +115 -0
  121. package/src/query/materialize.saga.ts +84 -0
  122. package/src/query/materialize.test.ts +58 -0
  123. package/src/query/materialize.ts +57 -0
  124. package/src/query/membership.saga.test.ts +350 -0
  125. package/src/query/membership.saga.ts +278 -0
  126. package/src/query/membership.test.ts +118 -0
  127. package/src/query/membership.ts +201 -0
  128. package/src/query/register.saga.test.ts +311 -0
  129. package/src/query/register.saga.ts +229 -0
  130. package/src/query/render-set.test.ts +47 -0
  131. package/src/query/render-set.ts +71 -0
  132. package/src/query/sql.test.ts +112 -0
  133. package/src/query/sql.ts +149 -0
  134. package/src/query/window-query.test.ts +70 -0
  135. package/src/query/window-query.ts +195 -0
  136. package/src/services/blobs/blob-cache.test.ts +359 -0
  137. package/src/services/blobs/blob-cache.ts +603 -0
  138. package/src/services/blobs/blob-manifest.ts +227 -0
  139. package/src/services/blobs/blob-store.test.ts +77 -0
  140. package/src/services/blobs/blob-store.ts +359 -0
  141. package/src/services/blobs/blob.fixture.ts +90 -0
  142. package/src/services/blobs/index.ts +70 -0
  143. package/src/services/database/cache-engine.ts +199 -0
  144. package/src/services/database/connection-supervisor.test.ts +318 -0
  145. package/src/services/database/connection-supervisor.ts +422 -0
  146. package/src/services/database/database.query-timeout.test.ts +140 -0
  147. package/src/services/database/database.responses.test.ts +43 -0
  148. package/src/services/database/database.ts +146 -61
  149. package/src/services/database/engine-factory.ts +33 -0
  150. package/src/services/database/errors.ts +34 -0
  151. package/src/services/database/events/index.ts +2 -1
  152. package/src/services/database/index.ts +7 -0
  153. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  154. package/src/services/database/local-migrator.integration.test.ts +110 -0
  155. package/src/services/database/local-migrator.ts +59 -16
  156. package/src/services/database/local.test.ts +64 -0
  157. package/src/services/database/local.ts +487 -63
  158. package/src/services/database/plan-render.test.ts +159 -0
  159. package/src/services/database/plan-render.ts +108 -0
  160. package/src/services/database/relation-resolver.test.ts +484 -0
  161. package/src/services/database/relation-resolver.ts +0 -0
  162. package/src/services/database/remote.ts +273 -6
  163. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  164. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  165. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  166. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  167. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  168. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  169. package/src/services/database/sqlite-lock-verify.ts +45 -0
  170. package/src/services/database/sqlite-open.test.ts +150 -0
  171. package/src/services/database/sqlite-open.ts +164 -0
  172. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  173. package/src/services/database/sqlite-plan-sql.ts +182 -0
  174. package/src/services/database/sqlite-projection.test.ts +121 -0
  175. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  176. package/src/services/database/sqlite-select.test.ts +261 -0
  177. package/src/services/database/sqlite-select.ts +131 -0
  178. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  179. package/src/services/database/sqlite-transport.ts +224 -0
  180. package/src/services/database/sqlite-worker.ts +437 -0
  181. package/src/services/database/surql-translate.ts +416 -0
  182. package/src/services/database/surreal-cache-engine.ts +161 -0
  183. package/src/services/logger/index.test.ts +132 -0
  184. package/src/services/logger/index.ts +189 -7
  185. package/src/services/persistence/localstorage.ts +2 -2
  186. package/src/services/persistence/resilient.ts +10 -3
  187. package/src/services/persistence/surrealdb.ts +4 -4
  188. package/src/services/stream-processor/index.ts +783 -68
  189. package/src/services/stream-processor/permissions.test.ts +47 -0
  190. package/src/services/stream-processor/permissions.ts +53 -0
  191. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  192. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  193. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  194. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  195. package/src/services/stream-processor/wasm-types.ts +58 -2
  196. package/src/services/tabs/broker-client.ts +283 -0
  197. package/src/services/tabs/broker.test.ts +327 -0
  198. package/src/services/tabs/coordinator.test.ts +386 -0
  199. package/src/services/tabs/coordinator.ts +638 -0
  200. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  201. package/src/services/tabs/leader-locks.ts +75 -0
  202. package/src/services/tabs/protocol.ts +262 -0
  203. package/src/services/tabs/support.ts +36 -0
  204. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  205. package/src/sp00ky.ts +5 -395
  206. package/src/state/client-state.ts +200 -0
  207. package/src/state/lifecycle.test.ts +141 -0
  208. package/src/state/lifecycle.ts +99 -0
  209. package/src/state/reducers.test.ts +304 -0
  210. package/src/state/reducers.ts +526 -0
  211. package/src/state/selectors.test.ts +175 -0
  212. package/src/state/selectors.ts +222 -0
  213. package/src/sw/index.test.ts +582 -0
  214. package/src/sw/index.ts +607 -0
  215. package/src/sw/store.ts +96 -0
  216. package/src/sync/connection.saga.test.ts +97 -0
  217. package/src/sync/connection.saga.ts +89 -0
  218. package/src/sync/live.saga.test.ts +178 -0
  219. package/src/sync/live.saga.ts +107 -0
  220. package/src/sync/policy.health.test.ts +56 -0
  221. package/src/sync/policy.test.ts +663 -0
  222. package/src/sync/policy.ts +515 -0
  223. package/src/sync/poll.saga.test.ts +51 -0
  224. package/src/sync/poll.saga.ts +36 -0
  225. package/src/sync/tabs.saga.test.ts +78 -0
  226. package/src/sync/tabs.saga.ts +96 -0
  227. package/src/testing/adapters.ts +133 -0
  228. package/src/testing/build.ts +63 -0
  229. package/src/testing/fake-services.ts +48 -0
  230. package/src/testing/run-pure.test.ts +92 -0
  231. package/src/testing/run-pure.ts +128 -0
  232. package/src/testing/services.ts +17 -0
  233. package/src/types.ts +624 -10
  234. package/src/utils/blurhash.ts +90 -0
  235. package/src/utils/edges.ts +39 -0
  236. package/src/utils/error-classification.test.ts +54 -0
  237. package/src/utils/error-classification.ts +25 -0
  238. package/src/utils/index.ts +78 -12
  239. package/src/utils/parser.test.ts +67 -102
  240. package/src/utils/parser.ts +64 -2
  241. package/src/utils/semver.test.ts +32 -0
  242. package/src/utils/semver.ts +30 -0
  243. package/src/utils/sha256.ts +15 -0
  244. package/src/utils/surql.ts +30 -18
  245. package/src/utils/utils.test.ts +3 -2
  246. package/tsdown.config.ts +117 -3
  247. package/vitest.config.ts +48 -0
  248. package/src/modules/cache/index.ts +0 -241
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.test.ts +0 -58
  251. package/src/modules/data/index.ts +0 -879
  252. package/src/modules/sync/engine.ts +0 -173
  253. package/src/modules/sync/events/index.ts +0 -77
  254. package/src/modules/sync/index.ts +0 -3
  255. package/src/modules/sync/queue/index.ts +0 -2
  256. package/src/modules/sync/queue/queue-down.ts +0 -89
  257. package/src/modules/sync/queue/queue-up.ts +0 -223
  258. package/src/modules/sync/scheduler.ts +0 -84
  259. package/src/modules/sync/sync.ts +0 -421
  260. package/src/modules/sync/utils.test.ts +0 -311
  261. package/src/modules/sync/utils.ts +0 -167
package/src/pure.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `@spooky-sync/core/pure`: the side-effect-free builders of the sync protocol.
3
+ *
4
+ * Everything here is plain functions and constants over `surrealdb` value
5
+ * types: no wasm, no logger, no `window`/`document`/`localStorage`. Safe to
6
+ * import from a service worker, a dedicated worker or Node. The full client
7
+ * imports the same modules, so what this entry builds is byte for byte what
8
+ * `Sp00kyClient` sends.
9
+ */
10
+ import { queryHashInput, type QueryKeyInput } from './query/hash';
11
+ import { sha256Hex } from './utils/sha256';
12
+
13
+ // Statement builders: register + snapshot, heartbeat, bodies, views.
14
+ export * from './query/sql';
15
+ // Query keys.
16
+ export { queryHashInput, viewKeyInput, type QueryKeyInput } from './query/hash';
17
+ // `_00_list_ref` table naming and user-id sanitizing (mirrors ssp-protocol).
18
+ export {
19
+ ANON_LIST_REF_TABLE,
20
+ ANON_USER_ID,
21
+ DEFAULT_REF_MODE,
22
+ bucketIdForUser,
23
+ listRefTableFor,
24
+ sanitizeUserId,
25
+ type RefMode,
26
+ } from './modules/ref-tables';
27
+ // Membership snapshot parsing (the answer to the register read-back).
28
+ export {
29
+ FAILED,
30
+ dedupeRecordVersions,
31
+ metaFromRow,
32
+ snapshotFromSingle,
33
+ type Answer,
34
+ type ListRefSnapshot,
35
+ type QueryMetaRow,
36
+ } from './query/membership';
37
+ // LIVE edge decoding.
38
+ export { hashOfEdge, rowOfEdge } from './utils/edges';
39
+ // SurrealQL string helpers.
40
+ export {
41
+ surql,
42
+ type SealOptions,
43
+ type SealedQuery,
44
+ type SurqlHelper,
45
+ type TxQuery,
46
+ } from './utils/surql';
47
+ // Param typing the full client applies before registering.
48
+ export { parseQueryParams } from './utils/parser';
49
+ export { encodeRecordId, parseRecordIdString, parseDuration } from './utils/index';
50
+ // Unverified JWT claim reading (user id, access method, impersonation).
51
+ export {
52
+ decodeTokenClaims,
53
+ IMPERSONATION_ACCESS,
54
+ type TokenClaims,
55
+ } from './modules/auth/impersonation';
56
+ export { sha256Hex } from './utils/sha256';
57
+ // Web Push wire types.
58
+ export * from './push/types';
59
+
60
+ /**
61
+ * The remote `_00_query` key of a query for one session salt: what the full
62
+ * client names its view (`_00_query:<key>`).
63
+ */
64
+ export function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string> {
65
+ return sha256Hex(queryHashInput(input, sessionId));
66
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Web Push wire types shared by the page (`db.webPush`), the service worker
3
+ * bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
4
+ * dependency-free helpers, safe in any context.
5
+ *
6
+ * `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
7
+ * `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
8
+ */
9
+
10
+ /** Payload format version this build understands. */
11
+ export const PUSH_PAYLOAD_VERSION = 1;
12
+
13
+ export type PushPayloadKind = 'rule' | 'message';
14
+ export type PushOp = 'create' | 'update' | 'delete';
15
+ export type PushUrgency = 'very-low' | 'low' | 'normal' | 'high';
16
+
17
+ /** One button on a notification (`NotificationAction` plus a per-action `url`). */
18
+ export interface PushNotificationAction {
19
+ action: string;
20
+ title: string;
21
+ icon?: string;
22
+ /** Opened instead of the notification's `url` when this action is clicked. */
23
+ url?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /**
28
+ * What a content push shows. Every key other than `title` and `url` is passed
29
+ * to `showNotification` as is, unknown ones included (`vibrate`, `timestamp`,
30
+ * whatever a browser adds later).
31
+ */
32
+ export interface PushNotification {
33
+ title?: string;
34
+ body?: string;
35
+ icon?: string;
36
+ badge?: string;
37
+ image?: string;
38
+ lang?: string;
39
+ dir?: string;
40
+ /** Defaults to the push's `topic`. */
41
+ tag?: string;
42
+ /** Opened (or focused) on click. */
43
+ url?: string;
44
+ requireInteraction?: boolean;
45
+ renotify?: boolean;
46
+ silent?: boolean;
47
+ actions?: PushNotificationAction[];
48
+ /** Merged into the notification's `data` next to the bridge's own keys. */
49
+ data?: Record<string, unknown>;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /**
54
+ * The JSON every push carries (then RFC 8291 encrypted). `notification`
55
+ * present: a content push the service worker shows as is. Absent: a
56
+ * content-free nudge, the app renders from its own data.
57
+ */
58
+ export interface PushPayload {
59
+ v: number;
60
+ kind: PushPayloadKind;
61
+ /** Rule name (`kind: 'rule'`). */
62
+ rule?: string;
63
+ /** `_00_push_message` id (`kind: 'message'`). */
64
+ message?: string;
65
+ /** The row that fired the rule. */
66
+ table?: string;
67
+ id?: string;
68
+ op?: PushOp;
69
+ topic?: string;
70
+ notification?: PushNotification;
71
+ data?: Record<string, unknown>;
72
+ /** Epoch ms when the engine built it. */
73
+ ts: number;
74
+ }
75
+
76
+ /** Page -> service worker: the session to use for live rendering. */
77
+ export interface BridgeTokenMessage {
78
+ type: 'sp00ky:token';
79
+ token: string;
80
+ userId: string;
81
+ /** The SurrealDB endpoint (`wss://host/rpc`), not the push endpoint. */
82
+ endpoint: string;
83
+ namespace: string;
84
+ database: string;
85
+ /** VAPID public key (base64url) the page subscribed with, for `pushsubscriptionchange`. */
86
+ publicKey?: string;
87
+ /** This browser's push endpoint, when subscribed. */
88
+ pushEndpoint?: string;
89
+ }
90
+
91
+ /** Page -> service worker: forget the session and close every notification. */
92
+ export interface BridgeSignOutMessage {
93
+ type: 'sp00ky:signout';
94
+ }
95
+
96
+ /** Service worker -> page messages. */
97
+ export type WebPushClientMessage =
98
+ /** A push arrived while this page was visible, so nothing was shown. */
99
+ | { type: 'sp00ky:push'; payload: PushPayload }
100
+ /** A notification was clicked and the bridge was told to route in-app. */
101
+ | { type: 'sp00ky:navigate'; url: string; payload?: PushPayload; action?: string }
102
+ /** The browser rotated the push subscription; the page should `sync()`. */
103
+ | { type: 'sp00ky:subscriptionchange'; endpoint?: string };
104
+
105
+ /** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
106
+ export function base64UrlToBytes(value: string): Uint8Array {
107
+ const padded =
108
+ value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
109
+ const raw = typeof atob === 'function' ? atob(padded) : decodeBase64Fallback(padded);
110
+ const out = new Uint8Array(raw.length);
111
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
112
+ return out;
113
+ }
114
+
115
+ export function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
116
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
117
+ let raw = '';
118
+ for (let i = 0; i < view.length; i++) raw += String.fromCharCode(view[i]);
119
+ const b64 = typeof btoa === 'function' ? btoa(raw) : encodeBase64Fallback(raw);
120
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
121
+ }
122
+
123
+ export function sameBytes(
124
+ a: ArrayBuffer | Uint8Array | null | undefined,
125
+ b: ArrayBuffer | Uint8Array | null | undefined
126
+ ): boolean {
127
+ if (!a || !b) return false;
128
+ const x = a instanceof Uint8Array ? a : new Uint8Array(a);
129
+ const y = b instanceof Uint8Array ? b : new Uint8Array(b);
130
+ if (x.length !== y.length) return false;
131
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
132
+ return true;
133
+ }
134
+
135
+ /** A payload this build can act on (`v` known, `kind` set). */
136
+ export function isPushPayload(value: unknown): value is PushPayload {
137
+ if (!value || typeof value !== 'object') return false;
138
+ const v = value as { v?: unknown; kind?: unknown };
139
+ return v.v === PUSH_PAYLOAD_VERSION && (v.kind === 'rule' || v.kind === 'message');
140
+ }
141
+
142
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
143
+
144
+ function decodeBase64Fallback(input: string): string {
145
+ const clean = input.replace(/=+$/, '');
146
+ let out = '';
147
+ let buffer = 0;
148
+ let bits = 0;
149
+ for (const ch of clean) {
150
+ const idx = B64.indexOf(ch);
151
+ if (idx < 0) continue;
152
+ buffer = (buffer << 6) | idx;
153
+ bits += 6;
154
+ if (bits >= 8) {
155
+ bits -= 8;
156
+ out += String.fromCharCode((buffer >> bits) & 0xff);
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function encodeBase64Fallback(raw: string): string {
163
+ let out = '';
164
+ for (let i = 0; i < raw.length; i += 3) {
165
+ const a = raw.charCodeAt(i);
166
+ const b = i + 1 < raw.length ? raw.charCodeAt(i + 1) : 0;
167
+ const c = i + 2 < raw.length ? raw.charCodeAt(i + 2) : 0;
168
+ const n = (a << 16) | (b << 8) | c;
169
+ out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
170
+ out += i + 1 < raw.length ? B64[(n >> 6) & 63] : '=';
171
+ out += i + 2 < raw.length ? B64[n & 63] : '=';
172
+ }
173
+ return out;
174
+ }
@@ -0,0 +1,337 @@
1
+ import { Socket } from 'node:net';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { RecordId, Surreal } from 'surrealdb';
7
+ import { liveEndpoint } from '../live/index';
8
+ import {
9
+ heartbeatBatch,
10
+ heartbeatRowGone,
11
+ leaveViewsBeacon,
12
+ queryRowCountBatchSelect,
13
+ queryRowCountSelect,
14
+ } from './sql';
15
+
16
+ /**
17
+ * Every signed-out visitor shares the one `anon` identity, so `_00_query`
18
+ * tells their views apart by session: an anonymous row answers only to the
19
+ * sessions in its `subscribers`. A second visitor must not be able to list,
20
+ * read, rewrite, plant or delete the first one's view, while the first keeps
21
+ * its heartbeat and its registration read-back, also after a reconnect.
22
+ *
23
+ * Runs the shipped `_00_query` block and `fn::query::*` functions from
24
+ * `apps/cli/src` against a real SurrealDB, because the guarantee is the
25
+ * engine's evaluation of the table and field permissions. Boot one first, e.g.:
26
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
27
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
28
+ *
29
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
30
+ */
31
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
32
+ const NS = 'it';
33
+
34
+ const cliSource = (file: string) =>
35
+ readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
36
+
37
+ /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
38
+ function queryTableSchema(): string {
39
+ const meta = cliSource('meta_tables_remote.surql');
40
+ const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
41
+ const end = meta.indexOf('-- SPOOKY LIST REF');
42
+ if (start < 0 || end < start)
43
+ throw new Error('_00_query block not found in meta_tables_remote.surql');
44
+ return meta.slice(start, end);
45
+ }
46
+
47
+ const ACCESS = `
48
+ DEFINE TABLE OVERWRITE user SCHEMALESS PERMISSIONS FOR select WHERE id = $auth.id;
49
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
50
+ SIGNUP (CREATE user SET name = $name)
51
+ SIGNIN (SELECT * FROM user WHERE name = $name)
52
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
53
+ `;
54
+
55
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
56
+ const SSP_REGISTER = `
57
+ UPSERT $id SET clientId = $sid, auth_id = $owner, surql = 'SELECT * FROM item',
58
+ params = { auth: { id: $authParam }, access: '' }, ttl = 1h, lastActiveAt = time::now(),
59
+ rowCount = 3, state = 'ready',
60
+ subscribers = array::append(
61
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
62
+ { id: $sid, seenAt: time::now() })`;
63
+
64
+ /** A `_00_query:<sha256>` id, the shape the client mints (and the pagehide beacon accepts). */
65
+ const viewId = () =>
66
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
67
+
68
+ describe('anonymous _00_query isolation (real SurrealDB)', () => {
69
+ let root: Surreal;
70
+ let database = '';
71
+ let reachable = false;
72
+ const open: Surreal[] = [];
73
+
74
+ // A plain TCP probe: the SDK never rejects a connect to a refused port.
75
+ beforeAll(async () => {
76
+ const url = new URL(endpoint);
77
+ reachable = await new Promise<boolean>((resolve) => {
78
+ const socket = new Socket();
79
+ const done = (ok: boolean) => {
80
+ socket.destroy();
81
+ resolve(ok);
82
+ };
83
+ socket.setTimeout(1_000, () => done(false));
84
+ socket.once('error', () => done(false));
85
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
86
+ });
87
+ });
88
+
89
+ beforeEach(async () => {
90
+ if (!reachable) return;
91
+ database = `anon_query_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
92
+ root = new Surreal();
93
+ await root.connect(endpoint, { reconnect: false });
94
+ await root.signin({ username: 'root', password: 'root' });
95
+ await root.use({ namespace: NS, database });
96
+ await root.query(ACCESS);
97
+ await root.query(queryTableSchema());
98
+ await root.query(cliSource('functions_remote.surql'));
99
+ });
100
+
101
+ afterEach(async () => {
102
+ if (!reachable) return;
103
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
104
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
105
+ await root.close();
106
+ });
107
+
108
+ /** A signed-out socket and its `session::id()`. */
109
+ async function guest(): Promise<[Surreal, string]> {
110
+ const db = new Surreal();
111
+ open.push(db);
112
+ await db.connect(endpoint, { reconnect: false });
113
+ await db.use({ namespace: NS, database });
114
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
115
+ return [db, sid];
116
+ }
117
+
118
+ /** A signed-in socket, its `session::id()`, `$auth.id` and access token. */
119
+ async function member(name: string): Promise<[Surreal, string, string, string]> {
120
+ const db = new Surreal();
121
+ open.push(db);
122
+ await db.connect(endpoint, { reconnect: false });
123
+ await db.use({ namespace: NS, database });
124
+ const tokens = (await db.signup({
125
+ namespace: NS,
126
+ database,
127
+ access: 'account',
128
+ variables: { name },
129
+ })) as string | { access: string };
130
+ const [sid, authId] = (await db.query(
131
+ 'RETURN <string>session::id(); RETURN <string>$auth.id'
132
+ )) as [string, string];
133
+ return [db, sid, authId, typeof tokens === 'string' ? tokens : tokens.access];
134
+ }
135
+
136
+ /** `beaconSql`'s request: a fresh HTTP session, Bearer token only when signed in. */
137
+ async function beacon(sql: string, token?: string): Promise<void> {
138
+ const url = new URL(endpoint);
139
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
140
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
141
+ const headers: Record<string, string> = {
142
+ Accept: 'application/json',
143
+ 'Content-Type': 'text/plain',
144
+ 'surreal-ns': NS,
145
+ 'surreal-db': database,
146
+ };
147
+ if (token) headers.Authorization = `Bearer ${token}`;
148
+ const res = await fetch(url, { method: 'POST', headers, body: sql });
149
+ expect(res.ok).toBe(true);
150
+ }
151
+
152
+ const register = (id: RecordId<string>, sid: string, owner = 'anon') =>
153
+ root.query(SSP_REGISTER, { id, sid, owner, authParam: owner === 'anon' ? '' : owner });
154
+ const row = async (id: RecordId<string>) =>
155
+ ((await root.query('SELECT * FROM ONLY $id', { id })) as [Record<string, any> | undefined])[0];
156
+ const count = async () =>
157
+ ((await root.query('SELECT VALUE id FROM _00_query')) as [unknown[]])[0].length;
158
+
159
+ /** The client's register read-back, as `registerSelect` sends it after `fn::query::register`. */
160
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
161
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
162
+ /** The client's lifecycle tick: did the heartbeat find the row? */
163
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
164
+ const beat = heartbeatBatch([id]);
165
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
166
+ return !heartbeatRowGone(result);
167
+ };
168
+
169
+ it("a second guest cannot list, read, rewrite, release or delete the first guest's view", async (ctx) => {
170
+ if (!reachable) ctx.skip();
171
+ const [a, sidA] = await guest();
172
+ const [b, sidB] = await guest();
173
+ const qa = viewId();
174
+ await register(qa, sidA);
175
+ const before = await row(qa);
176
+
177
+ expect(await b.query('SELECT id, surql FROM _00_query')).toEqual([[]]);
178
+ expect(await readBack(b, qa)).toBeNull();
179
+ // The membership batch read. 3.0.x answers a hidden row with an empty
180
+ // object, which the client skips for want of an `id`.
181
+ const [batch] = (await b.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
182
+ Array<{ id?: unknown }>,
183
+ ];
184
+ expect(batch.filter((r) => r?.id)).toEqual([]);
185
+
186
+ expect(
187
+ await b.query(`UPDATE $id SET surql = 'SELECT * FROM secret', ttl = 1000w`, { id: qa })
188
+ ).toEqual([[]]);
189
+ expect(await heartbeatFound(b, qa)).toBe(false);
190
+ expect(await b.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
191
+ { released: false, remaining: 0 },
192
+ ]);
193
+ // Session ids are not secret (they ride on `_00_list_ref_anon`), so naming
194
+ // the owner's must not help, over the socket or over the beacon's HTTP path.
195
+ await b.query('RETURN fn::query::leave($id, [$sid])', { id: qa, sid: sidA });
196
+ await beacon(leaveViewsBeacon([qa], [sidA])!);
197
+ await b.query('DELETE $id', { id: qa });
198
+
199
+ expect(await row(qa)).toEqual(before);
200
+ expect(await count()).toBe(1);
201
+ // ...and the owner never noticed.
202
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
203
+ expect(sidB).not.toBe(sidA);
204
+ });
205
+
206
+ it('no caller can plant a row, so nothing the SSP re-registers at boot comes from a client', async (ctx) => {
207
+ if (!reachable) ctx.skip();
208
+ const [g, sidG] = await guest();
209
+ const [u, , authU] = await member('alice');
210
+ const planted = (sid: string, owner: string) =>
211
+ `SET auth_id = '${owner}', surql = 'SELECT * FROM item', params = {}, ttl = 1h,
212
+ subscribers = [{ id: '${sid}', seenAt: time::now() }]`;
213
+
214
+ await g.query(`CREATE _00_query:g1 ${planted(sidG, 'anon')}`);
215
+ await g.query(`UPSERT _00_query:g2 ${planted(sidG, 'anon')}`);
216
+ await g.query(
217
+ `INSERT INTO _00_query { id: 'g3', auth_id: 'anon', ttl: 1h, subscribers: [{ id: '${sidG}' }] }`
218
+ );
219
+ await u.query(`CREATE _00_query:u1 ${planted('x', authU)}`);
220
+ await u.query(`UPSERT _00_query:u2 ${planted('x', authU)}`);
221
+ expect(await count()).toBe(0);
222
+ });
223
+
224
+ it('the owner cannot rewrite what the SSP trusts on its own row either', async (ctx) => {
225
+ if (!reachable) ctx.skip();
226
+ const [g, sidG] = await guest();
227
+ const [u, sidU, authU] = await member('alice');
228
+ const qg = viewId();
229
+ const qu = viewId();
230
+ await register(qg, sidG);
231
+ await register(qu, sidU, authU);
232
+ // One field per statement: a write the table rule rejects as a whole would
233
+ // hide whether the field itself is locked. `params.auth.id` is the identity
234
+ // the SSP injects into permission predicates when it rebuilds the view at
235
+ // boot, `surql` skips the allowlist there, and turning a user's row into an
236
+ // anonymous one would publish its edges to the world-readable anon table.
237
+ const tampers = (owner: string): Record<string, string> => ({
238
+ surql: `surql = 'SELECT * FROM secret'`,
239
+ params: `params = { auth: { id: 'user:victim' }, access: 'account' }`,
240
+ ttl: 'ttl = 1000w',
241
+ auth_id: `auth_id = '${owner === 'anon' ? 'user:victim' : 'anon'}'`,
242
+ clientId: `clientId = 'forged'`,
243
+ rowCount: 'rowCount = 0',
244
+ state: `state = 'materializing'`,
245
+ lastActiveAt: 'lastActiveAt = time::now() + 1000w',
246
+ });
247
+
248
+ for (const [db, id, owner] of [
249
+ [g, qg, 'anon'],
250
+ [u, qu, authU],
251
+ ] as const) {
252
+ for (const [field, set] of Object.entries(tampers(owner))) {
253
+ const before = await row(id);
254
+ await db.query(`UPDATE $id SET ${set}`, { id });
255
+ expect((await row(id))?.[field], `${owner} ${field}`).toEqual(before?.[field]);
256
+ }
257
+ }
258
+ });
259
+
260
+ it('the owning guest keeps its heartbeat and read-back, and after a reconnect gets them back by registering again', async (ctx) => {
261
+ if (!reachable) ctx.skip();
262
+ const [a, sidA] = await guest();
263
+ const [b] = await guest();
264
+ const qa = viewId();
265
+ await register(qa, sidA);
266
+
267
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
268
+ const [batch] = (await a.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
269
+ Array<{ id?: unknown; rowCount?: number }>,
270
+ ];
271
+ expect(batch.map((r) => r.rowCount)).toEqual([3]);
272
+ const stamped = (await row(qa))?.lastActiveAt;
273
+ expect(await heartbeatFound(a, qa)).toBe(true);
274
+ expect((await row(qa))?.lastActiveAt).not.toEqual(stamped);
275
+
276
+ // A reconnect is a new session. Until it registers again its heartbeat
277
+ // finds nothing, which the client reads as a reclaimed view and
278
+ // re-registers; the SSP's registration then adds the new session.
279
+ await a.close();
280
+ const [a2, sidA2] = await guest();
281
+ expect(await heartbeatFound(a2, qa)).toBe(false);
282
+ expect(await readBack(a2, qa)).toBeNull();
283
+ await register(qa, sidA2);
284
+ expect(await heartbeatFound(a2, qa)).toBe(true);
285
+ expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
286
+ expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
287
+
288
+ // The bystander is still out.
289
+ expect(await heartbeatFound(b, qa)).toBe(false);
290
+ expect(await readBack(b, qa)).toBeNull();
291
+ });
292
+
293
+ it('the owning guest can still release its view', async (ctx) => {
294
+ if (!reachable) ctx.skip();
295
+ const [a, sidA] = await guest();
296
+ const qa = viewId();
297
+ await register(qa, sidA);
298
+ expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
299
+ { released: true, remaining: 0 },
300
+ ]);
301
+ expect(await row(qa)).toBeUndefined();
302
+ });
303
+
304
+ it("a signed-in user's views are untouched: other tabs share them, the pagehide leave still pulls the expiry in", async (ctx) => {
305
+ if (!reachable) ctx.skip();
306
+ const [u, sidU, authU, token] = await member('alice');
307
+ const [bob] = await member('bob');
308
+ const [g] = await guest();
309
+ const qu = viewId();
310
+ await register(qu, sidU, authU);
311
+
312
+ // A second tab of the same user is not a subscriber yet and still gets in.
313
+ const tab2 = new Surreal();
314
+ open.push(tab2);
315
+ await tab2.connect(endpoint, { reconnect: false });
316
+ await tab2.use({ namespace: NS, database });
317
+ await tab2.authenticate(token);
318
+ expect(await heartbeatFound(tab2, qu)).toBe(true);
319
+ expect(await readBack(tab2, qu)).toEqual({ rowCount: 3, state: 'ready' });
320
+
321
+ expect(await heartbeatFound(bob, qu)).toBe(false);
322
+ expect(await readBack(g, qu)).toBeNull();
323
+
324
+ // The beacon names both tabs' sessions over a fresh HTTP session.
325
+ const [sid2] = (await tab2.query('RETURN <string>session::id()')) as [string];
326
+ await beacon(leaveViewsBeacon([qu], [sidU, sid2])!, token);
327
+ const [left] = (await root.query(
328
+ 'SELECT VALUE duration::secs((lastActiveAt + ttl) - time::now()) FROM ONLY $id',
329
+ {
330
+ id: qu,
331
+ }
332
+ )) as [number];
333
+ expect(left).toBeGreaterThan(0);
334
+ expect(left).toBeLessThanOrEqual(60);
335
+ expect((await row(qu))?.subscribers).toEqual([]);
336
+ });
337
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { columnsFor, defaultEnv, listRefTable } from './env';
3
+ import { emptyState } from '../state/client-state';
4
+
5
+ const schema = { tables: [{ name: 'thing', columns: { a: { type: 'string' } } }] } as any;
6
+
7
+ describe('SagaEnv', () => {
8
+ it('defaultEnv applies overrides', () => {
9
+ expect(defaultEnv(schema).outboxBatchSize).toBe(50);
10
+ expect(defaultEnv(schema, { outboxBatchSize: 5 }).outboxBatchSize).toBe(5);
11
+ });
12
+ it('listRefTable: per-user, anonymous when enabled, global otherwise', () => {
13
+ const s = emptyState({ tabId: 't' });
14
+ expect(listRefTable(defaultEnv(schema), { ...s, userId: 'user:abc' })).toBe('_00_list_ref_user_abc');
15
+ expect(listRefTable(defaultEnv(schema, { anonLive: true }), s)).toBe('_00_list_ref_anon');
16
+ expect(listRefTable(defaultEnv(schema), s)).toBe('_00_list_ref');
17
+ });
18
+ it('columnsFor: schema table, framework table, unknown', () => {
19
+ expect(columnsFor(defaultEnv(schema), 'thing')).toEqual({ a: { type: 'string' } });
20
+ expect(columnsFor(defaultEnv(schema), '_00_feature_flag')).toEqual({});
21
+ expect(columnsFor(defaultEnv(schema), 'nope')).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,56 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { RefMode } from '../modules/ref-tables';
3
+ import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor } from '../modules/ref-tables';
4
+ import type { ClientState } from '../state/client-state';
5
+ import {
6
+ DEGRADE_AFTER_FAILURES,
7
+ LIST_REF_POLL_BASE_MS,
8
+ MATERIALIZE_DEBOUNCE_MS,
9
+ OUTBOX_BATCH_SIZE,
10
+ PUSH_TIMEOUT_MS,
11
+ } from '../kernel/constants';
12
+
13
+ /**
14
+ * Static configuration the sagas read. Plain data, built once by the runtime
15
+ * from `Sp00kyConfig`; sagas never see the config object itself.
16
+ */
17
+ export interface SagaEnv {
18
+ readonly schema: SchemaStructure;
19
+ readonly refMode: RefMode;
20
+ readonly anonLive: boolean;
21
+ readonly remoteTimeoutMs: number;
22
+ readonly pushTimeoutMs: number;
23
+ readonly outboxBatchSize: number;
24
+ readonly degradeAfter: number;
25
+ readonly materializeDebounceMs: number;
26
+ readonly pollBaseMs: number;
27
+ readonly defaultTtlMs: number;
28
+ }
29
+
30
+ export function defaultEnv(schema: SchemaStructure, over: Partial<SagaEnv> = {}): SagaEnv {
31
+ return {
32
+ schema,
33
+ refMode: DEFAULT_REF_MODE,
34
+ anonLive: false,
35
+ remoteTimeoutMs: 60_000,
36
+ pushTimeoutMs: PUSH_TIMEOUT_MS,
37
+ outboxBatchSize: OUTBOX_BATCH_SIZE,
38
+ degradeAfter: DEGRADE_AFTER_FAILURES,
39
+ materializeDebounceMs: MATERIALIZE_DEBOUNCE_MS,
40
+ pollBaseMs: LIST_REF_POLL_BASE_MS,
41
+ defaultTtlMs: 600_000,
42
+ ...over,
43
+ };
44
+ }
45
+
46
+ /** The `_00_list_ref` table this session reads: per-user, anonymous, or global. */
47
+ export function listRefTable(env: SagaEnv, s: ClientState): string {
48
+ const userId = s.userId ?? (env.anonLive ? ANON_USER_ID : null);
49
+ return listRefTableFor(env.refMode, userId);
50
+ }
51
+
52
+ export function columnsFor(env: SagaEnv, table: string): Record<string, unknown> | null {
53
+ const found = env.schema.tables.find((t) => t.name === table);
54
+ if (found) return found.columns as Record<string, unknown>;
55
+ return table.startsWith('_00_') ? {} : null;
56
+ }