@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,293 @@
1
+ import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { QueryHash, QueryTimeToLive } from '../types';
4
+ import type { Saga } from '../kernel/saga';
5
+ import type { RegisterResult, StatementResult } from '../kernel/effects';
6
+ import { fx } from '../kernel/effects';
7
+ import { AUTH_READY_MAX_ATTEMPTS, AUTH_READY_RETRY_MS, REGISTER_MAX_RETRIES, backoffMs } from '../kernel/constants';
8
+ import type { ClientState, QueryEntry } from '../state/client-state';
9
+ import { emptyTelemetry } from '../state/client-state';
10
+ import { seedLifecycle } from '../state/lifecycle';
11
+ import * as R from '../state/reducers';
12
+ import { desiredRegistrations } from '../state/selectors';
13
+ import { parseDuration } from '../utils/index';
14
+ import type { SagaEnv } from './env';
15
+ import { listRefTable } from './env';
16
+ import { queryHashInput, viewKeyInput } from './hash';
17
+ import { FAILED, isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
18
+ import { applyMembership, applySubqueryChildren, markMembershipDirty } from './membership.saga';
19
+ import * as sql from './sql';
20
+
21
+ export interface RegisterInput {
22
+ tableName: string;
23
+ surql: string;
24
+ params: Record<string, unknown>;
25
+ ttl: QueryTimeToLive;
26
+ plan?: QueryPlan;
27
+ }
28
+
29
+ /**
30
+ * Register a query locally: compute its keys, read the durable `_00_view`
31
+ * row, build the SSP local view and publish the entry. Returns as soon as the
32
+ * entry exists (the first paint comes from `materialize`); the remote
33
+ * registration is dispatched, never awaited.
34
+ */
35
+ export function* registerLocal(env: SagaEnv, input: RegisterInput): Saga<QueryHash> {
36
+ const sessionId = (yield fx.state.read((s) => s.sessionId)) as string | null;
37
+ const hash = (yield fx.hash(queryHashInput(input, sessionId))) as string;
38
+ const status = (yield fx.state.read((s) => (s.queries.has(hash) ? 'active' : s.registering.has(hash) ? 'pending' : 'new'))) as
39
+ | 'active'
40
+ | 'pending'
41
+ | 'new';
42
+ if (status === 'active') return hash;
43
+ if (status === 'pending') {
44
+ yield fx.state.wait((s) => !s.registering.has(hash));
45
+ return hash;
46
+ }
47
+ yield fx.state.update(R.beginRegistering(hash));
48
+ try {
49
+ const viewKey = (yield fx.hash(viewKeyInput(input))) as string;
50
+ let viewRow: unknown = null;
51
+ try {
52
+ viewRow = yield fx.local.getById(sql.VIEW_TABLE, sql.viewRecordId(viewKey));
53
+ } catch {
54
+ viewRow = null;
55
+ }
56
+ const view = parseViewRow(viewRow);
57
+ const now = (yield fx.now()) as number;
58
+ const reg = (yield fx.ssp.register({
59
+ queryHash: hash,
60
+ surql: input.surql,
61
+ params: input.params,
62
+ ttl: input.ttl,
63
+ tableName: input.tableName,
64
+ })) as RegisterResult;
65
+ const entry: QueryEntry = {
66
+ def: {
67
+ id: new RecordId('_00_query', hash),
68
+ hash,
69
+ viewKey,
70
+ surql: input.surql,
71
+ params: input.params,
72
+ plan: input.plan,
73
+ ttl: input.ttl,
74
+ ttlMs: parseDuration(input.ttl),
75
+ tableName: input.tableName,
76
+ createdAt: now,
77
+ },
78
+ lifecycle: seedLifecycle(isResolvedBefore(view)),
79
+ remoteArray: view?.ids ?? [],
80
+ localArray: reg.localArray,
81
+ subqueryRemoteArray: [],
82
+ records: [],
83
+ serverState: null,
84
+ subscribers: 0,
85
+ lastSubscriberLeftAt: now,
86
+ lastHeartbeatAt: null,
87
+ lastPolledAt: null,
88
+ registerAttempts: 0,
89
+ viewLostCount: 0,
90
+ viewLostRetryAt: null,
91
+ telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
92
+ };
93
+ yield fx.state.update(R.putQuery(entry));
94
+ yield fx.dispatch({ type: 'EnsureRegistered' });
95
+ return hash;
96
+ } finally {
97
+ yield fx.state.update(R.endRegistering(hash));
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Make the remote match the desired set: start a registration for every
103
+ * query that has none. Idempotent. With `requireAuth` (after a reconnect or
104
+ * while degraded) it first waits for `$auth.id` to be visible on the socket:
105
+ * a register issued before that stamps the view with an empty identity.
106
+ */
107
+ export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
108
+ const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
109
+ if (hashes.length === 0) return;
110
+ if (opts.requireAuth && userId !== null) {
111
+ let authed = false;
112
+ try {
113
+ const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
114
+ authed = res[0]?.status === 'OK' && res[0].result != null;
115
+ } catch {
116
+ authed = false;
117
+ }
118
+ if (!authed) {
119
+ const attempt = (opts.attempt ?? 0) + 1;
120
+ if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
121
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
122
+ return;
123
+ }
124
+ yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
125
+ return;
126
+ }
127
+ }
128
+ for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
129
+ }
130
+
131
+ const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
132
+
133
+ /**
134
+ * The SSP answers a registration whose shape is not on the query allowlist
135
+ * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
136
+ * `fn::query::register` statement error carrying SurrealDB's http error text
137
+ * (status reason and/or body), and no retry can change the answer.
138
+ */
139
+ export const isNotAllowlisted = (error: unknown): boolean =>
140
+ /not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
141
+
142
+ /**
143
+ * The entry no longer names the remote id this registration sent: a salt
144
+ * rotation (`rebindQueries`) re-keyed it while the request was out, and
145
+ * seeded a fresh lifecycle for the new id.
146
+ */
147
+ const movedOff = (current: QueryEntry, sent: QueryEntry): boolean => String(current.def.id) !== String(sent.def.id);
148
+
149
+ /**
150
+ * Release the id a superseded registration sent and register the new one.
151
+ * The re-key's own `RegisterRemote` for this hash joined the run still in
152
+ * flight (the lane dedupes per hash), so it is armed again on a timer, which
153
+ * fires once this run has left the lane.
154
+ */
155
+ function* handOff(hash: QueryHash, sent: QueryEntry): Saga<void> {
156
+ yield fx.dispatch({ type: 'ReleaseSuperseded', ids: [sent.def.id] });
157
+ yield fx.timer.set(`register:${hash}`, 0, { type: 'RegisterRemote', hash });
158
+ }
159
+
160
+ /**
161
+ * One remote registration: `fn::query::register` plus the edge/meta/children
162
+ * read in ONE request, then membership application. Concurrent with every
163
+ * other registration; retried on its own backoff; stops when the entry is
164
+ * gone.
165
+ *
166
+ * An answer that lands after the query moved to a new id applies nothing:
167
+ * the membership and the `registered` flag belong to the id it sent, which
168
+ * this page no longer uses, so it releases that id and leaves the new one to
169
+ * the registration the re-key dispatched. Marking the new id registered from
170
+ * the old id's answer meant the new id was never registered, and nothing
171
+ * ever named the old one again.
172
+ */
173
+ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
174
+ const state = (yield fx.state.read((s) => s)) as ClientState;
175
+ const entry = state.queries.get(hash);
176
+ if (!entry) return;
177
+ const { remote } = entry.lifecycle;
178
+ if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
179
+ // A query that already shows rows (cached or live) only re-verifies them
180
+ // here: the cycle holds `fetching` but is not traffic the user waits on.
181
+ const background = entry.lifecycle.phase !== 'cold';
182
+ let superseded = false;
183
+ yield fx.state.update(
184
+ R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
185
+ );
186
+ try {
187
+ const table = listRefTable(env, state);
188
+ const results = (yield fx.remote.query(
189
+ sql.registerSelect(table),
190
+ sql.registerVars({
191
+ id: entry.def.id,
192
+ surql: entry.def.surql,
193
+ params: { ...entry.def.params },
194
+ ttl: entry.def.ttl,
195
+ ...(state.ownerSecret ? { owner: state.ownerSecret } : {}),
196
+ }),
197
+ env.remoteTimeoutMs
198
+ )) as StatementResult[];
199
+ const answered = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
200
+ if (!answered) return;
201
+ superseded = movedOff(answered, entry);
202
+ if (superseded) {
203
+ yield* handOff(hash, entry);
204
+ return;
205
+ }
206
+ const register = stmt(results, 0);
207
+ if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
208
+ const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
209
+ const snap = snapshotFromSingle(
210
+ answer(stmt(results, 1)) as never,
211
+ answer(stmt(results, 2)) as never,
212
+ answer(stmt(results, 3)) as never
213
+ );
214
+ if (!snap) {
215
+ // Registered, but the read-back did not answer. That says nothing about
216
+ // the row, so read membership again rather than guess: a guess of
217
+ // "gone" re-registered the query, and under load the next read-back
218
+ // failed the same way.
219
+ yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
220
+ yield fx.emit({ type: 'log', level: 'debug', message: 'registration read-back did not answer; re-reading membership', data: { hash } });
221
+ yield* markMembershipDirty([hash]);
222
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
223
+ return;
224
+ }
225
+ const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
226
+ if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
227
+ yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
228
+ yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
229
+ }
230
+ yield* applySubqueryChildren(hash, snap.subquery);
231
+ yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
232
+ yield fx.dispatch({ type: 'SyncOutcome', ok: true });
233
+ } catch (error) {
234
+ const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
235
+ if (!current) return;
236
+ // A request that timed out may still land: release its id all the same.
237
+ superseded = movedOff(current, entry);
238
+ if (superseded) {
239
+ yield* handOff(hash, entry);
240
+ return;
241
+ }
242
+ if (isNotAllowlisted(error)) {
243
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
244
+ yield fx.emit({
245
+ type: 'log',
246
+ level: 'error',
247
+ message: 'query refused: not allowlisted',
248
+ data: {
249
+ hash,
250
+ table: entry.def.tableName,
251
+ surql: entry.def.surql,
252
+ params: Object.keys(entry.def.params),
253
+ hint: 'Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev).',
254
+ },
255
+ });
256
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
257
+ return;
258
+ }
259
+ const attempts = current.registerAttempts + 1;
260
+ yield fx.state.update(R.bumpRegisterAttempts(hash));
261
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
262
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
263
+ if (attempts >= REGISTER_MAX_RETRIES) {
264
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
265
+ } else {
266
+ yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
267
+ }
268
+ } finally {
269
+ // A re-key seeded a fresh lifecycle: this cycle's begin is not in it.
270
+ const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
271
+ if (stillThere && !superseded) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Hand back remote ids this page registered and no longer uses: a query
277
+ * moved to a new salt (sign-up, sign-in, sign-out, impersonation) while its
278
+ * old id was registered or on its way. Nothing names the old id again, not
279
+ * the heartbeat, not the pagehide beacon, so it used to hold a live view
280
+ * until its TTL ran out, and the SSP fanned every change to a row it held out
281
+ * to it. Best-effort: a row the caller may no longer touch (another
282
+ * principal's) or a failed request is left to the TTL sweep, as before.
283
+ */
284
+ export function* releaseSuperseded(env: SagaEnv, ids: ReadonlyArray<QueryEntry['def']['id']>): Saga<void> {
285
+ if (ids.length === 0) return;
286
+ const sessionIds = (yield fx.service('remote.sessionIds')) as readonly string[];
287
+ const batch = sql.releaseBatch(ids, sessionIds);
288
+ try {
289
+ yield fx.remote.query(batch.sql, batch.vars, env.remoteTimeoutMs);
290
+ } catch (error) {
291
+ yield fx.emit({ type: 'log', level: 'debug', message: 'superseded view release failed; the TTL sweep reclaims it', data: { ids: ids.map(String), error } });
292
+ }
293
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
3
+
4
+ const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
5
+
6
+ describe('resolveMembership', () => {
7
+ it('cold scans, cold window uses the local view, anything else uses membership', () => {
8
+ const remoteArray: [string, number][] = [['t:1', 1]];
9
+ const localArray: [string, number][] = [['t:2', 1]];
10
+ expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: false })).toBeNull();
11
+ expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: true })).toBe(localArray);
12
+ expect(resolveMembership({ phase: 'cached', remoteArray, localArray, isWindow: false })).toBe(remoteArray);
13
+ expect(resolveMembership({ phase: 'view-lost', remoteArray, localArray, isWindow: true })).toBe(remoteArray);
14
+ });
15
+ });
16
+
17
+ describe('buildRenderIds', () => {
18
+ const opts = { hasExplicitOrder: false, isWindow: false };
19
+ it('membership minus deletes, sorted', () => {
20
+ expect(buildRenderIds([['t:b', 1], ['t:a', 1], ['t:c', 1]], [], ov([], ['t:c']), opts)).toEqual(['t:a', 't:b']);
21
+ });
22
+ it('adds pending writes the local view admits, never twice, never deleted', () => {
23
+ const ids = buildRenderIds(
24
+ [['t:1', 1]],
25
+ [['t:1', 1], ['t:2', 1], ['t:3', 1], ['t:4', 1]],
26
+ ov(['t:1', 't:2', 't:3', 't:9'], ['t:3']),
27
+ opts
28
+ );
29
+ expect(ids).toEqual(['t:1', 't:2']);
30
+ });
31
+ it('keeps server order for windows and explicit orderBy; dedupes membership', () => {
32
+ const m: [string, number][] = [['t:b', 1], ['t:a', 1], ['t:b', 1]];
33
+ expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: true, isWindow: false })).toEqual(['t:b', 't:a']);
34
+ expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
35
+ });
36
+ });
37
+
38
+ describe('paintsCold', () => {
39
+ it('only vouched bodies, optimistic writes and bookkeeping paint before the server answers', () => {
40
+ const vouched = new Set(['t:1']);
41
+ expect(paintsCold('t:1', vouched, ov())).toBe(true);
42
+ expect(paintsCold('t:2', vouched, ov(['t:2']))).toBe(true);
43
+ expect(paintsCold('_00_user_feature:f', vouched, ov())).toBe(true);
44
+ expect(paintsCold('t:3', vouched, ov([], ['t:3']))).toBe(false);
45
+ expect(paintsCold('t:3', vouched, ov())).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,71 @@
1
+ import type { RecordVersionArray } from '../types';
2
+ import type { QueryPhase } from '../state/lifecycle';
3
+ import type { Overlay } from '../state/selectors';
4
+
5
+ export interface RenderInput {
6
+ phase: QueryPhase;
7
+ remoteArray: RecordVersionArray;
8
+ localArray: RecordVersionArray;
9
+ isWindow: boolean;
10
+ }
11
+
12
+ /**
13
+ * The id-set a query renders from, or `null` when it must fall back to a
14
+ * local predicate scan (a cold query that was never resolved on this device).
15
+ * A cold window has no usable scan (re-applying `START m` against the shared
16
+ * store returns the wrong rows), so it renders the SSP's local window instead.
17
+ */
18
+ export function resolveMembership(input: RenderInput): RecordVersionArray | null {
19
+ if (input.phase !== 'cold') return input.remoteArray;
20
+ if (input.isWindow) return input.localArray;
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Whether a body may paint before the server has answered (`cold`). One no
26
+ * durable view vouches for and no optimistic write touches is an orphan:
27
+ * deleted upstream while no view watched it, or dropped from every view since.
28
+ * The predicate scan would otherwise resurrect it until the server answers,
29
+ * and indefinitely offline.
30
+ */
31
+ export function paintsCold(id: string, vouched: ReadonlySet<string>, overlay: Overlay): boolean {
32
+ return id.startsWith('_00_') || vouched.has(id) || overlay.writes.has(id);
33
+ }
34
+
35
+ export interface RenderOptions {
36
+ hasExplicitOrder: boolean;
37
+ isWindow: boolean;
38
+ }
39
+
40
+ /**
41
+ * render = (membership ∪ (writes ∩ localView)) − deletes
42
+ *
43
+ * `writes`/`deletes` are the outbox overlay (pending and acked items). The
44
+ * middle term keeps optimistic writes visible without re-admitting stale rows:
45
+ * `localView` (the SSP's local id-set) says whether the written row matches
46
+ * the query per local truth. Sorted unless the query orders itself or is a
47
+ * window (whose id order is the slice order).
48
+ */
49
+ export function buildRenderIds(
50
+ membership: RecordVersionArray,
51
+ localView: RecordVersionArray,
52
+ overlay: Overlay,
53
+ opts: RenderOptions
54
+ ): string[] {
55
+ const ordered: string[] = [];
56
+ const seen = new Set<string>();
57
+ for (const [id] of membership) {
58
+ if (overlay.deletes.has(id) || seen.has(id)) continue;
59
+ seen.add(id);
60
+ ordered.push(id);
61
+ }
62
+ if (overlay.writes.size > 0) {
63
+ for (const [id] of localView) {
64
+ if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
65
+ seen.add(id);
66
+ ordered.push(id);
67
+ }
68
+ }
69
+ if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
70
+ return ordered;
71
+ }