@supalive/core 0.1.4 → 0.1.5

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 (212) hide show
  1. package/README.md +17 -2
  2. package/dist/helper-CiacMqje.d.ts +14 -0
  3. package/dist/helper-CiacMqje.d.ts.map +1 -0
  4. package/dist/helper-zdJT5FUc.js +58 -0
  5. package/dist/helper-zdJT5FUc.js.map +1 -0
  6. package/dist/index-CTyXKcq_.d.ts +1736 -0
  7. package/dist/index-CTyXKcq_.d.ts.map +1 -0
  8. package/dist/mysql-C8SN443m.d.ts +104 -0
  9. package/dist/mysql-C8SN443m.d.ts.map +1 -0
  10. package/dist/mysql-DELSRrpY.js +569 -0
  11. package/dist/mysql-DELSRrpY.js.map +1 -0
  12. package/dist/overlap-checker-CVkbbuEQ.js +187 -0
  13. package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
  14. package/dist/postgres-DE-XyNAQ.js +823 -0
  15. package/dist/postgres-DE-XyNAQ.js.map +1 -0
  16. package/dist/postgres-D_gwCHx-.d.ts +108 -0
  17. package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
  18. package/dist/query-CbQLjeME.js +713 -0
  19. package/dist/query-CbQLjeME.js.map +1 -0
  20. package/dist/realtime_db-D9p2X2PH.js +246 -0
  21. package/dist/realtime_db-D9p2X2PH.js.map +1 -0
  22. package/dist/router-DlTYWpop.js +81 -0
  23. package/dist/router-DlTYWpop.js.map +1 -0
  24. package/dist/schema-SKcIWHfo.js +167 -0
  25. package/dist/schema-SKcIWHfo.js.map +1 -0
  26. package/dist/src/client/index.d.ts +2 -17
  27. package/dist/src/client/index.js +1151 -18
  28. package/dist/src/client/index.js.map +1 -1
  29. package/dist/src/exports/mysql.d.ts +2 -2
  30. package/dist/src/exports/mysql.js +2 -2
  31. package/dist/src/exports/postgres.d.ts +2 -2
  32. package/dist/src/exports/postgres.js +2 -2
  33. package/dist/src/exports/procedure.d.ts +3 -5
  34. package/dist/src/exports/procedure.js +5 -5
  35. package/dist/src/exports/server.d.ts +368 -2
  36. package/dist/src/exports/server.d.ts.map +1 -1
  37. package/dist/src/exports/server.js +1346 -2
  38. package/dist/src/exports/server.js.map +1 -1
  39. package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
  40. package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
  41. package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
  42. package/dist/src/exports/types.d.ts +4 -6
  43. package/dist/src/exports/types.js +5 -6
  44. package/dist/subscription-worker-BurdPO8d.js +511 -0
  45. package/dist/subscription-worker-BurdPO8d.js.map +1 -0
  46. package/dist/types_client_rpc-CIsjQQGG.js +105 -0
  47. package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
  48. package/dist/types_db-Ctf-ksoy.js +168 -0
  49. package/dist/types_db-Ctf-ksoy.js.map +1 -0
  50. package/dist/types_server-Ch56y8L3.d.ts +216 -0
  51. package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
  52. package/dist/types_server-Dk6o_B7N.js +24 -0
  53. package/dist/types_server-Dk6o_B7N.js.map +1 -0
  54. package/package.json +3 -3
  55. package/dist/src/client/caller_client.d.ts +0 -42
  56. package/dist/src/client/caller_client.d.ts.map +0 -1
  57. package/dist/src/client/caller_client.js +0 -77
  58. package/dist/src/client/caller_client.js.map +0 -1
  59. package/dist/src/client/client.d.ts +0 -149
  60. package/dist/src/client/client.d.ts.map +0 -1
  61. package/dist/src/client/client.js +0 -308
  62. package/dist/src/client/client.js.map +0 -1
  63. package/dist/src/client/index.d.ts.map +0 -1
  64. package/dist/src/client/sub_key.d.ts +0 -20
  65. package/dist/src/client/sub_key.d.ts.map +0 -1
  66. package/dist/src/client/sub_key.js +0 -37
  67. package/dist/src/client/sub_key.js.map +0 -1
  68. package/dist/src/client/ws_client_manager.d.ts +0 -268
  69. package/dist/src/client/ws_client_manager.d.ts.map +0 -1
  70. package/dist/src/client/ws_client_manager.js +0 -1013
  71. package/dist/src/client/ws_client_manager.js.map +0 -1
  72. package/dist/src/config.d.ts +0 -117
  73. package/dist/src/config.d.ts.map +0 -1
  74. package/dist/src/config.js +0 -2
  75. package/dist/src/config.js.map +0 -1
  76. package/dist/src/db/bytes.d.ts +0 -4
  77. package/dist/src/db/bytes.d.ts.map +0 -1
  78. package/dist/src/db/bytes.js +0 -24
  79. package/dist/src/db/bytes.js.map +0 -1
  80. package/dist/src/db/cache.d.ts +0 -30
  81. package/dist/src/db/cache.d.ts.map +0 -1
  82. package/dist/src/db/cache.js +0 -153
  83. package/dist/src/db/cache.js.map +0 -1
  84. package/dist/src/db/codec.d.ts +0 -10
  85. package/dist/src/db/codec.d.ts.map +0 -1
  86. package/dist/src/db/codec.js +0 -73
  87. package/dist/src/db/codec.js.map +0 -1
  88. package/dist/src/db/context.d.ts +0 -28
  89. package/dist/src/db/context.d.ts.map +0 -1
  90. package/dist/src/db/context.js +0 -147
  91. package/dist/src/db/context.js.map +0 -1
  92. package/dist/src/db/db.d.ts +0 -154
  93. package/dist/src/db/db.d.ts.map +0 -1
  94. package/dist/src/db/db.js +0 -23
  95. package/dist/src/db/db.js.map +0 -1
  96. package/dist/src/db/mysql.d.ts +0 -120
  97. package/dist/src/db/mysql.d.ts.map +0 -1
  98. package/dist/src/db/mysql.js +0 -708
  99. package/dist/src/db/mysql.js.map +0 -1
  100. package/dist/src/db/postgres.d.ts +0 -116
  101. package/dist/src/db/postgres.d.ts.map +0 -1
  102. package/dist/src/db/postgres.js +0 -998
  103. package/dist/src/db/postgres.js.map +0 -1
  104. package/dist/src/db/query.d.ts +0 -174
  105. package/dist/src/db/query.d.ts.map +0 -1
  106. package/dist/src/db/query.js +0 -790
  107. package/dist/src/db/query.js.map +0 -1
  108. package/dist/src/db/query_definition.d.ts +0 -54
  109. package/dist/src/db/query_definition.d.ts.map +0 -1
  110. package/dist/src/db/query_definition.js +0 -47
  111. package/dist/src/db/query_definition.js.map +0 -1
  112. package/dist/src/db/realtime_db.d.ts +0 -19
  113. package/dist/src/db/realtime_db.d.ts.map +0 -1
  114. package/dist/src/db/realtime_db.js +0 -98
  115. package/dist/src/db/realtime_db.js.map +0 -1
  116. package/dist/src/db/schema.d.ts +0 -110
  117. package/dist/src/db/schema.d.ts.map +0 -1
  118. package/dist/src/db/schema.js +0 -47
  119. package/dist/src/db/schema.js.map +0 -1
  120. package/dist/src/db/types_client_rpc.d.ts +0 -157
  121. package/dist/src/db/types_client_rpc.d.ts.map +0 -1
  122. package/dist/src/db/types_client_rpc.js +0 -106
  123. package/dist/src/db/types_client_rpc.js.map +0 -1
  124. package/dist/src/db/types_db.d.ts +0 -236
  125. package/dist/src/db/types_db.d.ts.map +0 -1
  126. package/dist/src/db/types_db.js +0 -188
  127. package/dist/src/db/types_db.js.map +0 -1
  128. package/dist/src/exports/mysql.d.ts.map +0 -1
  129. package/dist/src/exports/mysql.js.map +0 -1
  130. package/dist/src/exports/postgres.d.ts.map +0 -1
  131. package/dist/src/exports/postgres.js.map +0 -1
  132. package/dist/src/exports/procedure.d.ts.map +0 -1
  133. package/dist/src/exports/procedure.js.map +0 -1
  134. package/dist/src/exports/types.d.ts.map +0 -1
  135. package/dist/src/exports/types.js.map +0 -1
  136. package/dist/src/helper.d.ts +0 -11
  137. package/dist/src/helper.d.ts.map +0 -1
  138. package/dist/src/helper.js +0 -45
  139. package/dist/src/helper.js.map +0 -1
  140. package/dist/src/logger.d.ts +0 -7
  141. package/dist/src/logger.d.ts.map +0 -1
  142. package/dist/src/logger.js +0 -47
  143. package/dist/src/logger.js.map +0 -1
  144. package/dist/src/router/index.d.ts +0 -4
  145. package/dist/src/router/index.d.ts.map +0 -1
  146. package/dist/src/router/index.js +0 -4
  147. package/dist/src/router/index.js.map +0 -1
  148. package/dist/src/router/procedure.d.ts +0 -204
  149. package/dist/src/router/procedure.d.ts.map +0 -1
  150. package/dist/src/router/procedure.js +0 -122
  151. package/dist/src/router/procedure.js.map +0 -1
  152. package/dist/src/router/router.d.ts +0 -88
  153. package/dist/src/router/router.d.ts.map +0 -1
  154. package/dist/src/router/router.js +0 -88
  155. package/dist/src/router/router.js.map +0 -1
  156. package/dist/src/server/hash.d.ts +0 -12
  157. package/dist/src/server/hash.d.ts.map +0 -1
  158. package/dist/src/server/hash.js +0 -20
  159. package/dist/src/server/hash.js.map +0 -1
  160. package/dist/src/server/hash.test.d.ts +0 -2
  161. package/dist/src/server/hash.test.d.ts.map +0 -1
  162. package/dist/src/server/hash.test.js +0 -43
  163. package/dist/src/server/hash.test.js.map +0 -1
  164. package/dist/src/server/index.d.ts +0 -9
  165. package/dist/src/server/index.d.ts.map +0 -1
  166. package/dist/src/server/index.js +0 -8
  167. package/dist/src/server/index.js.map +0 -1
  168. package/dist/src/server/overlap-checker.d.ts +0 -18
  169. package/dist/src/server/overlap-checker.d.ts.map +0 -1
  170. package/dist/src/server/overlap-checker.js +0 -247
  171. package/dist/src/server/overlap-checker.js.map +0 -1
  172. package/dist/src/server/sub_hash.d.ts +0 -23
  173. package/dist/src/server/sub_hash.d.ts.map +0 -1
  174. package/dist/src/server/sub_hash.js +0 -38
  175. package/dist/src/server/sub_hash.js.map +0 -1
  176. package/dist/src/server/subscription-manager-client.d.ts +0 -120
  177. package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
  178. package/dist/src/server/subscription-manager-client.js +0 -207
  179. package/dist/src/server/subscription-manager-client.js.map +0 -1
  180. package/dist/src/server/subscription-manager-types.d.ts +0 -168
  181. package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
  182. package/dist/src/server/subscription-manager-types.js +0 -94
  183. package/dist/src/server/subscription-manager-types.js.map +0 -1
  184. package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
  185. package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
  186. package/dist/src/server/subscription-manager-worker-entry.js +0 -127
  187. package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
  188. package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
  189. package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
  190. package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
  191. package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
  192. package/dist/src/server/subscription-manager.d.ts +0 -32
  193. package/dist/src/server/subscription-manager.d.ts.map +0 -1
  194. package/dist/src/server/subscription-manager.js +0 -249
  195. package/dist/src/server/subscription-manager.js.map +0 -1
  196. package/dist/src/server/subscription-manager.test.d.ts +0 -2
  197. package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
  198. package/dist/src/server/subscription-manager.test.js +0 -610
  199. package/dist/src/server/subscription-manager.test.js.map +0 -1
  200. package/dist/src/server/subscription-worker.d.ts +0 -32
  201. package/dist/src/server/subscription-worker.d.ts.map +0 -1
  202. package/dist/src/server/subscription-worker.js +0 -190
  203. package/dist/src/server/subscription-worker.js.map +0 -1
  204. package/dist/src/server/supalive-server.d.ts +0 -158
  205. package/dist/src/server/supalive-server.d.ts.map +0 -1
  206. package/dist/src/server/supalive-server.js +0 -1048
  207. package/dist/src/server/supalive-server.js.map +0 -1
  208. package/dist/src/server/types_server.d.ts +0 -96
  209. package/dist/src/server/types_server.d.ts.map +0 -1
  210. package/dist/src/server/types_server.js +0 -36
  211. package/dist/src/server/types_server.js.map +0 -1
  212. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,610 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { Buffer } from "buffer";
