@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,384 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { MutationEventType, UpdateOptions } from '../types';
3
+ import type { Saga } from '../kernel/saga';
4
+ import { fx } from '../kernel/effects';
5
+ import type { ClientState, PeerWrite, PendingWrite } from '../state/client-state';
6
+ import * as R from '../state/reducers';
7
+ import { parseParams } from '../utils/parser';
8
+ import { extractTablePart, parseRecordIdString } from '../utils/index';
9
+ import type { SagaEnv } from '../query/env';
10
+ import { columnsFor } from '../query/env';
11
+ import { PENDING_WRITE_ORPHAN_MS } from '../kernel/constants';
12
+ import {
13
+ loadPendingWriteRows,
14
+ parsePendingWriteRow,
15
+ pendingWriteId,
16
+ planCreateTx,
17
+ planDeferredOutboxRowTx,
18
+ planDeleteTx,
19
+ planLocalOnlyUpdateTx,
20
+ planPeerCreateTx,
21
+ planPeerUndoTx,
22
+ planUpdateTx,
23
+ type LocalTx,
24
+ type PendingWriteRow,
25
+ } from './rows';
26
+
27
+ export interface WriteInput {
28
+ kind: MutationEventType;
29
+ recordId: string;
30
+ data?: Record<string, unknown>;
31
+ options?: UpdateOptions;
32
+ }
33
+
34
+ export interface WriteResult {
35
+ mutationId: string;
36
+ record: Record<string, unknown> | null;
37
+ }
38
+
39
+ const debounceKey = (input: WriteInput): string | null => {
40
+ const d = input.options?.debounced;
41
+ if (!d) return null;
42
+ const opts = d === true ? {} : d;
43
+ const keyMode = opts.key ?? 'recordId_x_fields';
44
+ return keyMode === 'recordId'
45
+ ? input.recordId
46
+ : `${input.recordId}::${Object.keys(input.data ?? {}).sort().join(',')}`;
47
+ };
48
+ const debounceDelay = (input: WriteInput): number => {
49
+ const d = input.options?.debounced;
50
+ return d && d !== true && typeof d.delay === 'number' ? d.delay : 300;
51
+ };
52
+ const flushOnHide = (input: WriteInput): boolean => {
53
+ const d = input.options?.debounced;
54
+ return !(d && d !== true && d.flushOnHide === false);
55
+ };
56
+
57
+ /**
58
+ * One optimistic write: read the previous row (update/delete), run the local
59
+ * transaction that writes the row AND its outbox entry, publish the outbox
60
+ * item (which puts the id into the overlay and dirties the table's queries),
61
+ * feed the circuit, and start a drain. A debounced update applies locally at
62
+ * once but accumulates into one outbox row written on flush.
63
+ */
64
+ export function* write(env: SagaEnv, input: WriteInput): Saga<WriteResult> {
65
+ const table = extractTablePart(input.recordId);
66
+ const columns = columnsFor(env, table);
67
+ if (!columns) throw new Error(`Table ${table} not found`);
68
+ const rid = parseRecordIdString(input.recordId);
69
+ const params = input.data ? parseParams(columns as never, input.data) : undefined;
70
+ const key = input.kind === 'update' ? debounceKey(input) : null;
71
+ if (key !== null) return yield* debouncedUpdate(input, key, rid, table, params ?? {});
72
+ const mutationId = (yield fx.id('mutation')) as string;
73
+ const now = (yield fx.now()) as number;
74
+ let before: Record<string, unknown> | null = null;
75
+ if (input.kind !== 'create') before = yield* readBefore(rid);
76
+ const tx = planTx(input.kind, { recordId: rid, mutationId: parseRecordIdString(mutationId), table, data: params, before, now });
77
+ return yield* commitWrite(input.kind, tx, { mutationId, recordId: input.recordId, table, before, now });
78
+ }
79
+
80
+ function planTx(kind: MutationEventType, planInput: Parameters<typeof planCreateTx>[0]): LocalTx {
81
+ if (kind === 'create') return planCreateTx(planInput);
82
+ if (kind === 'update') return planUpdateTx(planInput);
83
+ return planDeleteTx(planInput);
84
+ }
85
+
86
+ function* readBefore(rid: RecordId<string>): Saga<Record<string, unknown> | null> {
87
+ const res = (yield fx.local.query('SELECT * FROM ONLY $id', { id: rid })) as unknown[];
88
+ const row = Array.isArray(res) ? res[0] : null;
89
+ return row && typeof row === 'object' && !Array.isArray(row) ? (row as Record<string, unknown>) : null;
90
+ }
91
+
92
+ interface CommitCtx {
93
+ mutationId: string;
94
+ recordId: string;
95
+ table: string;
96
+ before: Record<string, unknown> | null;
97
+ now: number;
98
+ }
99
+
100
+ function* commitWrite(kind: MutationEventType, tx: LocalTx, ctx: CommitCtx): Saga<WriteResult> {
101
+ const result = (yield fx.local.execute(tx.query, tx.vars)) as unknown;
102
+ const target = kind === 'update' ? (result as { target?: unknown } | undefined)?.target : result;
103
+ const record = kind === 'delete' || !target || typeof target !== 'object' ? null : (target as Record<string, unknown>);
104
+ yield fx.state.update(
105
+ R.outboxPush({ id: ctx.mutationId, type: kind, recordId: ctx.recordId, table: ctx.table, status: 'pending', ackedAt: null, attempts: 0 })
106
+ );
107
+ const version = kind === 'create' ? 1 : typeof record?._00_rv === 'number' ? (record._00_rv as number) : ((ctx.before?._00_rv as number) ?? 0) + 1;
108
+ const circuitRecord = kind === 'delete' ? (ctx.before ?? {}) : { ...record, _00_rv: version };
109
+ const circuitTuple = { table: ctx.table, op: kind.toUpperCase() as 'CREATE' | 'UPDATE' | 'DELETE', id: ctx.recordId, record: circuitRecord };
110
+ try {
111
+ yield fx.ssp.ingest([circuitTuple]);
112
+ if (kind === 'delete') yield fx.state.update(R.deleteVersions([ctx.recordId]));
113
+ else yield fx.state.update(R.setVersions([[ctx.recordId, version]]));
114
+ } catch (error) {
115
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a local write', data: { recordId: ctx.recordId, error } });
116
+ }
117
+ yield fx.emit({
118
+ type: 'mutation:event',
119
+ event: { type: kind, mutation_id: parseRecordIdString(ctx.mutationId), record_id: parseRecordIdString(ctx.recordId), data: tx.vars.data, record, beforeRecord: ctx.before ?? undefined, tableName: ctx.table },
120
+ });
121
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
122
+ if (role !== 'solo') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [circuitTuple] } });
123
+ if (role === 'follower') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: ctx.mutationId } });
124
+ else yield fx.dispatch({ type: 'Drain' });
125
+ return { mutationId: ctx.mutationId, record };
126
+ }
127
+
128
+ /**
129
+ * Apply locally now, remember the merged patch, write the outbox row on flush.
130
+ * With `flushOnHide` (the default) the merged patch is also mirrored to
131
+ * `_00_pending_writes` in the same transaction, so a reload or a closed tab
132
+ * inside the delay cannot lose it.
133
+ */
134
+ function* debouncedUpdate(
135
+ input: WriteInput,
136
+ key: string,
137
+ rid: RecordId<string>,
138
+ table: string,
139
+ params: Record<string, unknown>
140
+ ): Saga<WriteResult> {
141
+ const existing = (yield fx.state.read((s) => s.pendingWrites.get(key))) as PendingWrite | undefined;
142
+ const now = (yield fx.now()) as number;
143
+ const before = existing ? existing.before : yield* readBefore(rid);
144
+ const mirrorId = existing?.mirrorId ?? (flushOnHide(input) ? pendingWriteId((yield fx.id('mutation')) as string) : null);
145
+ const delay = debounceDelay(input);
146
+ const mirror = mirrorId
147
+ ? { id: parseRecordIdString(mirrorId), row: { recordId: rid, tableName: table, data: { ...existing?.data, ...params }, beforeRecord: before, flushBy: now + delay } }
148
+ : undefined;
149
+ const local = planLocalOnlyUpdateTx({ recordId: rid, data: params, mirror });
150
+ const res = (yield fx.local.execute(local.query, local.vars)) as { target?: Record<string, unknown> } | undefined;
151
+ const record = res?.target ?? null;
152
+ yield fx.state.update(R.mergePendingWrite({ key, table, recordId: input.recordId, data: params, before, firstAt: existing?.firstAt ?? now, mirrorId }));
153
+ yield fx.state.update(R.markTableDirty(table));
154
+ const version = typeof record?._00_rv === 'number' ? (record._00_rv as number) : 0;
155
+ const tuple = { table, op: 'UPDATE' as const, id: input.recordId, record: { ...record, _00_rv: version } };
156
+ try {
157
+ yield fx.ssp.ingest([tuple]);
158
+ yield fx.state.update(R.setVersions([[input.recordId, version]]));
159
+ } catch (error) {
160
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a local write', data: { recordId: input.recordId, error } });
161
+ }
162
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
163
+ if (role !== 'solo') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [tuple] } });
164
+ yield fx.timer.set(`debounce:${key}`, delay, { type: 'FlushWrite', key });
165
+ return { mutationId: '', record };
166
+ }
167
+
168
+ /** Timer target: turn the accumulated patch into one outbox row and drain. */
169
+ export function* flushWrite(env: SagaEnv, key: string): Saga<void> {
170
+ const pending = (yield fx.state.read((s) => s.pendingWrites.get(key))) as PendingWrite | undefined;
171
+ if (!pending) return;
172
+ yield fx.state.update(R.clearPendingWrite(key));
173
+ const mutationId = (yield fx.id('mutation')) as string;
174
+ const now = (yield fx.now()) as number;
175
+ const rid = parseRecordIdString(pending.recordId);
176
+ const insert = planDeferredOutboxRowTx({
177
+ recordId: rid,
178
+ mutationId: parseRecordIdString(mutationId),
179
+ table: pending.table,
180
+ data: { ...pending.data },
181
+ before: pending.before,
182
+ now,
183
+ mirrorId: pending.mirrorId ? parseRecordIdString(pending.mirrorId) : undefined,
184
+ });
185
+ try {
186
+ yield fx.local.execute(insert.query, insert.vars);
187
+ } catch (error) {
188
+ // The patch is no longer in memory; a mirrored one is still in the store.
189
+ if (pending.mirrorId) yield fx.timer.set('adopt-writes', PENDING_WRITE_ORPHAN_MS, { type: 'AdoptPendingWrites' });
190
+ throw error;
191
+ }
192
+ yield fx.state.update(R.outboxPush({ id: mutationId, type: 'update', recordId: pending.recordId, table: pending.table, status: 'pending', ackedAt: null, attempts: 0 }));
193
+ yield fx.emit({
194
+ type: 'mutation:event',
195
+ event: { type: 'update', mutation_id: parseRecordIdString(mutationId), record_id: rid, data: { ...pending.data }, beforeRecord: pending.before ?? undefined, tableName: pending.table },
196
+ });
197
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
198
+ if (role === 'follower') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId } });
199
+ else yield fx.dispatch({ type: 'Drain' });
200
+ }
201
+
202
+ /**
203
+ * The page is being hidden (tab switch, reload, close, backgrounded): flush
204
+ * every mirrored pending write now rather than when its timer fires. On a
205
+ * reload or close this races the teardown; whatever does not make it stays
206
+ * in `_00_pending_writes` for the next boot.
207
+ */
208
+ export function* flushPendingWrites(): Saga<void> {
209
+ const keys = (yield fx.state.read((s) => [...s.pendingWrites.values()].filter((w) => w.mirrorId !== null).map((w) => w.key))) as string[];
210
+ for (const key of keys) {
211
+ yield fx.timer.clear(`debounce:${key}`);
212
+ yield fx.dispatch({ type: 'FlushWrite', key });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Move abandoned `_00_pending_writes` rows into the outbox: rows no pending
218
+ * write of this tab holds, `PENDING_WRITE_ORPHAN_MS` past their due flush. That
219
+ * is a tab that reloaded or closed inside the delay, or a flush of ours that
220
+ * failed. A row not yet overdue may still be live in another tab and is looked
221
+ * at again once it would be. A follower only nudges the leader, so a single
222
+ * tab adopts and a patch is never queued twice.
223
+ */
224
+ export function* adoptPendingWrites(): Saga<void> {
225
+ const state = (yield fx.state.read((s) => s)) as ClientState;
226
+ let raw: unknown[] = [];
227
+ try {
228
+ const res = (yield fx.local.query(loadPendingWriteRows())) as unknown[];
229
+ raw = Array.isArray(res?.[0]) ? (res[0] as unknown[]) : [];
230
+ } catch {
231
+ return; // An older store may not have the table yet: nothing to adopt.
232
+ }
233
+ const held = new Set([...state.pendingWrites.values()].map((w) => w.mirrorId));
234
+ const rows = raw.map(parsePendingWriteRow).filter((r): r is PendingWriteRow => r !== null && !held.has(r.id));
235
+ if (rows.length === 0) return;
236
+ if (state.tabRole === 'follower') {
237
+ yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: '' } });
238
+ return;
239
+ }
240
+ const now = (yield fx.now()) as number;
241
+ let recheckAt = Infinity;
242
+ let adopted = 0;
243
+ for (const row of rows) {
244
+ const due = row.flushBy + PENDING_WRITE_ORPHAN_MS;
245
+ if (now < due) {
246
+ recheckAt = Math.min(recheckAt, due);
247
+ continue;
248
+ }
249
+ const mutationId = (yield fx.id('mutation')) as string;
250
+ const move = planDeferredOutboxRowTx({
251
+ recordId: parseRecordIdString(row.recordId),
252
+ mutationId: parseRecordIdString(mutationId),
253
+ table: row.tableName,
254
+ data: row.data,
255
+ before: row.beforeRecord,
256
+ now,
257
+ mirrorId: parseRecordIdString(row.id),
258
+ });
259
+ try {
260
+ yield fx.local.execute(move.query, move.vars);
261
+ } catch (error) {
262
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not move an abandoned debounced write into the outbox', data: { id: row.id, error } });
263
+ continue;
264
+ }
265
+ yield fx.state.update(R.outboxPush({ id: mutationId, type: 'update', recordId: row.recordId, table: row.tableName, status: 'pending', ackedAt: null, attempts: 0 }));
266
+ adopted += 1;
267
+ }
268
+ if (adopted > 0) yield fx.dispatch({ type: 'Drain' });
269
+ if (recheckAt !== Infinity) yield fx.timer.set('adopt-writes', recheckAt - now, { type: 'AdoptPendingWrites' });
270
+ }
271
+
272
+ // ---- channel peers' creates and deletes ------------------------------------------
273
+
274
+ /** The row as the local store holds it, or null: what a channel checks its filter against. */
275
+ export function* readRow(recordId: string): Saga<Record<string, unknown> | null> {
276
+ return yield* readBefore(parseRecordIdString(recordId));
277
+ }
278
+
279
+ export interface PeerWriteInput {
280
+ recordId: string;
281
+ /** The row's fields, for a create. */
282
+ data?: Record<string, unknown>;
283
+ /** How long it shows without the server confirming it. */
284
+ ttlMs: number;
285
+ }
286
+
287
+ /**
288
+ * A channel peer created a row: put it in the store and the circuit like an
289
+ * own optimistic create, minus the outbox entry (the peer pushes it), and
290
+ * show it through the overlay. `_00_rv` stays unset on purpose: the server's
291
+ * body (with its computed fields) still downloads and replaces this one.
292
+ * Refuses an id this client already holds a confirmed row or an own pending
293
+ * write for, so a peer can never overwrite real data. Returns whether it applied.
294
+ */
295
+ export function* peerCreate(env: SagaEnv, input: PeerWriteInput): Saga<boolean> {
296
+ const table = extractTablePart(input.recordId);
297
+ const columns = columnsFor(env, table);
298
+ if (!columns) return false;
299
+ const [confirmed, ownPending, peerPending] = (yield fx.state.read((s) => [
300
+ s.versions.has(input.recordId),
301
+ s.outbox.some((i) => i.recordId === input.recordId),
302
+ s.peerWrites.has(input.recordId),
303
+ ])) as [boolean, boolean, boolean];
304
+ if (confirmed || ownPending || peerPending) return false;
305
+ const rid = parseRecordIdString(input.recordId);
306
+ const existing = yield* readBefore(rid);
307
+ let record = existing;
308
+ if (!existing) {
309
+ const params = parseParams(columns as never, input.data ?? {});
310
+ const tx = planPeerCreateTx({ recordId: rid, data: params });
311
+ try {
312
+ const res = (yield fx.local.execute(tx.query, tx.vars)) as unknown;
313
+ record = res && typeof res === 'object' && !Array.isArray(res) ? (res as Record<string, unknown>) : null;
314
+ } catch (error) {
315
+ yield fx.emit({ type: 'log', level: 'debug', message: 'peer create refused by the local store', data: { recordId: input.recordId, error } });
316
+ return false;
317
+ }
318
+ }
319
+ // An existing unconfirmed row is another tab's copy of the same peer create.
320
+ if (!record) return false;
321
+ try {
322
+ yield fx.ssp.ingest([{ table, op: 'CREATE', id: input.recordId, record: { ...record, _00_rv: 0 } }]);
323
+ } catch (error) {
324
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a peer create', data: { recordId: input.recordId, error } });
325
+ }
326
+ const now = (yield fx.now()) as number;
327
+ yield fx.state.update(R.setPeerWrite(input.recordId, { kind: 'create', table, expiresAt: now + input.ttlMs }));
328
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, { type: 'PeerWriteExpired', id: input.recordId });
329
+ return true;
330
+ }
331
+
332
+ /**
333
+ * A channel peer deleted a row: hide it through the overlay, keep it in the
334
+ * store. Undoing is then just forgetting the entry; a confirmed delete has
335
+ * taken the row out of the server's membership by the time it expires.
336
+ */
337
+ export function* peerDelete(env: SagaEnv, input: PeerWriteInput): Saga<boolean> {
338
+ const table = extractTablePart(input.recordId);
339
+ if (!columnsFor(env, table)) return false;
340
+ const [known, confirmed] = (yield fx.state.read((s) => [s.peerWrites.get(input.recordId), s.versions.has(input.recordId)])) as [
341
+ PeerWrite | undefined,
342
+ boolean,
343
+ ];
344
+ if (known?.kind === 'create' && !confirmed) {
345
+ // Deleting a row the same peer created and nobody confirmed: just undo it.
346
+ yield fx.timer.clear(`peer:${input.recordId}`);
347
+ yield* undoPeerCreate(table, input.recordId);
348
+ return true;
349
+ }
350
+ const exists = (yield* readBefore(parseRecordIdString(input.recordId))) !== null;
351
+ if (!exists) return false;
352
+ const now = (yield fx.now()) as number;
353
+ yield fx.state.update(R.setPeerWrite(input.recordId, { kind: 'delete', table, expiresAt: now + input.ttlMs }));
354
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, { type: 'PeerWriteExpired', id: input.recordId });
355
+ return true;
356
+ }
357
+
358
+ /** Timer target: a peer's write stops showing as unconfirmed. */
359
+ export function* peerWriteExpired(id: string): Saga<void> {
360
+ const [write, confirmed, ownPending] = (yield fx.state.read((s) => [
361
+ s.peerWrites.get(id),
362
+ s.versions.has(id),
363
+ s.outbox.some((i) => i.recordId === id),
364
+ ])) as [PeerWrite | undefined, boolean, boolean];
365
+ if (!write) return;
366
+ if (write.kind === 'create' && !confirmed && !ownPending) {
367
+ yield* undoPeerCreate(write.table, id);
368
+ return;
369
+ }
370
+ yield fx.state.update(R.clearPeerWrite(id));
371
+ }
372
+
373
+ function* undoPeerCreate(table: string, id: string): Saga<void> {
374
+ const rid = parseRecordIdString(id);
375
+ const before = yield* readBefore(rid);
376
+ try {
377
+ const tx = planPeerUndoTx(rid);
378
+ yield fx.local.execute(tx.query, tx.vars);
379
+ if (before) yield fx.ssp.ingest([{ table, op: 'DELETE', id, record: before }]);
380
+ } catch (error) {
381
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not undo an unconfirmed peer create', data: { recordId: id, error } });
382
+ }
383
+ yield fx.state.update(R.clearPeerWrite(id));
384
+ }
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
+ }