@supalive/core 0.1.4 → 0.1.6
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.
- package/README.md +17 -2
- package/dist/bytes-AuEkyxeU.js +19 -0
- package/dist/bytes-AuEkyxeU.js.map +1 -0
- package/dist/codec-BHAmGEIQ.js +62 -0
- package/dist/codec-BHAmGEIQ.js.map +1 -0
- package/dist/helper-CiacMqje.d.ts +14 -0
- package/dist/helper-CiacMqje.d.ts.map +1 -0
- package/dist/helper-wGbzz6zo.js +42 -0
- package/dist/helper-wGbzz6zo.js.map +1 -0
- package/dist/index-CaWMDqq0.d.ts +1754 -0
- package/dist/index-CaWMDqq0.d.ts.map +1 -0
- package/dist/mysql-Cxd4Uq5p.d.ts +104 -0
- package/dist/mysql-Cxd4Uq5p.d.ts.map +1 -0
- package/dist/mysql-bvnjYWvZ.js +570 -0
- package/dist/mysql-bvnjYWvZ.js.map +1 -0
- package/dist/overlap-checker-Ct_HP_iB.js +187 -0
- package/dist/overlap-checker-Ct_HP_iB.js.map +1 -0
- package/dist/postgres-B8u1YN8X.js +824 -0
- package/dist/postgres-B8u1YN8X.js.map +1 -0
- package/dist/postgres-Ck-xt72U.d.ts +108 -0
- package/dist/postgres-Ck-xt72U.d.ts.map +1 -0
- package/dist/procedure-DX87tyef.js +124 -0
- package/dist/procedure-DX87tyef.js.map +1 -0
- package/dist/query-Bh0RNxDz.js +673 -0
- package/dist/query-Bh0RNxDz.js.map +1 -0
- package/dist/realtime_db-DOCgqbwi.js +247 -0
- package/dist/realtime_db-DOCgqbwi.js.map +1 -0
- package/dist/router-DlTYWpop.js +81 -0
- package/dist/router-DlTYWpop.js.map +1 -0
- package/dist/schema-Dd7u0A1L.js +109 -0
- package/dist/schema-Dd7u0A1L.js.map +1 -0
- package/dist/src/client/index.d.ts +2 -17
- package/dist/src/client/index.js +1152 -18
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +2 -2
- package/dist/src/exports/mysql.js +2 -2
- package/dist/src/exports/postgres.d.ts +2 -2
- package/dist/src/exports/postgres.js +2 -2
- package/dist/src/exports/procedure.d.ts +3 -5
- package/dist/src/exports/procedure.js +6 -5
- package/dist/src/exports/schema-sql.d.ts +18 -0
- package/dist/src/exports/schema-sql.d.ts.map +1 -0
- package/dist/src/exports/schema-sql.js +161 -0
- package/dist/src/exports/schema-sql.js.map +1 -0
- package/dist/src/exports/server.d.ts +368 -2
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +1346 -2
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
- package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
- package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
- package/dist/src/exports/types.d.ts +4 -6
- package/dist/src/exports/types.js +5 -6
- package/dist/subscription-worker-pahX0zxg.js +511 -0
- package/dist/subscription-worker-pahX0zxg.js.map +1 -0
- package/dist/types_client_rpc-CIsjQQGG.js +105 -0
- package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
- package/dist/types_db-DXj_l9n-.js +168 -0
- package/dist/types_db-DXj_l9n-.js.map +1 -0
- package/dist/types_server-Dk6o_B7N.js +24 -0
- package/dist/types_server-Dk6o_B7N.js.map +1 -0
- package/dist/types_server-TFo6SWDH.d.ts +216 -0
- package/dist/types_server-TFo6SWDH.d.ts.map +1 -0
- package/package.json +7 -3
- package/dist/src/client/caller_client.d.ts +0 -42
- package/dist/src/client/caller_client.d.ts.map +0 -1
- package/dist/src/client/caller_client.js +0 -77
- package/dist/src/client/caller_client.js.map +0 -1
- package/dist/src/client/client.d.ts +0 -149
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js +0 -308
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/sub_key.d.ts +0 -20
- package/dist/src/client/sub_key.d.ts.map +0 -1
- package/dist/src/client/sub_key.js +0 -37
- package/dist/src/client/sub_key.js.map +0 -1
- package/dist/src/client/ws_client_manager.d.ts +0 -268
- package/dist/src/client/ws_client_manager.d.ts.map +0 -1
- package/dist/src/client/ws_client_manager.js +0 -1013
- package/dist/src/client/ws_client_manager.js.map +0 -1
- package/dist/src/config.d.ts +0 -117
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -2
- package/dist/src/config.js.map +0 -1
- package/dist/src/db/bytes.d.ts +0 -4
- package/dist/src/db/bytes.d.ts.map +0 -1
- package/dist/src/db/bytes.js +0 -24
- package/dist/src/db/bytes.js.map +0 -1
- package/dist/src/db/cache.d.ts +0 -30
- package/dist/src/db/cache.d.ts.map +0 -1
- package/dist/src/db/cache.js +0 -153
- package/dist/src/db/cache.js.map +0 -1
- package/dist/src/db/codec.d.ts +0 -10
- package/dist/src/db/codec.d.ts.map +0 -1
- package/dist/src/db/codec.js +0 -73
- package/dist/src/db/codec.js.map +0 -1
- package/dist/src/db/context.d.ts +0 -28
- package/dist/src/db/context.d.ts.map +0 -1
- package/dist/src/db/context.js +0 -147
- package/dist/src/db/context.js.map +0 -1
- package/dist/src/db/db.d.ts +0 -154
- package/dist/src/db/db.d.ts.map +0 -1
- package/dist/src/db/db.js +0 -23
- package/dist/src/db/db.js.map +0 -1
- package/dist/src/db/mysql.d.ts +0 -120
- package/dist/src/db/mysql.d.ts.map +0 -1
- package/dist/src/db/mysql.js +0 -708
- package/dist/src/db/mysql.js.map +0 -1
- package/dist/src/db/postgres.d.ts +0 -116
- package/dist/src/db/postgres.d.ts.map +0 -1
- package/dist/src/db/postgres.js +0 -998
- package/dist/src/db/postgres.js.map +0 -1
- package/dist/src/db/query.d.ts +0 -174
- package/dist/src/db/query.d.ts.map +0 -1
- package/dist/src/db/query.js +0 -790
- package/dist/src/db/query.js.map +0 -1
- package/dist/src/db/query_definition.d.ts +0 -54
- package/dist/src/db/query_definition.d.ts.map +0 -1
- package/dist/src/db/query_definition.js +0 -47
- package/dist/src/db/query_definition.js.map +0 -1
- package/dist/src/db/realtime_db.d.ts +0 -19
- package/dist/src/db/realtime_db.d.ts.map +0 -1
- package/dist/src/db/realtime_db.js +0 -98
- package/dist/src/db/realtime_db.js.map +0 -1
- package/dist/src/db/schema.d.ts +0 -110
- package/dist/src/db/schema.d.ts.map +0 -1
- package/dist/src/db/schema.js +0 -47
- package/dist/src/db/schema.js.map +0 -1
- package/dist/src/db/types_client_rpc.d.ts +0 -157
- package/dist/src/db/types_client_rpc.d.ts.map +0 -1
- package/dist/src/db/types_client_rpc.js +0 -106
- package/dist/src/db/types_client_rpc.js.map +0 -1
- package/dist/src/db/types_db.d.ts +0 -236
- package/dist/src/db/types_db.d.ts.map +0 -1
- package/dist/src/db/types_db.js +0 -188
- package/dist/src/db/types_db.js.map +0 -1
- package/dist/src/exports/mysql.d.ts.map +0 -1
- package/dist/src/exports/mysql.js.map +0 -1
- package/dist/src/exports/postgres.d.ts.map +0 -1
- package/dist/src/exports/postgres.js.map +0 -1
- package/dist/src/exports/procedure.d.ts.map +0 -1
- package/dist/src/exports/procedure.js.map +0 -1
- package/dist/src/exports/types.d.ts.map +0 -1
- package/dist/src/exports/types.js.map +0 -1
- package/dist/src/helper.d.ts +0 -11
- package/dist/src/helper.d.ts.map +0 -1
- package/dist/src/helper.js +0 -45
- package/dist/src/helper.js.map +0 -1
- package/dist/src/logger.d.ts +0 -7
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/logger.js +0 -47
- package/dist/src/logger.js.map +0 -1
- package/dist/src/router/index.d.ts +0 -4
- package/dist/src/router/index.d.ts.map +0 -1
- package/dist/src/router/index.js +0 -4
- package/dist/src/router/index.js.map +0 -1
- package/dist/src/router/procedure.d.ts +0 -204
- package/dist/src/router/procedure.d.ts.map +0 -1
- package/dist/src/router/procedure.js +0 -122
- package/dist/src/router/procedure.js.map +0 -1
- package/dist/src/router/router.d.ts +0 -88
- package/dist/src/router/router.d.ts.map +0 -1
- package/dist/src/router/router.js +0 -88
- package/dist/src/router/router.js.map +0 -1
- package/dist/src/server/hash.d.ts +0 -12
- package/dist/src/server/hash.d.ts.map +0 -1
- package/dist/src/server/hash.js +0 -20
- package/dist/src/server/hash.js.map +0 -1
- package/dist/src/server/hash.test.d.ts +0 -2
- package/dist/src/server/hash.test.d.ts.map +0 -1
- package/dist/src/server/hash.test.js +0 -43
- package/dist/src/server/hash.test.js.map +0 -1
- package/dist/src/server/index.d.ts +0 -9
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -8
- package/dist/src/server/index.js.map +0 -1
- package/dist/src/server/overlap-checker.d.ts +0 -18
- package/dist/src/server/overlap-checker.d.ts.map +0 -1
- package/dist/src/server/overlap-checker.js +0 -247
- package/dist/src/server/overlap-checker.js.map +0 -1
- package/dist/src/server/sub_hash.d.ts +0 -23
- package/dist/src/server/sub_hash.d.ts.map +0 -1
- package/dist/src/server/sub_hash.js +0 -38
- package/dist/src/server/sub_hash.js.map +0 -1
- package/dist/src/server/subscription-manager-client.d.ts +0 -120
- package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-client.js +0 -207
- package/dist/src/server/subscription-manager-client.js.map +0 -1
- package/dist/src/server/subscription-manager-types.d.ts +0 -168
- package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-types.js +0 -94
- package/dist/src/server/subscription-manager-types.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
- package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.js +0 -127
- package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
- package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
- package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
- package/dist/src/server/subscription-manager.d.ts +0 -32
- package/dist/src/server/subscription-manager.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.js +0 -249
- package/dist/src/server/subscription-manager.js.map +0 -1
- package/dist/src/server/subscription-manager.test.d.ts +0 -2
- package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.test.js +0 -610
- package/dist/src/server/subscription-manager.test.js.map +0 -1
- package/dist/src/server/subscription-worker.d.ts +0 -32
- package/dist/src/server/subscription-worker.d.ts.map +0 -1
- package/dist/src/server/subscription-worker.js +0 -190
- package/dist/src/server/subscription-worker.js.map +0 -1
- package/dist/src/server/supalive-server.d.ts +0 -158
- package/dist/src/server/supalive-server.d.ts.map +0 -1
- package/dist/src/server/supalive-server.js +0 -1048
- package/dist/src/server/supalive-server.js.map +0 -1
- package/dist/src/server/types_server.d.ts +0 -96
- package/dist/src/server/types_server.d.ts.map +0 -1
- package/dist/src/server/types_server.js +0 -36
- package/dist/src/server/types_server.js.map +0 -1
- 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
|