@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.270

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2279 -81
  4. package/dist/index.js +13490 -2585
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +939 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +528 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +443 -0
  54. package/src/modules/auth/impersonation.test.ts +355 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +322 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +283 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +603 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
@@ -0,0 +1,166 @@
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, 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 { planCreateTx, planDeferredOutboxRowTx, planDeleteTx, planLocalOnlyUpdateTx, planUpdateTx, type LocalTx } from './rows';
12
+
13
+ export interface WriteInput {
14
+ kind: MutationEventType;
15
+ recordId: string;
16
+ data?: Record<string, unknown>;
17
+ options?: UpdateOptions;
18
+ }
19
+
20
+ export interface WriteResult {
21
+ mutationId: string;
22
+ record: Record<string, unknown> | null;
23
+ }
24
+
25
+ const debounceKey = (input: WriteInput): string | null => {
26
+ const d = input.options?.debounced;
27
+ if (!d) return null;
28
+ const opts = d === true ? {} : d;
29
+ const keyMode = opts.key ?? 'recordId_x_fields';
30
+ return keyMode === 'recordId'
31
+ ? input.recordId
32
+ : `${input.recordId}::${Object.keys(input.data ?? {}).sort().join(',')}`;
33
+ };
34
+ const debounceDelay = (input: WriteInput): number => {
35
+ const d = input.options?.debounced;
36
+ return d && d !== true && typeof d.delay === 'number' ? d.delay : 300;
37
+ };
38
+
39
+ /**
40
+ * One optimistic write: read the previous row (update/delete), run the local
41
+ * transaction that writes the row AND its outbox entry, publish the outbox
42
+ * item (which puts the id into the overlay and dirties the table's queries),
43
+ * feed the circuit, and start a drain. A debounced update applies locally at
44
+ * once but accumulates into one outbox row written on flush.
45
+ */
46
+ export function* write(env: SagaEnv, input: WriteInput): Saga<WriteResult> {
47
+ const table = extractTablePart(input.recordId);
48
+ const columns = columnsFor(env, table);
49
+ if (!columns) throw new Error(`Table ${table} not found`);
50
+ const rid = parseRecordIdString(input.recordId);
51
+ const params = input.data ? parseParams(columns as never, input.data) : undefined;
52
+ const key = input.kind === 'update' ? debounceKey(input) : null;
53
+ if (key !== null) return yield* debouncedUpdate(input, key, rid, table, params ?? {});
54
+ const mutationId = (yield fx.id('mutation')) as string;
55
+ const now = (yield fx.now()) as number;
56
+ let before: Record<string, unknown> | null = null;
57
+ if (input.kind !== 'create') before = yield* readBefore(rid);
58
+ const tx = planTx(input.kind, { recordId: rid, mutationId: parseRecordIdString(mutationId), table, data: params, before, now });
59
+ return yield* commitWrite(input.kind, tx, { mutationId, recordId: input.recordId, table, before, now });
60
+ }
61
+
62
+ function planTx(kind: MutationEventType, planInput: Parameters<typeof planCreateTx>[0]): LocalTx {
63
+ if (kind === 'create') return planCreateTx(planInput);
64
+ if (kind === 'update') return planUpdateTx(planInput);
65
+ return planDeleteTx(planInput);
66
+ }
67
+
68
+ function* readBefore(rid: RecordId<string>): Saga<Record<string, unknown> | null> {
69
+ const res = (yield fx.local.query('SELECT * FROM ONLY $id', { id: rid })) as unknown[];
70
+ const row = Array.isArray(res) ? res[0] : null;
71
+ return row && typeof row === 'object' && !Array.isArray(row) ? (row as Record<string, unknown>) : null;
72
+ }
73
+
74
+ interface CommitCtx {
75
+ mutationId: string;
76
+ recordId: string;
77
+ table: string;
78
+ before: Record<string, unknown> | null;
79
+ now: number;
80
+ }
81
+
82
+ function* commitWrite(kind: MutationEventType, tx: LocalTx, ctx: CommitCtx): Saga<WriteResult> {
83
+ const result = (yield fx.local.execute(tx.query, tx.vars)) as unknown;
84
+ const target = kind === 'update' ? (result as { target?: unknown } | undefined)?.target : result;
85
+ const record = kind === 'delete' || !target || typeof target !== 'object' ? null : (target as Record<string, unknown>);
86
+ yield fx.state.update(
87
+ R.outboxPush({ id: ctx.mutationId, type: kind, recordId: ctx.recordId, table: ctx.table, status: 'pending', ackedAt: null, attempts: 0 })
88
+ );
89
+ const version = kind === 'create' ? 1 : typeof record?._00_rv === 'number' ? (record._00_rv as number) : ((ctx.before?._00_rv as number) ?? 0) + 1;
90
+ const circuitRecord = kind === 'delete' ? (ctx.before ?? {}) : { ...record, _00_rv: version };
91
+ const circuitTuple = { table: ctx.table, op: kind.toUpperCase() as 'CREATE' | 'UPDATE' | 'DELETE', id: ctx.recordId, record: circuitRecord };
92
+ try {
93
+ yield fx.ssp.ingest([circuitTuple]);
94
+ if (kind === 'delete') yield fx.state.update(R.deleteVersions([ctx.recordId]));
95
+ else yield fx.state.update(R.setVersions([[ctx.recordId, version]]));
96
+ } catch (error) {
97
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a local write', data: { recordId: ctx.recordId, error } });
98
+ }
99
+ yield fx.emit({
100
+ type: 'mutation:event',
101
+ 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 },
102
+ });
103
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
104
+ if (role !== 'solo') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [circuitTuple] } });
105
+ if (role === 'follower') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: ctx.mutationId } });
106
+ else yield fx.dispatch({ type: 'Drain' });
107
+ return { mutationId: ctx.mutationId, record };
108
+ }
109
+
110
+ /** Apply locally now, remember the merged patch, write the outbox row on flush. */
111
+ function* debouncedUpdate(
112
+ input: WriteInput,
113
+ key: string,
114
+ rid: RecordId<string>,
115
+ table: string,
116
+ params: Record<string, unknown>
117
+ ): Saga<WriteResult> {
118
+ const existing = (yield fx.state.read((s) => s.pendingWrites.get(key))) as PendingWrite | undefined;
119
+ const now = (yield fx.now()) as number;
120
+ const before = existing ? existing.before : yield* readBefore(rid);
121
+ const local = planLocalOnlyUpdateTx({ recordId: rid, data: params });
122
+ const res = (yield fx.local.execute(local.query, local.vars)) as { target?: Record<string, unknown> } | undefined;
123
+ const record = res?.target ?? null;
124
+ yield fx.state.update(R.mergePendingWrite({ key, table, recordId: input.recordId, data: params, before, firstAt: existing?.firstAt ?? now }));
125
+ yield fx.state.update(R.markTableDirty(table));
126
+ const version = typeof record?._00_rv === 'number' ? (record._00_rv as number) : 0;
127
+ const tuple = { table, op: 'UPDATE' as const, id: input.recordId, record: { ...record, _00_rv: version } };
128
+ try {
129
+ yield fx.ssp.ingest([tuple]);
130
+ yield fx.state.update(R.setVersions([[input.recordId, version]]));
131
+ } catch (error) {
132
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a local write', data: { recordId: input.recordId, error } });
133
+ }
134
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
135
+ if (role !== 'solo') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [tuple] } });
136
+ yield fx.timer.set(`debounce:${key}`, debounceDelay(input), { type: 'FlushWrite', key });
137
+ return { mutationId: '', record };
138
+ }
139
+
140
+ /** Timer target: turn the accumulated patch into one outbox row and drain. */
141
+ export function* flushWrite(env: SagaEnv, key: string): Saga<void> {
142
+ const pending = (yield fx.state.read((s) => s.pendingWrites.get(key))) as PendingWrite | undefined;
143
+ if (!pending) return;
144
+ yield fx.state.update(R.clearPendingWrite(key));
145
+ const mutationId = (yield fx.id('mutation')) as string;
146
+ const now = (yield fx.now()) as number;
147
+ const rid = parseRecordIdString(pending.recordId);
148
+ const insert = planDeferredOutboxRowTx({
149
+ recordId: rid,
150
+ mutationId: parseRecordIdString(mutationId),
151
+ table: pending.table,
152
+ data: { ...pending.data },
153
+ before: pending.before,
154
+ now,
155
+ });
156
+ yield fx.local.execute(insert.query, insert.vars);
157
+ yield fx.state.update(R.outboxPush({ id: mutationId, type: 'update', recordId: pending.recordId, table: pending.table, status: 'pending', ackedAt: null, attempts: 0 }));
158
+ yield fx.emit({
159
+ type: 'mutation:event',
160
+ event: { type: 'update', mutation_id: parseRecordIdString(mutationId), record_id: rid, data: { ...pending.data }, beforeRecord: pending.before ?? undefined, tableName: pending.table },
161
+ });
162
+ const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
163
+ if (role === 'follower') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId } });
164
+ else yield fx.dispatch({ type: 'Drain' });
165
+ }
166
+
package/src/otel/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Level } from 'pino';
2
- import { PinoTransmit } from '../types';
1
+ import type { Level } from 'pino';
2
+ import type { PinoTransmit } from '../types';
3
3
 
