@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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 +2355 -81
  4. package/dist/index.js +13247 -2595
  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 +793 -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 +507 -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 +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -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 +317 -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 +280 -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 +591 -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,467 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { RelationPlan, WhereNode } from '@spooky-sync/query-builder';
3
+ import { looksLikeRecordId, resolveRelations, sortRows, stableKey } from './relation-resolver';
4
+ import {
5
+ RelationCycleError,
6
+ type RelationFetch,
7
+ type Row,
8
+ type RowFetcher,
9
+ } from './cache-engine';
10
+
11
+ /**
12
+ * Deterministic in-memory store used as the reference for decomposition. It
13
+ * implements the SAME fetch primitive the real engines expose
14
+ * (`WHERE matchField IN keys AND <where>`, ORDER BY, projection) so a passing
15
+ * resolver here is correct independent of SurrealDB/SQLite specifics. Rows are
16
+ * deep-cloned on read (as a real engine returns fresh objects), so nested
17
+ * attachment never mutates the store.
18
+ */
19
+ class MemStore implements RowFetcher {
20
+ constructor(private tables: Record<string, Row[]>) {}
21
+
22
+ async fetchRelation(req: RelationFetch): Promise<Row[]> {
23
+ const keySet = new Set(req.keys.map(stableKey));
24
+ let rows = (this.tables[req.table] ?? []).filter((r) =>
25
+ keySet.has(stableKey(r[req.matchField]))
26
+ );
27
+ if (req.where) rows = rows.filter((r) => matchesWhere(r, req.where!));
28
+ rows = rows.map((r) => structuredClone(r));
29
+ if (req.orderBy) rows = sortRows(rows, req.orderBy);
30
+ if (req.select) {
31
+ rows = rows.map((r) => {
32
+ const out: Row = {};
33
+ for (const f of ['id', ...req.select!]) if (f in r) out[f] = r[f];
34
+ return out;
35
+ });
36
+ }
37
+ return rows;
38
+ }
39
+ }
40
+
41
+ function matchesWhere(row: Row, where: WhereNode[]): boolean {
42
+ const cmp = (c: { field: string; op: string; value: unknown }): boolean => {
43
+ const v = row[c.field];
44
+ switch (c.op) {
45
+ case '=':
46
+ return stableKey(v) === stableKey(c.value);
47
+ case '!=':
48
+ return stableKey(v) !== stableKey(c.value);
49
+ case '>':
50
+ return (v as number) > (c.value as number);
51
+ case '>=':
52
+ return (v as number) >= (c.value as number);
53
+ case '<':
54
+ return (v as number) < (c.value as number);
55
+ case '<=':
56
+ return (v as number) <= (c.value as number);
57
+ default:
58
+ return false;
59
+ }
60
+ };
61
+ return where.every((node) => {
62
+ if ('or' in node) return node.or.some(cmp);
63
+ return cmp(node);
64
+ });
65
+ }
66
+
67
+ // Convenience builder for a RelationPlan.
68
+ function rel(p: Partial<RelationPlan> & Pick<RelationPlan, 'alias' | 'table' | 'cardinality' | 'foreignKeyField'>): RelationPlan {
69
+ return p as RelationPlan;
70
+ }
71
+
72
+ describe('resolveRelations — decomposition', () => {
73
+ it('1. flat, no relations: leaves parents untouched', async () => {
74
+ const store = new MemStore({});
75
+ const parents: Row[] = [{ id: 'post:1', title: 'a' }];
76
+ await resolveRelations(parents, undefined, store);
77
+ expect(parents).toEqual([{ id: 'post:1', title: 'a' }]);
78
+ });
79
+
80
+ it('2a. one-to-one match: attaches the single related row', async () => {
81
+ const store = new MemStore({ user: [{ id: 'user:1', name: 'ana' }] });
82
+ const parents: Row[] = [{ id: 'post:1', author: 'user:1' }];
83
+ await resolveRelations(
84
+ parents,
85
+ [rel({ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author', limit: 1 })],
86
+ store
87
+ );
88
+ expect(parents[0].author).toEqual({ id: 'user:1', name: 'ana' });
89
+ });
90
+
91
+ it('2b. one-to-one no match: attaches null', async () => {
92
+ const store = new MemStore({ user: [] });
93
+ const parents: Row[] = [{ id: 'post:1', author: 'user:404' }];
94
+ await resolveRelations(
95
+ parents,
96
+ [rel({ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author', limit: 1 })],
97
+ store
98
+ );
99
+ expect(parents[0].author).toBeNull();
100
+ });
101
+
102
+ it('3. one-to-many: empty vs N children grouped per parent', async () => {
103
+ const store = new MemStore({
104
+ comment: [
105
+ { id: 'comment:1', post: 'post:1', body: 'x' },
106
+ { id: 'comment:2', post: 'post:1', body: 'y' },
107
+ { id: 'comment:3', post: 'post:2', body: 'z' },
108
+ ],
109
+ });
110
+ const parents: Row[] = [{ id: 'post:1' }, { id: 'post:2' }, { id: 'post:3' }];
111
+ await resolveRelations(
112
+ parents,
113
+ [rel({ alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'post' })],
114
+ store
115
+ );
116
+ expect((parents[0].comments as Row[]).map((c) => c.id)).toEqual(['comment:1', 'comment:2']);
117
+ expect((parents[1].comments as Row[]).map((c) => c.id)).toEqual(['comment:3']);
118
+ expect(parents[2].comments).toEqual([]);
119
+ });
120
+
121
+ it('4. per-parent LIMIT + ORDER applied within each group, not globally', async () => {
122
+ const store = new MemStore({
123
+ comment: [
124
+ { id: 'comment:1', post: 'post:1', rank: 3 },
125
+ { id: 'comment:2', post: 'post:1', rank: 1 },
126
+ { id: 'comment:3', post: 'post:1', rank: 2 },
127
+ { id: 'comment:4', post: 'post:2', rank: 5 },
128
+ { id: 'comment:5', post: 'post:2', rank: 4 },
129
+ ],
130
+ });
131
+ const parents: Row[] = [{ id: 'post:1' }, { id: 'post:2' }];
132
+ await resolveRelations(
133
+ parents,
134
+ [
135
+ rel({
136
+ alias: 'comments',
137
+ table: 'comment',
138
+ cardinality: 'many',
139
+ foreignKeyField: 'post',
140
+ orderBy: [['rank', 'asc']],
141
+ limit: 2,
142
+ }),
143
+ ],
144
+ store
145
+ );
146
+ // post:1 keeps its OWN top-2 by rank (1,2) — not a global top-2 (which would
147
+ // have starved post:2).
148
+ expect((parents[0].comments as Row[]).map((c) => c.rank)).toEqual([1, 2]);
149
+ expect((parents[1].comments as Row[]).map((c) => c.rank)).toEqual([4, 5]);
150
+ });
151
+
152
+ it('5. sub-where filters the relation batch', async () => {
153
+ const store = new MemStore({
154
+ comment: [
155
+ { id: 'comment:1', post: 'post:1', hidden: false },
156
+ { id: 'comment:2', post: 'post:1', hidden: true },
157
+ ],
158
+ });
159
+ const parents: Row[] = [{ id: 'post:1' }];
160
+ await resolveRelations(
161
+ parents,
162
+ [
163
+ rel({
164
+ alias: 'comments',
165
+ table: 'comment',
166
+ cardinality: 'many',
167
+ foreignKeyField: 'post',
168
+ where: [{ field: 'hidden', op: '=', value: false }],
169
+ }),
170
+ ],
171
+ store
172
+ );
173
+ expect((parents[0].comments as Row[]).map((c) => c.id)).toEqual(['comment:1']);
174
+ });
175
+
176
+ it('6. 2-level nested with cross-level $parent dependency (many -> one)', async () => {
177
+ const store = new MemStore({
178
+ comment: [
179
+ { id: 'comment:1', post: 'post:1', author: 'user:1' },
180
+ { id: 'comment:2', post: 'post:1', author: 'user:2' },
181
+ ],
182
+ user: [
183
+ { id: 'user:1', name: 'ana' },
184
+ { id: 'user:2', name: 'bob' },
185
+ ],
186
+ });
187
+ const parents: Row[] = [{ id: 'post:1' }];
188
+ await resolveRelations(
189
+ parents,
190
+ [
191
+ rel({
192
+ alias: 'comments',
193
+ table: 'comment',
194
+ cardinality: 'many',
195
+ foreignKeyField: 'post',
196
+ relations: [
197
+ rel({ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author', limit: 1 }),
198
+ ],
199
+ }),
200
+ ],
201
+ store
202
+ );
203
+ const comments = parents[0].comments as Row[];
204
+ expect(comments.map((c) => (c.author as Row).name)).toEqual(['ana', 'bob']);
205
+ });
206
+
207
+ it('7. 3-level deep, mixed cardinality, O(depth) batch count', async () => {
208
+ let batches = 0;
209
+ const base = new MemStore({
210
+ comment: [{ id: 'comment:1', post: 'post:1', author: 'user:1' }],
211
+ user: [{ id: 'user:1', org: 'org:1' }],
212
+ org: [{ id: 'org:1', name: 'acme' }],
213
+ });
214
+ const counting: RowFetcher = {
215
+ fetchRelation: (req) => {
216
+ batches++;
217
+ return base.fetchRelation(req);
218
+ },
219
+ };
220
+ const parents: Row[] = [{ id: 'post:1' }];
221
+ await resolveRelations(
222
+ parents,
223
+ [
224
+ rel({
225
+ alias: 'comments',
226
+ table: 'comment',
227
+ cardinality: 'many',
228
+ foreignKeyField: 'post',
229
+ relations: [
230
+ rel({
231
+ alias: 'author',
232
+ table: 'user',
233
+ cardinality: 'one',
234
+ foreignKeyField: 'author',
235
+ limit: 1,
236
+ relations: [
237
+ rel({ alias: 'org', table: 'org', cardinality: 'one', foreignKeyField: 'org', limit: 1 }),
238
+ ],
239
+ }),
240
+ ],
241
+ }),
242
+ ],
243
+ counting
244
+ );
245
+ const org = ((parents[0].comments as Row[])[0].author as Row).org as Row;
246
+ expect(org.name).toBe('acme');
247
+ // One batch per level (3 levels), NOT per row.
248
+ expect(batches).toBe(3);
249
+ });
250
+
251
+ it('8. null/absent foreign keys mid-tree yield empty, no spurious fetch', async () => {
252
+ let fetchedKeys: unknown[] = [];
253
+ const base = new MemStore({ user: [{ id: 'user:1', name: 'ana' }] });
254
+ const spy: RowFetcher = {
255
+ fetchRelation: (req) => {
256
+ fetchedKeys = req.keys;
257
+ return base.fetchRelation(req);
258
+ },
259
+ };
260
+ const parents: Row[] = [
261
+ { id: 'post:1', author: 'user:1' },
262
+ { id: 'post:2', author: null },
263
+ { id: 'post:3' }, // absent
264
+ ];
265
+ await resolveRelations(
266
+ parents,
267
+ [rel({ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author', limit: 1 })],
268
+ spy
269
+ );
270
+ expect(fetchedKeys).toEqual(['user:1']); // null/absent excluded
271
+ expect(parents[0].author).toEqual({ id: 'user:1', name: 'ana' });
272
+ expect(parents[1].author).toBeNull();
273
+ expect(parents[2].author).toBeNull();
274
+ });
275
+
276
+ it('9. nesting past MAX_RELATION_DEPTH throws RelationCycleError', async () => {
277
+ // Build a plan nested deeper than the guard.
278
+ let leaf: RelationPlan = rel({ alias: 'r', table: 't', cardinality: 'one', foreignKeyField: 'r' });
279
+ for (let i = 0; i < 20; i++) {
280
+ leaf = rel({ alias: 'r', table: 't', cardinality: 'one', foreignKeyField: 'r', relations: [leaf] });
281
+ }
282
+ const store = new MemStore({ t: [{ id: 't:1', r: 't:1' }] });
283
+ await expect(resolveRelations([{ id: 't:1', r: 't:1' }], [leaf], store)).rejects.toBeInstanceOf(
284
+ RelationCycleError
285
+ );
286
+ });
287
+
288
+ it('10. RecordId-shaped keys group identically to their string form', async () => {
289
+ // Parent FK is a RecordId-like object; child id is the string form.
290
+ const rid = { tb: 'user', id: '1', toString: () => 'user:1' };
291
+ const store = new MemStore({ user: [{ id: 'user:1', name: 'ana' }] });
292
+ const parents: Row[] = [{ id: 'post:1', author: rid }];
293
+ await resolveRelations(
294
+ parents,
295
+ [rel({ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author', limit: 1 })],
296
+ store
297
+ );
298
+ expect((parents[0].author as Row).name).toBe('ana');
299
+ // alias appended LAST (key order parity with `SELECT *, <sub> AS alias`).
300
+ expect(Object.keys(parents[0])).toEqual(['id', 'author']);
301
+ });
302
+ });
303
+
304
+ describe('stableKey', () => {
305
+ it('collapses RecordId object and string form', () => {
306
+ expect(stableKey({ tb: 'user', id: '1' })).toBe('user:1');
307
+ expect(stableKey('user:1')).toBe('user:1');
308
+ });
309
+ });
310
+
311
+ /**
312
+ * Independent, obviously-correct reference resolver: for each parent, fetch its
313
+ * relations directly (no batching, no dedup) and recurse. The batched
314
+ * `resolveRelations` must produce byte-identical output for random trees.
315
+ */
316
+ async function naiveResolve(parents: Row[], relations: RelationPlan[] | undefined, store: MemStore): Promise<void> {
317
+ if (!relations) return;
318
+ for (const parent of parents) {
319
+ for (const r of relations) {
320
+ const isOne = r.cardinality === 'one';
321
+ const key = isOne ? parent[r.foreignKeyField] : parent['id'];
322
+ let bucket: Row[] = [];
323
+ if (key != null) {
324
+ bucket = await store.fetchRelation({
325
+ table: r.table,
326
+ matchField: isOne ? 'id' : r.foreignKeyField,
327
+ keys: [key],
328
+ where: r.where,
329
+ orderBy: r.orderBy,
330
+ select: r.select,
331
+ });
332
+ await naiveResolve(bucket, r.relations, store);
333
+ }
334
+ if (r.orderBy) bucket = sortRows(bucket, r.orderBy);
335
+ if (r.limit !== undefined) bucket = bucket.slice(0, r.limit);
336
+ parent[r.alias] = isOne ? bucket[0] ?? null : bucket;
337
+ }
338
+ }
339
+ }
340
+
341
+ // Tiny seeded PRNG so failures reproduce (Math.random is nondeterministic).
342
+ function mulberry32(seed: number): () => number {
343
+ return () => {
344
+ seed |= 0;
345
+ seed = (seed + 0x6d2b79f5) | 0;
346
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
347
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
348
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
349
+ };
350
+ }
351
+
352
+ describe('resolveRelations — property: batched == naive over random trees', () => {
353
+ const TABLES = ['a', 'b', 'c', 'd'];
354
+
355
+ it('matches the naive reference for 200 random datasets/plans', async () => {
356
+ for (let seed = 0; seed < 200; seed++) {
357
+ const rng = mulberry32(seed + 1);
358
+ const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
359
+
360
+ // Random dataset: each table gets a few rows with random FK fields.
361
+ const tables: Record<string, Row[]> = {};
362
+ for (const t of TABLES) {
363
+ const n = Math.floor(rng() * 5);
364
+ tables[t] = [];
365
+ for (let i = 0; i < n; i++) {
366
+ tables[t].push({
367
+ id: `${t}:${i}`,
368
+ rank: Math.floor(rng() * 5),
369
+ // FK columns pointing at every table (some valid, some dangling).
370
+ ...Object.fromEntries(
371
+ TABLES.map((ft) => [`${ft}_fk`, rng() < 0.7 ? `${ft}:${Math.floor(rng() * 5)}` : null])
372
+ ),
373
+ });
374
+ }
375
+ }
376
+
377
+ // Random relation tree, depth <= 4.
378
+ const buildPlan = (depth: number): RelationPlan[] => {
379
+ if (depth > 4 || rng() < 0.35) return [];
380
+ const count = Math.floor(rng() * 2) + 1;
381
+ const out: RelationPlan[] = [];
382
+ for (let i = 0; i < count; i++) {
383
+ const table = pick(TABLES);
384
+ const cardinality = rng() < 0.5 ? 'one' : 'many';
385
+ out.push(
386
+ rel({
387
+ alias: `rel_${depth}_${i}`,
388
+ table,
389
+ cardinality,
390
+ foreignKeyField: cardinality === 'one' ? `${table}_fk` : `${pick(TABLES)}_fk`,
391
+ orderBy: rng() < 0.5 ? [['rank', pick(['asc', 'desc'] as const)]] : undefined,
392
+ limit: rng() < 0.5 ? Math.floor(rng() * 3) + 1 : undefined,
393
+ relations: buildPlan(depth + 1),
394
+ })
395
+ );
396
+ }
397
+ return out;
398
+ };
399
+ const plan = buildPlan(0);
400
+
401
+ const rootTable = pick(TABLES);
402
+ const roots = (tables[rootTable] ?? []).map((r) => structuredClone(r));
403
+ if (roots.length === 0) continue;
404
+
405
+ const batched = structuredClone(roots);
406
+ const naive = structuredClone(roots);
407
+ await resolveRelations(batched, plan, new MemStore(structuredClone(tables)));
408
+ await naiveResolve(naive, plan, new MemStore(structuredClone(tables)));
409
+
410
+ expect(batched, `seed ${seed}`).toEqual(naive);
411
+ }
412
+ });
413
+ });
414
+
415
+ describe('to-one relations over a `string | record` column', () => {
416
+ const rel = (alias: string, fk: string): RelationPlan => ({
417
+ table: 'player_name',
418
+ alias,
419
+ cardinality: 'one',
420
+ foreignKeyField: fk,
421
+ });
422
+
423
+ it('keeps a legacy plain string when the alias is the foreign-key column', async () => {
424
+ const store = new MemStore({
425
+ player_name: [{ id: 'player_name:PN_1', name: 'Magnus' }],
426
+ });
427
+ const rows: Row[] = [
428
+ { id: 'game:1', white: 'player_name:PN_1' },
429
+ { id: 'game:2', white: 'Hikaru' }, // pre-normalization row: not a link
430
+ { id: 'game:3', white: 'player_name:PN_gone' }, // dangling link
431
+ ];
432
+ await resolveRelations(rows, [rel('white', 'white')], store);
433
+ expect(rows[0].white).toEqual({ id: 'player_name:PN_1', name: 'Magnus' });
434
+ expect(rows[1].white).toBe('Hikaru');
435
+ // A dangling LINK is null, like SurrealQL's `(SELECT …)[0] AS white`.
436
+ expect(rows[2].white).toBeNull();
437
+ });
438
+
439
+ it('never sends a non-record value as a correlation key', async () => {
440
+ const seen: unknown[][] = [];
441
+ const store: RowFetcher = {
442
+ fetchRelation: async (req) => {
443
+ seen.push(req.keys);
444
+ return [];
445
+ },
446
+ };
447
+ const rows: Row[] = [{ id: 'game:1', white: 'Hikaru' }, { id: 'game:2', white: 'player_name:PN_2' }];
448
+ await resolveRelations(rows, [rel('white', 'white')], store);
449
+ expect(seen).toEqual([['player_name:PN_2']]);
450
+ });
451
+
452
+ it('nulls a distinct alias when nothing resolves', async () => {
453
+ const store = new MemStore({ player_name: [] });
454
+ const rows: Row[] = [{ id: 'game:1', white: 'player_name:PN_gone' }];
455
+ await resolveRelations(rows, [rel('whiteRow', 'white')], store);
456
+ expect(rows[0].whiteRow).toBeNull();
457
+ expect(rows[0].white).toBe('player_name:PN_gone');
458
+ });
459
+
460
+ it('looksLikeRecordId accepts table:id strings and RecordId shapes only', () => {
461
+ expect(looksLikeRecordId('player_name:PN_1')).toBe(true);
462
+ expect(looksLikeRecordId({ tb: 'player_name', id: 'PN_1' })).toBe(true);
463
+ expect(looksLikeRecordId('Magnus')).toBe(false);
464
+ expect(looksLikeRecordId(42)).toBe(false);
465
+ expect(looksLikeRecordId(null)).toBe(false);
466
+ });
467
+ });