@supalive/core 0.1.3 → 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.
- package/README.md +308 -1
- package/dist/helper-CiacMqje.d.ts +14 -0
- package/dist/helper-CiacMqje.d.ts.map +1 -0
- package/dist/helper-zdJT5FUc.js +58 -0
- package/dist/helper-zdJT5FUc.js.map +1 -0
- package/dist/index-CTyXKcq_.d.ts +1736 -0
- package/dist/index-CTyXKcq_.d.ts.map +1 -0
- package/dist/mysql-C8SN443m.d.ts +104 -0
- package/dist/mysql-C8SN443m.d.ts.map +1 -0
- package/dist/mysql-DELSRrpY.js +569 -0
- package/dist/mysql-DELSRrpY.js.map +1 -0
- package/dist/overlap-checker-CVkbbuEQ.js +187 -0
- package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
- package/dist/postgres-DE-XyNAQ.js +823 -0
- package/dist/postgres-DE-XyNAQ.js.map +1 -0
- package/dist/postgres-D_gwCHx-.d.ts +108 -0
- package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
- package/dist/query-CbQLjeME.js +713 -0
- package/dist/query-CbQLjeME.js.map +1 -0
- package/dist/realtime_db-D9p2X2PH.js +246 -0
- package/dist/realtime_db-D9p2X2PH.js.map +1 -0
- package/dist/router-DlTYWpop.js +81 -0
- package/dist/router-DlTYWpop.js.map +1 -0
- package/dist/schema-SKcIWHfo.js +167 -0
- package/dist/schema-SKcIWHfo.js.map +1 -0
- package/dist/src/client/index.d.ts +2 -17
- package/dist/src/client/index.js +1151 -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 +5 -5
- 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-BurdPO8d.js +511 -0
- package/dist/subscription-worker-BurdPO8d.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-Ctf-ksoy.js +168 -0
- package/dist/types_db-Ctf-ksoy.js.map +1 -0
- package/dist/types_server-Ch56y8L3.d.ts +216 -0
- package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
- package/dist/types_server-Dk6o_B7N.js +24 -0
- package/dist/types_server-Dk6o_B7N.js.map +1 -0
- package/package.json +3 -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 -45
- 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 -8
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -7
- 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
package/README.md
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="../../supalive.png" alt="Supalive" width="420" />
|
|
3
|
+
|
|
1
4
|
# @supalive/core
|
|
2
5
|
|
|
3
6
|
Core package for supalive — a reactive database framework.
|
|
4
7
|
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
**Reactive queries for any Postgres/MySQL.**
|
|
11
|
+
|
|
12
|
+
Write a typed query once. The client re-renders the moment a mutation touches a row that would change the result — no triggers, no `LISTEN/NOTIFY` wiring, no manual cache invalidation.
|
|
13
|
+
|
|
14
|
+
[Live demo](#try-the-demo-locally) · [How it works](#how-it-works) · [Architecture](./examples/basic/README.md)
|
|
15
|
+
|
|
16
|
+
[](#license)
|
|
17
|
+
[](package.json)
|
|
18
|
+
[](#status)
|
|
19
|
+
|
|
5
20
|
## Installation
|
|
6
21
|
|
|
7
22
|
```bash
|
|
@@ -17,6 +32,298 @@ npm install @supalive/core
|
|
|
17
32
|
- `@supalive/core/server` - Server implementation
|
|
18
33
|
- `@supalive/core/client` - Client implementation
|
|
19
34
|
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## What it is
|
|
41
|
+
|
|
42
|
+
Supalive sits between your app and your database and turns any query into a live query.
|
|
43
|
+
|
|
44
|
+
You define procedures the same way you'd define a tRPC router. Mark one as a `query` and any client that calls `useLiveQuery` on it gets a stream of updates. When a `mutation` commits and its writes overlap that query's read set, Supalive recomputes and pushes the new result. Everything is typed end-to-end from the database column to the React hook.
|
|
45
|
+
|
|
46
|
+
It does **not** replace your database. It runs on top of plain Postgres or MySQL — including Supabase Postgres, Neon, RDS, PlanetScale — without schema changes.
|
|
47
|
+
|
|
48
|
+
## The 30-second version
|
|
49
|
+
|
|
50
|
+
**1. Define a query and a mutation.**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// procedures.ts
|
|
54
|
+
import { z } from "zod";
|
|
55
|
+
import { createQueryBuilder, createMutationBuilder, defineSchema, router } from "@supalive/core/procedure";
|
|
56
|
+
|
|
57
|
+
const query = createQueryBuilder<ServerContext>();
|
|
58
|
+
const mutation = createMutationBuilder<ServerContext>();
|
|
59
|
+
|
|
60
|
+
const ItemSchema = defineSchema({
|
|
61
|
+
name: "items",
|
|
62
|
+
schema: {
|
|
63
|
+
id: z.string(),
|
|
64
|
+
title: z.string(),
|
|
65
|
+
status: z.enum(["todo", "in_progress", "done"]),
|
|
66
|
+
ownerId: z.string(),
|
|
67
|
+
},
|
|
68
|
+
columns: { ownerId: "owner_id" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const listItems = query({
|
|
72
|
+
args: z.object({ status: z.enum(["todo", "in_progress", "done"]).optional() }),
|
|
73
|
+
handler: async (ctx, { status }) => {
|
|
74
|
+
let q = ctx.db.query(ItemSchema).select()
|
|
75
|
+
.where((f) => f.eq("ownerId", ctx.serverCtx.user.userId));
|
|
76
|
+
if (status) q = q.where((f) => f.eq("status", status));
|
|
77
|
+
return q.orderBy("createdAt", "desc").limit(50).get();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const createItem = mutation({
|
|
82
|
+
args: z.object({ id: z.string(), title: z.string(), status: z.enum(["todo", "in_progress", "done"]) }),
|
|
83
|
+
handler: async (ctx, args) => {
|
|
84
|
+
await ctx.db.insert(ItemSchema, args.id, {
|
|
85
|
+
...args,
|
|
86
|
+
ownerId: ctx.serverCtx.user.userId,
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
updatedAt: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const appRouter = router({ procedures: { listItems, createItem } });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**2. Start the server.**
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// server.ts
|
|
100
|
+
import { SupaliveWebSocketServer } from "@supalive/core/server";
|
|
101
|
+
import { appRouter } from "./procedures";
|
|
102
|
+
|
|
103
|
+
const { cacheLayer, redisSubClient } = initCacheLayer({
|
|
104
|
+
upstash: {
|
|
105
|
+
url: `https://${process.env.UPSTASH_REDIS_HOST}`,
|
|
106
|
+
token: process.env.UPSTASH_REDIS_TOKEN ?? "",
|
|
107
|
+
redisUrl: process.env.UPSTASH_REDIS_URL ?? ""
|
|
108
|
+
},
|
|
109
|
+
cacheTtlSeconds: 24 * 3600,
|
|
110
|
+
});
|
|
111
|
+
const db = await initDatabase({
|
|
112
|
+
DbConfig: { type: "postgres", connectionString: process.env.DATABASE_URL || '', },
|
|
113
|
+
CacheLayer: cacheLayer,
|
|
114
|
+
forceBootstrapReload: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const server = new SupaliveWebSocketServer({
|
|
118
|
+
port: 3000,
|
|
119
|
+
cacheLayer: cacheLayer,
|
|
120
|
+
redisSubClient: redisSubClient,
|
|
121
|
+
database: db,
|
|
122
|
+
subManagerUrl: process.env.SUB_MANAGER_URL!,
|
|
123
|
+
verifyAuth: async (data) => verifyJwt(data.token),
|
|
124
|
+
});
|
|
125
|
+
server.registerRouter(appRouter);
|
|
126
|
+
await server.start();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**3. Use it on the client.**
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
// App.tsx
|
|
133
|
+
import { useLiveQuery, useMutation } from "./supalive";
|
|
134
|
+
|
|
135
|
+
function Todos() {
|
|
136
|
+
const { data, status } = useLiveQuery((c) => c.listItems, { status: "todo" });
|
|
137
|
+
const [createItem] = useMutation((c) => c.createItem);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
{data?.map((item) => <li key={item.id}>{item.title}</li>)}
|
|
142
|
+
<button onClick={() => createItem({ id: crypto.randomUUID(), title: "new", status: "todo" })}>
|
|
143
|
+
add
|
|
144
|
+
</button>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Open this page in two tabs. Click **add** in one. Watch the list grow in both, automatically.
|
|
151
|
+
|
|
152
|
+
## Why this exists
|
|
153
|
+
|
|
154
|
+
Postgres `LISTEN/NOTIFY` is row-level — you get a notification, you still have to figure out which queries it affects. Supabase Realtime is great for broadcast, presence, and a row-level changefeed, but it doesn't answer the question every reactive app actually has: *"re-run this exact query when its result would change."*
|
|
155
|
+
|
|
156
|
+
The frameworks that *do* answer that question — Convex, Zero, Replicache, PowerSync — each ship their own database, sync engine, or client-side store. That's a lot to adopt when all you wanted was reactivity on top of the Postgres you already have.
|
|
157
|
+
|
|
158
|
+
Supalive is the smallest possible "reactive query" layer that works on top of your existing database. It's the Convex DX without the Convex lock-in.
|
|
159
|
+
|
|
160
|
+
## How it works
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
client supalive server sub-manager (1 process) postgres
|
|
164
|
+
────── ──────────────── ───────────────────────── ────────
|
|
165
|
+
┌── worker 0 ──┐
|
|
166
|
+
│ subs slice 0 │
|
|
167
|
+
├── worker 1 ──┤ ← FNV-1a(subId) % N
|
|
168
|
+
│ subs slice 1 │ routes ops here
|
|
169
|
+
├── … ────┤
|
|
170
|
+
│ worker N-1 │
|
|
171
|
+
└──────────────┘
|
|
172
|
+
|
|
173
|
+
subscribe ────────▶ register subId ───────────▶ slot in worker; if metadata
|
|
174
|
+
is still valid vs commit log,
|
|
175
|
+
skip recompute
|
|
176
|
+
liveQuery ────────────────────────────────────────────▶ SELECT
|
|
177
|
+
cache { data, metadata }
|
|
178
|
+
sub:update ◀──────── push result
|
|
179
|
+
|
|
180
|
+
call(mutation) ────▶ run handler (DbWriter)
|
|
181
|
+
capture readSet + writeSet (+ prevData)
|
|
182
|
+
OCC commit:
|
|
183
|
+
beginTs = latest snapshot
|
|
184
|
+
getNextTimestamp() ────────────────────────────────▶ commit_ts
|
|
185
|
+
CAS apply + readSet validate ─────────────────────▶ BEGIN…COMMIT
|
|
186
|
+
retries on conflict (cockatiel, max 15)
|
|
187
|
+
invalidateWriteset ────────▶ every worker scans its
|
|
188
|
+
slice; per-sub same-table
|
|
189
|
+
filter + exact predicate
|
|
190
|
+
eval on prev+post row
|
|
191
|
+
◀── affected subs
|
|
192
|
+
recompute locally (if subs are here)
|
|
193
|
+
OR publish recompute-race
|
|
194
|
+
on sub:instance:<id> ─┐
|
|
195
|
+
SETNX lock:recompute: │ (cross-instance fanout
|
|
196
|
+
<subId>:<commitTs> │ via Redis pub/sub)
|
|
197
|
+
winner runs query, losers │
|
|
198
|
+
wait for update broadcast ─┘
|
|
199
|
+
sub:update ◀───────── { data, dataHash }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Four things make this fast and correct:
|
|
203
|
+
|
|
204
|
+
- **ReadSet + WriteSet capture.** Every query records exactly which point reads and range predicates (with column lists, order, and limit) it touched. Every mutation records the rows it wrote plus their previous values — both sides are needed so the invalidator can detect rows entering, leaving, or changing within a result set.
|
|
205
|
+
- **Sharded scan, not row-by-row recheck.** A single sub-manager process owns all subscription state, partitioned across N logical workers by `FNV-1a(subId) % N` (configurable via `SUPALIVE_SUB_MANAGER_WORKERS`). On commit, each worker scans only its slice; per-sub the check short-circuits on table-id mismatch, then runs an exact 3-valued predicate evaluation on the row's pre- and post-images. New subscribers also benefit: if the worker still holds metadata for a `cacheKey`, it replays `commit_logs` between the cached `snapshotTs` and the caller's `snapshotTs` to decide whether a fresh `liveQuery` is needed at all.
|
|
206
|
+
- **OCC with retries.** Mutations execute against a snapshot, allocate `commitTs` from a DB sequence at apply time, and commit via compare-and-swap with a readSet validator (per-row fast path; commit-log scan when range reads are present). Conflicts retry with exponential backoff (`cockatiel`, up to 15 attempts). The Σ-balance invariant in the money-transfer demo holds under burst contention because of this.
|
|
207
|
+
- **Cross-instance fanout with a deterministic recompute winner.** Multiple Supalive servers behind a load balancer share one sub-manager and one Redis. When a write on instance A affects a subscription whose session lives on instance B, A publishes a `recompute-race` on `sub:instance:<B>`; B and any other holders try to `SETNX lock:recompute:<subId>:<commitTs>` — the winner re-runs the query and broadcasts `sub:update` on `sub:instance:<…>`; losers no-op. A periodic prune trims `commit_logs` below the oldest active subscription's snapshot.
|
|
208
|
+
|
|
209
|
+
Full architecture write-up — including the cache layout, schema-reload broadcast, and sub-manager recovery protocol — lives in [examples/basic/README.md](./examples/basic/README.md).
|
|
210
|
+
|
|
211
|
+
## Try the demo locally
|
|
212
|
+
|
|
213
|
+
The `examples/basic` workspace ships a 5-tab React demo that shows everything Supalive can do. Each tab is a different stress test of the system.
|
|
214
|
+
|
|
215
|
+
| Tab | What it shows |
|
|
216
|
+
|---|---|
|
|
217
|
+
| **Todos · list** | Live query + create. Toggle the subscription to prove updates only flow when subscribed. |
|
|
218
|
+
| **Todos · cards** | Filter + inline edit. Changing `status` moves a row in or out of the visible set — predicate index handles it. |
|
|
219
|
+
| **Mouse tracking** | High-frequency writes (cursor positions @ ~60ms). Stress test for fanout. |
|
|
220
|
+
| **Money transfer** | OCC under contention. Σ-balance on screen must never drift, no matter how hard you spam transfers. |
|
|
221
|
+
| **Parallel lanes** | Disjoint concurrent transfers. Non-overlapping writers don't block each other. |
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
git clone https://github.com/rebaz94/supalive
|
|
225
|
+
cd supalive
|
|
226
|
+
npm install
|
|
227
|
+
|
|
228
|
+
cp examples/basic/.env.example examples/basic/.env
|
|
229
|
+
# fill in DATABASE_URL, REDIS_URL, UPSTASH_REDIS_TOKEN, JWT_SECRET
|
|
230
|
+
|
|
231
|
+
# terminal 1 — sub-manager
|
|
232
|
+
cd examples/basic && npm run dev:submanager
|
|
233
|
+
|
|
234
|
+
# terminal 2 — websocket server
|
|
235
|
+
cd examples/basic && npm run dev
|
|
236
|
+
|
|
237
|
+
# terminal 3 — web demo
|
|
238
|
+
cd examples/basic && npm run dev:demo
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Open the demo URL in two tabs and click around. The whole point is that **nothing in your app code wires the updates** — Supalive does.
|
|
242
|
+
|
|
243
|
+
## API surface
|
|
244
|
+
|
|
245
|
+
### Server
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { SupaliveWebSocketServer, SubscriptionManager } from "@supalive/core/server";
|
|
249
|
+
import {
|
|
250
|
+
createQueryBuilder, createMutationBuilder, createActionBuilder,
|
|
251
|
+
defineSchema, router,
|
|
252
|
+
} from "@supalive/core/procedure";
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- `query` — read-only, subscribable. ReadSet captured automatically.
|
|
256
|
+
- `mutation` — read + write, transactional, OCC-committed.
|
|
257
|
+
- `action` — arbitrary async work (HTTP, queue calls). Can compose queries and mutations via `ctx.db`.
|
|
258
|
+
|
|
259
|
+
### Client (React)
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import {
|
|
263
|
+
createSupaliveContext, createSupaliveProvider, createUseSupalive,
|
|
264
|
+
createUseConnectionState, createLiveQuery, createQuery,
|
|
265
|
+
createMutation, createAction,
|
|
266
|
+
} from "@supalive/react";
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- `useLiveQuery(c => c.listItems, args)` — subscribe; re-renders on push.
|
|
270
|
+
- `useQuery(c => c.getItem, args)` — one-shot.
|
|
271
|
+
- `useMutation(c => c.createItem)` — returns `[mutate, { status }]`.
|
|
272
|
+
- `useAction(c => c.bulkUpdate)` — returns `[run, { status }]`.
|
|
273
|
+
- `useSupaliveConnectionState()` — `connecting | connected | reconnecting | closed`.
|
|
274
|
+
|
|
275
|
+
The hooks are factory-built so you get fully-typed autocomplete on `c.<procedure>` from your own router type. See [examples/basic/src/web/supalive.tsx](./examples/basic/src/web/supalive.tsx) for the wiring pattern.
|
|
276
|
+
|
|
277
|
+
## Where it sits
|
|
278
|
+
|
|
279
|
+
| | Supalive | Supabase Realtime | ElectricSQL | PowerSync | Zero | Replicache | Convex |
|
|
280
|
+
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
|
281
|
+
| Live queries (re-runs on relevant writes) | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
282
|
+
| Works on your own Postgres / MySQL | ✓ | ✓ | ✓ | ✓ | — | — | — |
|
|
283
|
+
| Server-authoritative auth + validation | ✓ | ✓ | partial | partial | ✓ | partial | ✓ |
|
|
284
|
+
| OCC mutations w/ automatic retry | ✓ | — | — | — | partial | — | ✓ |
|
|
285
|
+
| Typed end-to-end (TS, no codegen) | ✓ | — | — | — | ✓ | ✓ | ✓ |
|
|
286
|
+
| No client-side SQLite required | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
|
|
287
|
+
| Horizontally scalable out of the box | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
288
|
+
| MIT, self-hostable | ✓ | ✓ | ✓ | partial | ✓ | — | — |
|
|
289
|
+
|
|
290
|
+
(Categories vary in nuance; this is the executive-summary view. PRs welcome to refine.)
|
|
291
|
+
|
|
292
|
+
## Production
|
|
293
|
+
|
|
294
|
+
The repo already includes Fly configs for a three-process deployment:
|
|
295
|
+
|
|
296
|
+
- `examples/basic/config/fly.server.toml` — `SupaliveWebSocketServer` (handles client websockets)
|
|
297
|
+
- `examples/basic/config/fly.submanager.toml` — `SubscriptionManager` (predicate index, overlap checks)
|
|
298
|
+
- `examples/basic/config/fly.web.toml` — the demo frontend
|
|
299
|
+
|
|
300
|
+
Multiple `SupaliveWebSocketServer` instances behind a load balancer share state via:
|
|
301
|
+
|
|
302
|
+
- The sub-manager (single source of truth for active subscriptions)
|
|
303
|
+
- Redis pub/sub (cross-instance recompute + cache-invalidation messages)
|
|
304
|
+
- An Upstash Redis cache layer for query results
|
|
305
|
+
|
|
306
|
+
### Environment variables
|
|
307
|
+
|
|
308
|
+
| Variable | Required | Description |
|
|
309
|
+
|---|---|---|
|
|
310
|
+
| `DATABASE_URL` | yes | Postgres or MySQL connection string |
|
|
311
|
+
| `REDIS_URL` / `UPSTASH_REDIS_URL` | yes | Cache + pub/sub |
|
|
312
|
+
| `UPSTASH_REDIS_TOKEN` | yes | If using Upstash HTTP transport |
|
|
313
|
+
| `SUB_MANAGER_URL` | yes | `ws://…:3003` — where the sub-manager listens |
|
|
314
|
+
| `JWT_SECRET` | yes (example) | The example verifies JWTs with `jose` |
|
|
315
|
+
| `SUPALIVE_DB_QUERY_TIMEOUT_MS` | no | Per-statement timeout, default 30s. `0` to disable |
|
|
316
|
+
| `SUPALIVE_HEARTBEAT_INTERVAL_MS` | no | WebSocket heartbeat, default 15s |
|
|
317
|
+
| `SUPALIVE_RECOMPUTE_LOCK_TTL_SECONDS` | no | Redis recompute lock TTL, default 10s |
|
|
318
|
+
| `SUPALIVE_SUB_MANAGER_WORKERS` | no | Logical workers inside the sub-manager process, default 1. Subscriptions are hash-routed across them |
|
|
319
|
+
| `SUPALIVE_SCHEMA_VERSION` | no | Cache-busting key for column-type bootstrap |
|
|
320
|
+
|
|
321
|
+
## Status
|
|
322
|
+
|
|
323
|
+
**Alpha.** APIs may change. The implementation is real — there's a working test suite (`packages/test`, `packages/core/src/server/*.test.ts`), the OCC and overlap-detection layers carry production-shaped concerns (statement timeouts, prepared statement cache, schema-reload broadcast, sub-manager recovery), and the demo is end-to-end. But: no semver guarantees yet, no published npm packages, no formal docs site.
|
|
324
|
+
|
|
325
|
+
If you're evaluating Supalive for production, open an issue first — I'd love to know what you're building.
|
|
326
|
+
|
|
20
327
|
## License
|
|
21
328
|
|
|
22
|
-
MIT
|
|
329
|
+
MIT © Rebaz Raouf
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/helper.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Synchronous deterministic JSON stringification.
|
|
4
|
+
* Keys are sorted; `undefined` values are dropped.
|
|
5
|
+
*
|
|
6
|
+
* Suitable for React `useEffect` dependency keys — calling this on the same
|
|
7
|
+
* logical input always produces the same string, regardless of property order.
|
|
8
|
+
*/
|
|
9
|
+
declare function stableStringify(input: unknown): string;
|
|
10
|
+
declare function supaliveStringify(input: unknown): string;
|
|
11
|
+
declare function groupByToMap<T, K>(items: Iterable<T>, getKey: (item: T) => K): Map<K, T[]>;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { stableStringify as n, supaliveStringify as r, groupByToMap as t };
|
|
14
|
+
//# sourceMappingURL=helper-CiacMqje.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper-CiacMqje.d.ts","names":[],"sources":["../src/helper.ts"],"mappings":";;AASA;;;;AAA8C;AAS9C;iBATgB,eAAA,CAAgB,KAAc;AAAA,iBAS9B,iBAAA,CAAkB,KAAc;AAAA,iBAchC,YAAA,OACd,KAAA,EAAO,QAAA,CAAS,CAAA,GAChB,MAAA,GAAS,IAAA,EAAM,CAAA,KAAM,CAAA,GACpB,GAAA,CAAI,CAAA,EAAG,CAAA"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import stableStringifyFn from "json-stable-stringify";
|
|
2
|
+
//#region src/db/bytes.ts
|
|
3
|
+
function bytesEqual(a, b) {
|
|
4
|
+
if (a === b) return true;
|
|
5
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
6
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const _enc = new TextEncoder();
|
|
10
|
+
const _dec = new TextDecoder("utf-8");
|
|
11
|
+
function utf8Encode(s) {
|
|
12
|
+
return _enc.encode(s);
|
|
13
|
+
}
|
|
14
|
+
function utf8Decode(b) {
|
|
15
|
+
return _dec.decode(b);
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/helper.ts
|
|
19
|
+
/**
|
|
20
|
+
* Synchronous deterministic JSON stringification.
|
|
21
|
+
* Keys are sorted; `undefined` values are dropped.
|
|
22
|
+
*
|
|
23
|
+
* Suitable for React `useEffect` dependency keys — calling this on the same
|
|
24
|
+
* logical input always produces the same string, regardless of property order.
|
|
25
|
+
*/
|
|
26
|
+
function stableStringify(input) {
|
|
27
|
+
return stableStringifyFn(input, { replacer: (_, v) => {
|
|
28
|
+
if (typeof v === "bigint") return v.toString();
|
|
29
|
+
return v ?? void 0;
|
|
30
|
+
} }) ?? "";
|
|
31
|
+
}
|
|
32
|
+
function supaliveStringify(input) {
|
|
33
|
+
return JSON.stringify(input, (_, v) => {
|
|
34
|
+
if (typeof v === "bigint") return v.toString();
|
|
35
|
+
if (v instanceof Uint8Array) return {
|
|
36
|
+
type: "Buffer",
|
|
37
|
+
data: Array.from(v)
|
|
38
|
+
};
|
|
39
|
+
return v ?? void 0;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function groupByToMap(items, getKey) {
|
|
43
|
+
const map = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
const key = getKey(item);
|
|
46
|
+
let group = map.get(key);
|
|
47
|
+
if (!group) {
|
|
48
|
+
group = [];
|
|
49
|
+
map.set(key, group);
|
|
50
|
+
}
|
|
51
|
+
group.push(item);
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { utf8Decode as a, bytesEqual as i, stableStringify as n, utf8Encode as o, supaliveStringify as r, groupByToMap as t };
|
|
57
|
+
|
|
58
|
+
//# sourceMappingURL=helper-zdJT5FUc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helper-zdJT5FUc.js","names":[],"sources":["../src/db/bytes.ts","../src/helper.ts"],"sourcesContent":["// Small helpers for byte arrays. We standardize on Uint8Array internally so\n// values survive structured clone (worker_threads postMessage) and stay\n// browser-safe. Buffer is a Uint8Array subclass, so values from pg/mysql\n// drivers satisfy Uint8Array fields automatically.\n\nexport function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {\n if (a === b) return true;\n if (a.byteLength !== b.byteLength) return false;\n for (let i = 0; i < a.byteLength; i++) {\n if (a[i] !== b[i]) return false;\n }\n return true;\n}\n\nconst _enc = new TextEncoder();\nconst _dec = new TextDecoder(\"utf-8\");\n\nexport function utf8Encode(s: string): Uint8Array {\n return _enc.encode(s);\n}\n\nexport function utf8Decode(b: Uint8Array): string {\n return _dec.decode(b);\n}\n","import stableStringifyFn from \"json-stable-stringify\";\n\n/**\n * Synchronous deterministic JSON stringification.\n * Keys are sorted; `undefined` values are dropped.\n *\n * Suitable for React `useEffect` dependency keys — calling this on the same\n * logical input always produces the same string, regardless of property order.\n */\nexport function stableStringify(input: unknown): string {\n return stableStringifyFn(input, {\n replacer: (_, v) => {\n if (typeof v === \"bigint\") return v.toString();\n return v ?? undefined;\n }\n }) ?? \"\";\n}\n\nexport function supaliveStringify(input: unknown): string {\n return JSON.stringify(input, (_, v) => {\n if (typeof v === \"bigint\") return v.toString();\n // Emit a stable wire shape for byte arrays regardless of whether the\n // producer holds a Buffer or a plain Uint8Array (the latter is what\n // crosses worker_threads postMessage). The matching decoder lives in\n // `bytesFromJson` in db/types_db.ts.\n if (v instanceof Uint8Array) {\n return { type: \"Buffer\", data: Array.from(v) };\n }\n return v ?? undefined;\n });\n}\n\nexport function groupByToMap<T, K>(\n items: Iterable<T>,\n getKey: (item: T) => K,\n): Map<K, T[]> {\n const map = new Map<K, T[]>();\n\n for (const item of items) {\n const key = getKey(item);\n\n let group = map.get(key);\n if (!group) {\n group = [];\n map.set(key, group);\n }\n\n group.push(item);\n }\n\n return map;\n}\n"],"mappings":";;AAKA,SAAgB,WAAW,GAAe,GAAwB;CAChE,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,EAAE,eAAe,EAAE,YAAY,OAAO;CAC1C,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,YAAY,KAChC,IAAI,EAAE,OAAO,EAAE,IAAI,OAAO;CAE5B,OAAO;AACT;AAEA,MAAM,OAAO,IAAI,YAAY;AAC7B,MAAM,OAAO,IAAI,YAAY,OAAO;AAEpC,SAAgB,WAAW,GAAuB;CAChD,OAAO,KAAK,OAAO,CAAC;AACtB;AAEA,SAAgB,WAAW,GAAuB;CAChD,OAAO,KAAK,OAAO,CAAC;AACtB;;;;;;;;;;ACdA,SAAgB,gBAAgB,OAAwB;CACtD,OAAO,kBAAkB,OAAO,EAC9B,WAAW,GAAG,MAAM;EAClB,IAAI,OAAO,MAAM,UAAU,OAAO,EAAE,SAAS;EAC7C,OAAO,KAAK,KAAA;CACd,EACF,CAAC,KAAK;AACR;AAEA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,KAAK,UAAU,QAAQ,GAAG,MAAM;EACrC,IAAI,OAAO,MAAM,UAAU,OAAO,EAAE,SAAS;EAK7C,IAAI,aAAa,YACf,OAAO;GAAE,MAAM;GAAU,MAAM,MAAM,KAAK,CAAC;EAAE;EAE/C,OAAO,KAAK,KAAA;CACd,CAAC;AACH;AAEA,SAAgB,aACd,OACA,QACa;CACb,MAAM,sBAAM,IAAI,IAAY;CAE5B,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,OAAO,IAAI;EAEvB,IAAI,QAAQ,IAAI,IAAI,GAAG;EACvB,IAAI,CAAC,OAAO;GACV,QAAQ,CAAC;GACT,IAAI,IAAI,KAAK,KAAK;EACpB;EAEA,MAAM,KAAK,IAAI;CACjB;CAEA,OAAO;AACT"}
|