4
4
  // Map pino levels to OTEL severity numbers
5
5
  // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#severity-fields
@@ -32,7 +32,7 @@ async function loadOtelModules(otelEndpoint: string) {
32
32
  ]);
33
33
 
34
34
  const resource = resourceFromAttributes({
35
- [ATTR_SERVICE_NAME]: 'spooky-client',
35
+ [ATTR_SERVICE_NAME]: 'sp00ky-client',
36
36
  });
37
37
 
38
38
  const exporter = new OTLPLogExporter({
@@ -61,7 +61,7 @@ async function loadOtelModules(otelEndpoint: string) {
61
61
  * ```ts
62
62
  * import { createOtelTransmit } from '@spooky-sync/core/otel';
63
63
  *
64
- * new SpookyClient({
64
+ * new Sp00kyClient({
65
65
  * // ...
66
66
  * otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
67
67
  * });
@@ -74,6 +74,7 @@ export function createOtelTransmit(endpoint: string, level: Level = 'info'): Pin
74
74
  return {
75
75
  level: level,
76
76
  send: (levelLabel: string, logEvent: any) => {
77
+ // oxlint-disable-next-line promise/always-return
77
78
  otelReady.then((getOtelLogger) => {
78
79
  try {
79
80
  const messages = [...logEvent.messages];
@@ -81,15 +82,15 @@ export function createOtelTransmit(endpoint: string, level: Level = 'info'): Pin
81
82
 
82
83
  // Construct the message body
83
84
  let body = '';
84
- const msg = messages.pop();
85
+ const lastMsg = messages.pop();
85
86
 
86
- if (typeof msg === 'string') {
87
- body = msg;
88
- } else if (msg) {
89
- body = JSON.stringify(msg);
87
+ if (typeof lastMsg === 'string') {
88
+ body = lastMsg;
89
+ } else if (lastMsg) {
90
+ body = JSON.stringify(lastMsg);
90
91
  }
91
92
 
92
- let category = 'spooky-client::unknown';
93
+ let category = 'sp00ky-client::unknown';
93
94
 
94
95
  const attributes = {};
95
96
  for (const msg of messages) {
@@ -114,9 +115,11 @@ export function createOtelTransmit(endpoint: string, level: Level = 'info'): Pin
114
115
  timestamp: new Date(logEvent.ts),
115
116
  });
116
117
  } catch (e) {
118
+ // oxlint-disable-next-line no-console
117
119
  console.warn('Failed to transmit log to OTEL endpoint', e);
118
120
  }
119
121
  }).catch((e) => {
122
+ // oxlint-disable-next-line no-console
120
123
  console.warn('Failed to load OpenTelemetry modules', e);
121
124
  });
122
125
  },
@@ -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
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
5
+ import * as R from '../state/reducers';
6
+ import { defaultEnv } from './env';
7
+ import { fetchRows } from './fetch.saga';
8
+ import type { StatementResult } from '../kernel/effects';
9
+
10
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {} } }] } as any);
11
+ const ok = (result: unknown): StatementResult => ({ status: 'OK', result });
12
+ const live = { phase: 'live' } as const;
13
+ const primed = (s: ReturnType<typeof buildState>) => ({ ...s, primed: true });
14
+
15
+ describe('fetchRows', () => {
16
+ it('waits for the prime and returns on an empty plan (resetting the attempt counter)', async () => {
17
+ const s = R.patchSync({ fetchAttempts: 3 })(buildState());
18
+ const out = await runPure(fetchRows(env), { state: s, handlers: { 'state.wait': (_e, ctx) => void (ctx.state = { ...ctx.state, primed: true }) } });
19
+ expect(out.state.sync.fetchAttempts).toBe(0);
20
+ expect(out.log.filter((e) => e.kind === 'remote.query')).toHaveLength(0);
21
+ });
22
+
23
+ it('fetches the deduped set once, writes + ingests bodies, records versions, balances fetch depth', async () => {
24
+ const s = primed(
25
+ buildState(
26
+ [
27
+ buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 2], ['thing:2', 1]] }),
28
+ buildEntry({ def: { hash: 'b' }, lifecycle: live, remoteArray: [['thing:2', 1], ['thing:3', 1]] }),
29
+ ],
30
+ R.setVersions([['thing:3', 1], ['thing:1', 1]]),
31
+ R.outboxReplace([buildOutboxItem({ type: 'delete', recordId: 'thing:2' })])
32
+ )
33
+ );
34
+ const depths: number[] = [];
35
+ const out = await runPure(fetchRows(env), {
36
+ state: s,
37
+ epoch: 7,
38
+ handlers: {
39
+ 'remote.query': (e: any, ctx) => {
40
+ depths.push(ctx.state.queries.get('a')!.lifecycle.fetchDepth);
41
+ expect(e.sql).toBe('SELECT * FROM $ids');
42
+ expect(e.vars.ids).toEqual([new RecordId('thing', '1')]);
43
+ return [ok([{ id: new RecordId('thing', '1'), a: 'x', junk: true }])];
44
+ },
45
+ 'local.execute': () => undefined,
46
+ 'ssp.ingest': () => undefined,
47
+ },
48
+ });
49
+ expect(depths).toEqual([1]);
50
+ expect(out.state.queries.get('a')!.lifecycle.fetchDepth).toBe(0);
51
+ expect(out.state.versions.get('thing:1')).toBe(2);
52
+ const exec = out.log.find((e) => e.kind === 'local.execute') as any;
53
+ expect(exec.vars.content0).toEqual({ a: 'x', _00_rv: 2 });
54
+ expect(exec.epoch).toBe(7);
55
+ expect(out.log.findIndex((e) => e.kind === 'local.epoch')).toBeLessThan(out.log.findIndex((e) => e.kind === 'all'));
56
+ const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
57
+ expect(ingest.records).toEqual([{ table: 'thing', op: 'UPDATE', id: 'thing:1', record: { id: new RecordId('thing', '1'), a: 'x', _00_rv: 2 } }]);
58
+ expect(out.dispatched).toEqual([{ type: 'SyncOutcome', ok: true, error: undefined }]);
59
+ expect(out.state.dirty.has('a')).toBe(true);
60
+ });
61
+
62
+ it('a body the server does not return is remembered at the requested version so the plan converges', async () => {
63
+ const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1], ['other:2', 1]] })]));
64
+ let calls = 0;
65
+ const out = await runPure(fetchRows(env), {
66
+ state: s,
67
+ handlers: {
68
+ 'remote.query': () => {
69
+ calls++;
70
+ return [ok([{ id: new RecordId('other', '2'), z: 1 }, { nope: true }])];
71
+ },
72
+ 'local.execute': () => undefined,
73
+ 'ssp.ingest': () => undefined,
74
+ },
75
+ });
76
+ expect(calls).toBe(1);
77
+ expect(out.state.versions.get('thing:1')).toBe(1);
78
+ expect(out.state.versions.get('other:2')).toBe(1);
79
+ const extra = await runPure(fetchRows(env), {
80
+ state: s,
81
+ handlers: {
82
+ 'remote.query': () => [ok([{ id: new RecordId('thing', '1') }, { id: new RecordId('thing', 'unrequested') }])],
83
+ 'local.execute': () => undefined,
84
+ 'ssp.ingest': (e: any) => {
85
+ expect(e.records[1]).toMatchObject({ id: 'thing:unrequested', record: { _00_rv: 0 } });
86
+ },
87
+ },
88
+ });
89
+ expect(extra.state.versions.has('thing:unrequested')).toBe(false);
90
+ const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
91
+ expect(ingest.records[0]).toMatchObject({ table: 'other', op: 'CREATE', record: { z: 1, _00_rv: 1 } });
92
+ });
93
+
94
+ it('failures back off: transport error, ERR statement, non-array result, local write error; ingest errors are logged only', async () => {
95
+ const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })]));
96
+ const expectBackoff = (out: Awaited<ReturnType<typeof runPure>>, attempts: number) => {
97
+ expect(out.state.sync.fetchAttempts).toBe(attempts);
98
+ expect(out.timers.get('fetch')).toEqual({ ms: 500 * 2 ** (attempts - 1), event: { type: 'FetchRows' } });
99
+ expect(out.state.queries.get('a')!.lifecycle.fetchDepth).toBe(0);
100
+ expect(out.dispatched.at(-1)).toMatchObject({ type: 'SyncOutcome', ok: false });
101
+ };
102
+ expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => { throw new Error('offline'); } } }), 1);
103
+ expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => [{ status: 'ERR', error: 'x' }] } }), 1);
104
+ expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => [ok('nope')] } }), 1);
105
+ expectBackoff(
106
+ await runPure(fetchRows(env), {
107
+ state: R.patchSync({ fetchAttempts: 1 })(s),
108
+ handlers: {
109
+ 'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])],
110
+ 'local.execute': () => {
111
+ throw new Error('stale epoch');
112
+ },
113
+ },
114
+ }),
115
+ 2
116
+ );
117
+ const ingestFail = await runPure(fetchRows(env), {
118
+ state: s,
119
+ handlers: {
120
+ 'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])],
121
+ 'local.execute': () => undefined,
122
+ 'ssp.ingest': () => {
123
+ throw new Error('wasm');
124
+ },
125
+ },
126
+ });
127
+ expect(ingestFail.state.sync.fetchAttempts).toBe(0);
128
+ expect(ingestFail.emitted).toContainEqual(expect.objectContaining({ message: 'circuit ingest failed' }));
129
+ });
130
+
131
+ it('a leader relays landed bodies to followers', async () => {
132
+ const s = { ...primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })])), tabRole: 'leader' as const };
133
+ const out = await runPure(fetchRows(env), {
134
+ state: s,
135
+ handlers: { 'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
136
+ });
137
+ expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ id: 'thing:1' })] } });
138
+ });
139
+
140
+ it('loops until the plan is empty', async () => {
141
+ const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })]));
142
+ let calls = 0;
143
+ const out = await runPure(fetchRows(env), {
144
+ state: s,
145
+ handlers: {
146
+ 'remote.query': (_e, ctx) => {
147
+ calls++;
148
+ if (calls === 1) ctx.state = R.commitMembership('a', [['thing:1', 1], ['thing:2', 1]], true)(ctx.state);
149
+ return [ok([])];
150
+ },
151
+ },
152
+ });
153
+ expect(calls).toBe(2);
154
+ expect(out.state.versions.get('thing:2')).toBe(1);
155
+ });
156
+ });
@@ -0,0 +1,115 @@
1
+ import type { RecordId } from 'surrealdb';
2
+ import type { Saga } from '../kernel/saga';
3
+ import type { Settled, StatementResult } from '../kernel/effects';
4
+ import { fx } from '../kernel/effects';
5
+ import { backoffMs } from '../kernel/constants';
6
+ import type { IngestRecord } from '../services/stream-processor/index';
7
+ import type { ClientState } from '../state/client-state';
8
+ import * as R from '../state/reducers';
9
+ import { planFetch } from '../state/selectors';
10
+ import { encodeRecordId, parseRecordIdString } from '../utils/index';
11
+ import { cleanRecord } from '../utils/parser';
12
+ import type { SagaEnv } from './env';
13
+ import * as sql from './sql';
14
+
15
+ /**
16
+ * The unified row fetcher (the poke analogue). Runs on the serial `fetch`
17
+ * lane: computes the cross-query set of bodies missing or stale locally,
18
+ * pulls them in parallel chunks, writes + ingests them, and loops until the
19
+ * plan is empty. A row shared by N queries is fetched once. Bodies are never
20
+ * deleted here; ids that left membership simply stop rendering.
21
+ */
22
+ export function* fetchRows(env: SagaEnv): Saga<void> {
23
+ yield fx.state.wait((s) => s.primed);
24
+ for (;;) {
25
+ const state = (yield fx.state.read((s) => s)) as ClientState;
26
+ const plan = planFetch(state);
27
+ if (plan.chunks.length === 0) {
28
+ if (state.sync.fetchAttempts !== 0) yield fx.state.update(R.patchSync({ fetchAttempts: 0 }));
29
+ return;
30
+ }
31
+ // The engine's epoch, not a mirrored counter: it also moves on transport
32
+ // failover, and a stale value makes the store drop every body write.
33
+ const epoch = (yield fx.local.epoch()) as number;
34
+ yield fx.state.update(R.compose(...plan.hashes.map((h) => R.applyLifecycle(h, { type: 'fetch-begin' }))));
35
+ let failed = 0;
36
+ try {
37
+ const results = (yield fx.all(
38
+ plan.chunks.map((ids) => fx.remote.query(sql.bodySelect(), { ids: ids.map(parseRecordIdString) }, env.remoteTimeoutMs))
39
+ )) as Settled[];
40
+ for (let i = 0; i < results.length; i++) {
41
+ const r = results[i];
42
+ const first = r.ok ? (r.value as StatementResult[])[0] : undefined;
43
+ if (!r.ok || !first || first.status === 'ERR' || !Array.isArray(first.result)) {
44
+ failed++;
45
+ continue;
46
+ }
47
+ const landed = yield* landChunk(env, plan.chunks[i], first.result as Array<Record<string, unknown>>, plan.versions, state, epoch);
48
+ if (!landed) failed++;
49
+ }
50
+ } finally {
51
+ yield fx.state.update(R.compose(...plan.hashes.map((h) => R.applyLifecycle(h, { type: 'fetch-end' }))));
52
+ }
53
+ yield fx.dispatch({ type: 'SyncOutcome', ok: failed === 0, error: failed > 0 ? 'body fetch failed' : undefined });
54
+ if (failed > 0) {
55
+ const attempt = (yield fx.state.read((s) => s.sync.fetchAttempts)) as number;
56
+ yield fx.state.update(R.patchSync({ fetchAttempts: attempt + 1 }));
57
+ yield fx.timer.set('fetch', backoffMs(attempt), { type: 'FetchRows' });
58
+ return;
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Write one chunk's bodies to the store and circuit; record their versions.
65
+ *
66
+ * Shared with the LIVE path: a body that arrives on a notification is landed
67
+ * through here so it is indistinguishable from a fetched one, which is what
68
+ * makes `planFetch` stop asking for it.
69
+ */
70
+ export function* landChunk(
71
+ env: SagaEnv,
72
+ requested: string[],
73
+ rows: Array<Record<string, unknown>>,
74
+ versions: ReadonlyMap<string, number>,
75
+ state: ClientState,
76
+ epoch: number
77
+ ): Saga<boolean> {
78
+ const bodies: Array<{ id: unknown; content: Record<string, unknown> }> = [];
79
+ const ingest: IngestRecord[] = [];
80
+ const seen = new Set<string>();
81
+ for (const row of rows) {
82
+ const rid = row?.id as RecordId<string> | undefined;
83
+ if (!rid || typeof rid !== 'object') continue;
84
+ const id = encodeRecordId(rid);
85
+ const table = String(rid.table);
86
+ const version = versions.get(id) ?? 0;
87
+ const columns = env.schema.tables.find((t) => t.name === table)?.columns;
88
+ const cleaned = columns ? cleanRecord(columns, row) : row;
89
+ const { id: _id, ...content } = cleaned;
90
+ bodies.push({ id: rid, content: { ...content, _00_rv: version } });
91
+ ingest.push({ table, op: state.versions.has(id) ? 'UPDATE' : 'CREATE', id, record: { ...cleaned, _00_rv: version } });
92
+ seen.add(id);
93
+ }
94
+ if (bodies.length > 0) {
95
+ try {
96
+ const tx = sql.upsertBodiesTx(bodies);
97
+ yield fx.local.execute(tx.query, tx.vars, epoch);
98
+ } catch (error) {
99
+ yield fx.emit({ type: 'log', level: 'warn', message: 'body write failed', data: { error } });
100
+ return false;
101
+ }
102
+ try {
103
+ yield fx.ssp.ingest(ingest);
104
+ } catch (error) {
105
+ yield fx.emit({ type: 'log', level: 'warn', message: 'circuit ingest failed', data: { error } });
106
+ }
107
+ if (state.tabRole === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: ingest } });
108
+ }
109
+ // Ids the server did not return (deleted or unreadable upstream) count as
110
+ // known at the requested version, so the plan stops asking for them; their
111
+ // absence from the store is what the render shows.
112
+ const entries: Array<readonly [string, number]> = requested.map((id) => [id, versions.get(id) as number]);
113
+ yield fx.state.update(R.setVersions(entries));
114
+ return true;
115
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { queryHashInput, viewKeyInput } from './hash';
3
+
4
+ describe('hash inputs (golden against the previous DataModule formulas)', () => {
5
+ const input = { surql: 'SELECT * FROM t WHERE a = $a', params: { a: 1, b: 'x' } };
6
+ it('salted key is JSON.stringify({surql, params, sessionId}) in that key order', () => {
7
+ expect(queryHashInput(input, 'sess')).toBe(JSON.stringify({ ...input, sessionId: 'sess' }));
8
+ expect(queryHashInput(input, null)).toBe(JSON.stringify({ ...input, sessionId: null }));
9
+ });
10
+ it('view key is the same input minus the salt', () => {
11
+ expect(viewKeyInput(input)).toBe(JSON.stringify(input));
12
+ expect(viewKeyInput(input)).not.toBe(queryHashInput(input, 'sess'));
13
+ });
14
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hash inputs for the two query keys. Kept byte-identical to the previous
3
+ * implementation (`DataModule.calculateHash` / `calculateMembershipKey`):
4
+ * the salted key names the remote `_00_query` row, the unsalted one names the
5
+ * durable `_00_view` row, and both are `sha256(JSON.stringify(input))`.
6
+ */
7
+ export interface QueryKeyInput {
8
+ surql: string;
9
+ params: Record<string, unknown>;
10
+ }
11
+
12
+ /** Session-salted: two tabs of one user must not share a remote view. */
13
+ export const queryHashInput = (input: QueryKeyInput, sessionId: string | null): string =>
14
+ JSON.stringify({ surql: input.surql, params: input.params, sessionId });
15
+
16
+ /** Session-independent: survives reload and bucket switch. */
17
+ export const viewKeyInput = (input: QueryKeyInput): string =>
18
+ JSON.stringify({ surql: input.surql, params: input.params });