3
- import { mapWriteSetToRaw } from "./overlap-checker";
4
- import { fnv1a32 } from "./hash";
5
- import { SubscriptionWorker } from "./subscription-manager";
6
- import { supaliveStringify } from "../helper";
7
- import { sleep } from "../client";
8
- // ─── Test doubles ──────────────────────────────────────────────────────────
9
- class StubCache {
10
- meta = new Map();
11
- data = new Map();
12
- // Backs persistRegistrationRecord / deleteRegistrationRecord so tests can
13
- // observe registration persistence via redis.store.
14
- redis;
15
- constructor(redis) {
16
- this.redis = redis;
17
- }
18
- async getQueryCacheMetaData(key) {
19
- return this.meta.get(key) ?? null;
20
- }
21
- async setQueryCacheMetadata(key, value) {
22
- this.meta.set(key, value);
23
- }
24
- async getQueryCacheFor(key) {
25
- const v = this.data.get(key);
26
- return v == null ? null : { data: v };
27
- }
28
- async setQueryCacheFor(key, value) {
29
- this.data.set(key, value);
30
- }
31
- async persistRegistrationRecord(subId, record) {
32
- await this.redis.set(`sl:reg:${subId}`, supaliveStringify(record));
33
- }
34
- async deleteRegistrationRecord(subId) {
35
- await this.redis.del(`sl:reg:${subId}`);
36
- }
37
- async deleteRegistrationsRecord(subIds) {
38
- for (const subId of subIds)
39
- await this.redis.del(`sl:reg:${subId}`);
40
- }
41
- }
42
- class StubDb {
43
- dbType = "postgres";
44
- commitLogs = [];
45
- async getCommitLogsBetweenTs(_beginTs, _endTs, _tableIds) {
46
- return this.commitLogs;
47
- }
48
- // The rest of Database is unused by SubscriptionWorker:
49
- get sqlBuilder() { throw new Error("not implemented"); }
50
- get migrationSql() { throw new Error("not implemented"); }
51
- async query() { throw new Error("not implemented"); }
52
- async getClient() { throw new Error("not implemented"); }
53
- async getTransactionClient() { throw new Error("not implemented"); }
54
- async getLatestSnapshotTimestamp() { return 0; }
55
- async updateLatestSnapshotTimestamp() { }
56
- async getNextTimestamp() { return 0; }
57
- async commitWrites() { return { success: true }; }
58
- async close() { }
59
- async insertCommitLogs() { }
60
- async pruneCommitLogsBefore() { return 0; }
61
- async attachPrevDataToWriteSet() { }
62
- }
63
- /**
64
- * Minimal stub of the Upstash Redis client. Only `get`, `set`, `del` are
65
- * touched by SubscriptionWorker; the rest stay undefined and would throw
66
- * if accidentally used.
67
- */
68
- class StubRedis {
69
- store = new Map();
70
- setCalls = [];
71
- delCalls = [];
72
- async get(key) {
73
- return this.store.get(key) ?? null;
74
- }
75
- async set(key, value) {
76
- this.store.set(key, value);
77
- this.setCalls.push([key, value]);
78
- return "OK";
79
- }
80
- async del(key) {
81
- const had = this.store.delete(key);
82
- this.delCalls.push(key);
83
- return had ? 1 : 0;
84
- }
85
- async mget(...keys) {
86
- return keys.map((k) => this.store.get(k) ?? null);
87
- }
88
- }
89
- const cacheOf = (s) => s;
90
- const dbOf = (s) => s;
91
- const redisOf = (s) => s;
92
- // ─── Fixtures ──────────────────────────────────────────────────────────────
93
- const ITEMS_TABLE_ID = Buffer.from("items-tableid");
94
- const USERS_TABLE_ID = Buffer.from("users-tableid");
95
- function pointRead(table, tableId, id) {
96
- return { kind: "point", table, tableId, id };
97
- }
98
- function rangeRead(table, tableId, predicateValue) {
99
- return {
100
- kind: "range",
101
- table,
102
- tableId,
103
- predicate: { kind: "leaf", column: "status", operator: "=", value: predicateValue },
104
- columns: ["id", "status"],
105
- orderBy: null,
106
- limit: null,
107
- offset: null,
108
- };
109
- }
110
- function writeEntry(table, tableId, id, op = "update", data, prevData) {
111
- return { table, tableId, id, op, data, prevData };
112
- }
113
- function makeRegisterParams(opts = {}) {
114
- return {
115
- subId: opts.subId ?? "sub_a",
116
- cacheKey: opts.cacheKey ?? "ck_a",
117
- queryName: opts.queryName ?? "getItems",
118
- args: opts.args ?? {},
119
- queryIdentity: "anonymous",
120
- instanceName: opts.instanceName ?? "app-1",
121
- lastSnapshotTs: opts.lastSnapshotTs ?? 0n,
122
- };
123
- }
124
- function makeUpdateParams(subId, cacheKey, readSet, opts = {}) {
125
- return {
126
- cacheKey,
127
- subId,
128
- instanceName: opts.instanceName ?? "app-1",
129
- lastSnapshotTs: opts.lastSnapshotTs ?? 1n,
130
- readSet,
131
- };
132
- }
133
- function makeUnregisterParams(subId, opts = {}) {
134
- return {
135
- subId,
136
- instanceName: opts.instanceName ?? "app-1",
137
- };
138
- }
139
- function makeWorker() {
140
- const redis = new StubRedis();
141
- const cache = new StubCache(redis);
142
- const db = new StubDb();
143
- const worker = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
144
- return { worker, cache, db, redis };
145
- }
146
- // ─── Worker tests ──────────────────────────────────────────────────────────
147
- describe("SubscriptionWorker", () => {
148
- let cache;
149
- let db;
150
- let redis;
151
- let worker;
152
- beforeEach(() => {
153
- ({ worker, cache, db, redis } = makeWorker());
154
- });
155
- it("registers a brand-new subscription with empty readSet and recompute=true", async () => {
156
- const res = await worker.register(makeRegisterParams());
157
- expect(res.recompute).toBe(true);
158
- expect(res.readSet).toEqual([]);
159
- expect(res.cacheKey).toBe("ck_a");
160
- expect(worker.size()).toBe(1);
161
- expect(worker.has("sub_a")).toBe(true);
162
- });
163
- it("persists a registration record to sl:reg:<subId> on first creation", async () => {
164
- await worker.register(makeRegisterParams({ queryName: "getItems", args: { limit: 10 } }));
165
- expect(redis.store.has("sl:reg:sub_a")).toBe(true);
166
- const parsed = JSON.parse(redis.store.get("sl:reg:sub_a"));
167
- expect(parsed.queryName).toBe("getItems");
168
- expect(parsed.args).toEqual({ limit: 10 });
169
- expect(parsed.queryIdentity).toBe("anonymous");
170
- expect(parsed.cacheKey).toBe("ck_a");
171
- });
172
- it("does not double-write the registration record when a second instance joins", async () => {
173
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
174
- expect(redis.setCalls.length).toBe(1);
175
- await worker.register(makeRegisterParams({ instanceName: "app-2" }));
176
- expect(redis.setCalls.length).toBe(1);
177
- });
178
- it("returns existing readSet and recompute=false when another instance joins", async () => {
179
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
180
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
181
- pointRead("items", ITEMS_TABLE_ID, "1"),
182
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
183
- const res = await worker.register(makeRegisterParams({ instanceName: "app-2" }));
184
- expect(res.recompute).toBe(false);
185
- expect(res.readSet.length).toBe(1);
186
- });
187
- it("reuses cached metadata when commit log shows no overlap → recompute=false", async () => {
188
- const readSet = [rangeRead("items", ITEMS_TABLE_ID, "active")];
189
- cache.meta.set("ck_a", { lastSnapshotTs: 5n, readSet });
190
- db.commitLogs = [];
191
- const res = await worker.register(makeRegisterParams({ lastSnapshotTs: 10n }));
192
- expect(res.recompute).toBe(false);
193
- expect(res.readSet).toEqual(readSet);
194
- });
195
- it("forces recompute when commit log between metadata.ts and now affects readSet", async () => {
196
- const readSet = [pointRead("items", ITEMS_TABLE_ID, "1")];
197
- cache.meta.set("ck_a", { lastSnapshotTs: 5n, readSet });
198
- db.commitLogs = [{
199
- id: Buffer.from("1"),
200
- ts: 7n,
201
- tableId: ITEMS_TABLE_ID,
202
- // Commit logs serialize the full WriteEntry envelope, not raw row data.
203
- data: Buffer.from(supaliveStringify({
204
- table: "items", tableId: ITEMS_TABLE_ID, id: "1", op: "update",
205
- data: { id: "1", name: "X" },
206
- })),
207
- }];
208
- const res = await worker.register(makeRegisterParams({ lastSnapshotTs: 10n }));
209
- expect(res.recompute).toBe(true);
210
- });
211
- // ─── OCC range-read precision (checkRangeReadOverlap via checkLogsAffectReadSet) ──
212
- //
213
- // Before the fix, checkRangeReadOverlap evaluated the predicate against
214
- // the full WriteEntry envelope (which has fields `table`, `op`, `data`,
215
- // etc. — none of which match a row's predicate column), so it always
216
- // returned "unknown" → every concurrent write was treated as a conflict.
217
- // After the fix, it pulls out the row's prev/post state and uses the
218
- // same prev-OR-post logic the in-memory invalidator uses.
219
- it("recovery: range-read sub recomputes when a gap log moves a row OUT of the set", async () => {
220
- const readSet = [rangeRead("items", ITEMS_TABLE_ID, "todo")];
221
- cache.meta.set("ck_a", { lastSnapshotTs: 5n, readSet });
222
- db.commitLogs = [{
223
- id: Buffer.from("1"),
224
- ts: 7n,
225
- tableId: ITEMS_TABLE_ID,
226
- data: Buffer.from(supaliveStringify({
227
- table: "items", tableId: ITEMS_TABLE_ID, id: "1", op: "update",
228
- data: { status: "done" },
229
- prevData: { id: "1", status: "todo" },
230
- })),
231
- }];
232
- const res = await worker.register(makeRegisterParams({ lastSnapshotTs: 10n }));
233
- expect(res.recompute).toBe(true);
234
- });
235
- it("recovery: range-read sub does NOT recompute when gap log is irrelevant (precision)", async () => {
236
- const readSet = [rangeRead("items", ITEMS_TABLE_ID, "todo")];
237
- cache.meta.set("ck_a", { lastSnapshotTs: 5n, readSet });
238
- db.commitLogs = [{
239
- id: Buffer.from("1"),
240
- ts: 7n,
241
- tableId: ITEMS_TABLE_ID,
242
- // Row was "done" before and is still "done" — never in the "todo" set.
243
- data: Buffer.from(supaliveStringify({
244
- table: "items", tableId: ITEMS_TABLE_ID, id: "1", op: "update",
245
- data: { status: "done" },
246
- prevData: { id: "1", status: "done" },
247
- })),
248
- }];
249
- const res = await worker.register(makeRegisterParams({ lastSnapshotTs: 10n }));
250
- expect(res.recompute).toBe(false);
251
- });
252
- it("recovery: range-read sub recomputes when a gap log inserts a matching row", async () => {
253
- const readSet = [rangeRead("items", ITEMS_TABLE_ID, "todo")];
254
- cache.meta.set("ck_a", { lastSnapshotTs: 5n, readSet });
255
- db.commitLogs = [{
256
- id: Buffer.from("2"),
257
- ts: 7n,
258
- tableId: ITEMS_TABLE_ID,
259
- data: Buffer.from(supaliveStringify({
260
- table: "items", tableId: ITEMS_TABLE_ID, id: "2", op: "insert",
261
- data: { id: "2", status: "todo" },
262
- })),
263
- }];
264
- const res = await worker.register(makeRegisterParams({ lastSnapshotTs: 10n }));
265
- expect(res.recompute).toBe(true);
266
- });
267
- it("updateReadSet accepts any instance — exactly one recomputes per mutation in the dispatch model", async () => {
268
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
269
- await worker.register(makeRegisterParams({ instanceName: "app-2" }));
270
- const res = await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
271
- pointRead("items", ITEMS_TABLE_ID, "2"),
272
- ], { instanceName: "app-2", lastSnapshotTs: 5n }));
273
- expect(res.tracked).toBe(true);
274
- // Non-primary's readSet is applied now that primary-only filtering is gone.
275
- const affected = worker.invalidate(mapWriteSetToRaw([
276
- writeEntry("items", ITEMS_TABLE_ID, "2"),
277
- ]));
278
- expect(affected.length).toBe(1);
279
- });
280
- it("single-instance sub: notifyInstances is just that instance", async () => {
281
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
282
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
283
- pointRead("items", ITEMS_TABLE_ID, "1"),
284
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
285
- const affected = worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")]));
286
- expect(affected.length).toBe(1);
287
- expect(affected[0].subId).toBe("sub_a");
288
- expect(affected[0].notifyInstances).toEqual(["app-1"]);
289
- });
290
- it("multi-instance sub: notifyInstances includes every live holder", async () => {
291
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
292
- await worker.register(makeRegisterParams({ instanceName: "app-2" }));
293
- await worker.register(makeRegisterParams({ instanceName: "app-3" }));
294
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
295
- pointRead("items", ITEMS_TABLE_ID, "1"),
296
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
297
- const affected = worker.invalidate(mapWriteSetToRaw([
298
- writeEntry("items", ITEMS_TABLE_ID, "1"),
299
- ]));
300
- // Under the race-lock dispatch model every holder must appear in
301
- // notifyInstances — the writer broadcasts a race to all of them and
302
- // the lock arbitrates which one actually recomputes.
303
- expect(affected[0].notifyInstances.slice().sort())
304
- .toEqual(["app-1", "app-2", "app-3"]);
305
- });
306
- // Regression for the original bug: a writer on instance B (not holding
307
- // the sub locally) used to forward a single targeted recompute to the
308
- // "primary" instance. If that primary was unreachable (e.g. just died,
309
- // or the targeted-channel publish silently failed), the update was
310
- // lost. Under the race model the writer broadcasts to *every* holder,
311
- // so any survivor can pick up the lock and recompute.
312
- it("notifyInstances includes every holder so a non-holder writer can broadcast", async () => {
313
- await worker.register(makeRegisterParams({ instanceName: "app-A" }));
314
- await worker.register(makeRegisterParams({ instanceName: "app-B" }));
315
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
316
- pointRead("items", ITEMS_TABLE_ID, "1"),
317
- ], { instanceName: "app-A", lastSnapshotTs: 5n }));
318
- const affected = worker.invalidate(mapWriteSetToRaw([
319
- writeEntry("items", ITEMS_TABLE_ID, "1"),
320
- ]));
321
- expect(affected.length).toBe(1);
322
- expect(affected[0].notifyInstances).toContain("app-A");
323
- expect(affected[0].notifyInstances).toContain("app-B");
324
- });
325
- it("invalidate produces no false positives for unrelated writes", async () => {
326
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
327
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
328
- pointRead("items", ITEMS_TABLE_ID, "1"),
329
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
330
- // Different table
331
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("users", USERS_TABLE_ID, "1")])).length).toBe(0);
332
- // Same table, different row id (point read)
333
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "999")])).length).toBe(0);
334
- });
335
- it("range read with matching predicate is invalidated; non-matching is not", async () => {
336
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
337
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
338
- rangeRead("items", ITEMS_TABLE_ID, "active"),
339
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
340
- // Row entering the result set (prev="inactive", new="active")
341
- const matching = worker.invalidate(mapWriteSetToRaw([
342
- writeEntry("items", ITEMS_TABLE_ID, "1", "update", { status: "active" }, { id: "1", status: "inactive" }),
343
- ]));
344
- expect(matching.length).toBe(1);
345
- // Row that was inactive and stays inactive (predicate column changed
346
- // to another non-matching value) — neither prev nor new matches.
347
- const nonMatching = worker.invalidate(mapWriteSetToRaw([
348
- writeEntry("items", ITEMS_TABLE_ID, "2", "update", { status: "archived" }, { id: "2", status: "inactive" }),
349
- ]));
350
- expect(nonMatching.length).toBe(0);
351
- });
352
- it("invalidates when an update moves a row OUT of the predicate set (regression: row-leaves-set)", async () => {
353
- // The bug: sub on status="todo" missed updates that flipped a row's
354
- // status from "todo" to "done" because only the new (non-matching)
355
- // data was visible to the invalidator.
356
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
357
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
358
- rangeRead("items", ITEMS_TABLE_ID, "todo"),
359
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
360
- const affected = worker.invalidate(mapWriteSetToRaw([
361
- writeEntry("items", ITEMS_TABLE_ID, "1", "update", { status: "done" }, { id: "1", status: "todo" }),
362
- ]));
363
- expect(affected.length).toBe(1);
364
- });
365
- it("does NOT invalidate when both prev and new states miss the predicate", async () => {
366
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
367
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
368
- rangeRead("items", ITEMS_TABLE_ID, "todo"),
369
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
370
- // done → in_progress, sub is on todo — neither matches.
371
- const affected = worker.invalidate(mapWriteSetToRaw([
372
- writeEntry("items", ITEMS_TABLE_ID, "1", "update", { status: "in_progress" }, { id: "1", status: "done" }),
373
- ]));
374
- expect(affected.length).toBe(0);
375
- });
376
- it("invalidates partial updates (untouched predicate column) using prevData", async () => {
377
- // Updating only a non-predicate column on a row that's currently in
378
- // the set must still invalidate (the row's visible fields changed).
379
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
380
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
381
- rangeRead("items", ITEMS_TABLE_ID, "todo"),
382
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
383
- // status stays "todo", only title changes — predicate column carried
384
- // forward from prevData.
385
- const inSet = worker.invalidate(mapWriteSetToRaw([
386
- writeEntry("items", ITEMS_TABLE_ID, "1", "update", { title: "new" }, { id: "1", status: "todo", title: "old" }),
387
- ]));
388
- expect(inSet.length).toBe(1);
389
- // Same kind of partial update on a row NOT in the set — must NOT invalidate.
390
- const outOfSet = worker.invalidate(mapWriteSetToRaw([
391
- writeEntry("items", ITEMS_TABLE_ID, "2", "update", { title: "new" }, { id: "2", status: "done", title: "old" }),
392
- ]));
393
- expect(outOfSet.length).toBe(0);
394
- });
395
- it("delete invalidation is precise — only invalidates subs whose predicate matched the deleted row", async () => {
396
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
397
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
398
- rangeRead("items", ITEMS_TABLE_ID, "todo"),
399
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
400
- // Deleting a row that was in the result set → invalidate.
401
- const matchedDelete = worker.invalidate(mapWriteSetToRaw([
402
- writeEntry("items", ITEMS_TABLE_ID, "1", "delete", undefined, { id: "1", status: "todo" }),
403
- ]));
404
- expect(matchedDelete.length).toBe(1);
405
- // Deleting a row that was NOT in the result set → no invalidation.
406
- const unmatchedDelete = worker.invalidate(mapWriteSetToRaw([
407
- writeEntry("items", ITEMS_TABLE_ID, "2", "delete", undefined, { id: "2", status: "done" }),
408
- ]));
409
- expect(unmatchedDelete.length).toBe(0);
410
- });
411
- it("insert into the result set invalidates; insert outside does not", async () => {
412
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
413
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
414
- rangeRead("items", ITEMS_TABLE_ID, "todo"),
415
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
416
- const inserted = worker.invalidate(mapWriteSetToRaw([
417
- writeEntry("items", ITEMS_TABLE_ID, "1", "insert", { id: "1", status: "todo" }),
418
- ]));
419
- expect(inserted.length).toBe(1);
420
- const outsideInsert = worker.invalidate(mapWriteSetToRaw([
421
- writeEntry("items", ITEMS_TABLE_ID, "2", "insert", { id: "2", status: "done" }),
422
- ]));
423
- expect(outsideInsert.length).toBe(0);
424
- });
425
- it("unregister removes only the specified instance; sub stays until last leaves", { timeout: 30_000 }, async () => {
426
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
427
- await worker.register(makeRegisterParams({ instanceName: "app-2" }));
428
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
429
- pointRead("items", ITEMS_TABLE_ID, "1"),
430
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
431
- await worker.unregister(makeUnregisterParams("sub_a", { instanceName: "app-1" }));
432
- expect(worker.has("sub_a")).toBe(true);
433
- // sl:reg should NOT be deleted yet — another instance still holds the sub
434
- expect(redis.store.has("sl:reg:sub_a")).toBe(true);
435
- expect(redis.delCalls.length).toBe(0);
436
- const affected = worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")]));
437
- expect(affected.length).toBe(1);
438
- expect(affected[0].notifyInstances).toEqual(["app-2"]);
439
- await worker.unregister(makeUnregisterParams("sub_a", { instanceName: "app-2" }));
440
- await sleep(1000 * 18); // sub not closed immeditly, so wait 15+jitter
441
- expect(worker.has("sub_a")).toBe(false);
442
- // Now sl:reg is gone because instances.size === 0
443
- expect(redis.store.has("sl:reg:sub_a")).toBe(false);
444
- expect(redis.delCalls).toContain("sl:reg:sub_a");
445
- });
446
- it("unregister on absent sub is idempotent", async () => {
447
- const res = await worker.unregister(makeUnregisterParams("sub_missing"));
448
- expect(res.ok).toBe(true);
449
- expect(redis.delCalls.length).toBe(0);
450
- });
451
- it("updateReadSet on absent sub returns tracked:false (no throw)", async () => {
452
- const res = await worker.updateReadSet(makeUpdateParams("sub_ghost", "ck_ghost", [], {
453
- instanceName: "app-1",
454
- lastSnapshotTs: 1n,
455
- }));
456
- expect(res.tracked).toBe(false);
457
- });
458
- it("lastSnapshotTs ordering: older update is noop", async () => {
459
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
460
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
461
- pointRead("items", ITEMS_TABLE_ID, "1"),
462
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
463
- // A later, slower call with an older snapshot must NOT clobber the newer state.
464
- const older = await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
465
- pointRead("items", ITEMS_TABLE_ID, "999"), // different read
466
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
467
- expect(older.tracked).toBe(true);
468
- // Original read for id="1" should still be active; id="999" should NOT
469
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")])).length).toBe(1);
470
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "999")])).length).toBe(0);
471
- });
472
- it("lastSnapshotTs ordering: equal ts is treated as noop", async () => {
473
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
474
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
475
- pointRead("items", ITEMS_TABLE_ID, "1"),
476
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
477
- // Same ts must not apply (idempotent)
478
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
479
- pointRead("items", ITEMS_TABLE_ID, "2"), // a different read
480
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
481
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")])).length).toBe(0);
482
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "2")])).length).toBe(1);
483
- });
484
- it("lastSnapshotTs ordering: newer ts applies", async () => {
485
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
486
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
487
- pointRead("items", ITEMS_TABLE_ID, "1"),
488
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
489
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
490
- pointRead("items", ITEMS_TABLE_ID, "2"),
491
- ], { instanceName: "app-1", lastSnapshotTs: 20n }));
492
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")])).length).toBe(0);
493
- expect(worker.invalidate(mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "2")])).length).toBe(1);
494
- });
495
- it("cache metadata is updated with the new lastSnapshotTs on apply", async () => {
496
- await worker.register(makeRegisterParams({ instanceName: "app-1" }));
497
- await worker.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
498
- pointRead("items", ITEMS_TABLE_ID, "1"),
499
- ], { instanceName: "app-1", lastSnapshotTs: 42n }));
500
- expect(cache.meta.get("ck_a")?.lastSnapshotTs).toBe(42n);
501
- });
502
- });
503
- // ─── Routing tests ─────────────────────────────────────────────────────────
504
- describe("Worker routing by FNV-1a(subId)", () => {
505
- it("same subId always lands on same worker (multiple workers)", () => {
506
- const ids = ["sub_aa", "sub_bb", "sub_cc", "sub_a_very_long_one_just_to_be_safe"];
507
- for (const N of [1, 2, 4, 8, 16]) {
508
- for (const id of ids) {
509
- const a = fnv1a32(id) % N;
510
- const b = fnv1a32(id) % N;
511
- expect(a).toBe(b);
512
- }
513
- }
514
- });
515
- it("distinct subIds typically land on distinct workers across N>=2", () => {
516
- const N = 4;
517
- const buckets = new Set();
518
- for (let i = 0; i < 32; i++) {
519
- buckets.add(fnv1a32(`sub_${i}`) % N);
520
- }
521
- expect(buckets.size).toBeGreaterThanOrEqual(2);
522
- });
523
- });
524
- // ─── Worker isolation ──────────────────────────────────────────────────────
525
- describe("Worker isolation", () => {
526
- it("invalidate on worker A scans only worker A's subs; worker B sees nothing", async () => {
527
- const redis = new StubRedis();
528
- const cache = new StubCache(redis);
529
- const db = new StubDb();
530
- const workerA = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
531
- const workerB = new SubscriptionWorker(1, cacheOf(cache), dbOf(db));
532
- await workerA.register(makeRegisterParams({ instanceName: "app-1" }));
533
- await workerA.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
534
- pointRead("items", ITEMS_TABLE_ID, "1"),
535
- ], { instanceName: "app-1", lastSnapshotTs: 5n }));
536
- const ws = mapWriteSetToRaw([writeEntry("items", ITEMS_TABLE_ID, "1")]);
537
- expect(workerA.invalidate(ws).length).toBe(1);
538
- expect(workerB.invalidate(ws).length).toBe(0);
539
- });
540
- });
541
- // ─── Recovery simulation ───────────────────────────────────────────────────
542
- //
543
- // Simulate the post-restart flow:
544
- // 1. Worker A holds sub_a with a readSet.
545
- // 2. A "restart" creates Worker B with empty state but the SAME cache &
546
- // redis (since those are external services).
547
- // 3. App server reads sl:reg:sub_a from Redis and calls register on B.
548
- // 4. B's register() walks the commit log (if any) to decide if recompute
549
- // is needed, then sets up state.
550
- // 5. After register, an invalidate on B correctly finds the sub.
551
- describe("Recovery flow simulation", () => {
552
- it("a fresh worker with shared Redis can be re-registered from sl:reg + cache", async () => {
553
- const redis = new StubRedis();
554
- const cache = new StubCache(redis);
555
- const db = new StubDb();
556
- // Lifetime 1: worker A registers and uploads readSet
557
- const workerA = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
558
- await workerA.register(makeRegisterParams({ instanceName: "app-1" }));
559
- await workerA.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
560
- pointRead("items", ITEMS_TABLE_ID, "1"),
561
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
562
- // Lifetime 2: worker B is the "post-restart" worker. Cache + redis survive.
563
- const workerB = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
564
- // App server reads sl:reg from Redis to learn how to re-register.
565
- const raw = redis.store.get("sl:reg:sub_a");
566
- expect(raw).toBeTruthy();
567
- const record = JSON.parse(raw);
568
- // Re-register on worker B
569
- const res = await workerB.register({
570
- subId: "sub_a",
571
- cacheKey: "ck_a",
572
- queryName: record.queryName,
573
- args: record.args,
574
- queryIdentity: record.queryIdentity,
575
- instanceName: "app-1",
576
- lastSnapshotTs: 15n,
577
- });
578
- // Cache metadata gave us the existing readSet; no DB writes happened
579
- // between ts 10 and 15 (db.commitLogs is empty) → no recompute needed.
580
- expect(res.recompute).toBe(false);
581
- expect(res.readSet.length).toBe(1);
582
- // Invalidate against B finds the sub
583
- expect(workerB.invalidate(mapWriteSetToRaw([
584
- writeEntry("items", ITEMS_TABLE_ID, "1"),
585
- ])).length).toBe(1);
586
- });
587
- it("recovery with gap-writes triggers recompute=true via commit-log replay", async () => {
588
- const redis = new StubRedis();
589
- const cache = new StubCache(redis);
590
- const db = new StubDb();
591
- // Lifetime 1
592
- const workerA = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
593
- await workerA.register(makeRegisterParams({ instanceName: "app-1" }));
594
- await workerA.updateReadSet(makeUpdateParams("sub_a", "ck_a", [
595
- pointRead("items", ITEMS_TABLE_ID, "1"),
596
- ], { instanceName: "app-1", lastSnapshotTs: 10n }));
597
- // Simulate a write to row 1 that happened during the disconnect
598
- db.commitLogs = [{
599
- id: Buffer.from("1"),
600
- ts: 12n,
601
- tableId: ITEMS_TABLE_ID,
602
- data: Buffer.from(supaliveStringify({ id: "1", name: "modified" })),
603
- }];
604
- // Lifetime 2: fresh worker re-registers
605
- const workerB = new SubscriptionWorker(0, cacheOf(cache), dbOf(db));
606
- const res = await workerB.register(makeRegisterParams({ instanceName: "app-1", lastSnapshotTs: 15n }));
607
- expect(res.recompute).toBe(true);
608
- });
609
- });
610
- //# sourceMappingURL=subscription-manager.test.js.map