@absolutejs/sync 0.0.1 → 0.2.0
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 +281 -24
- package/dist/adapters/drizzle/collection.d.ts +27 -0
- package/dist/adapters/drizzle/index.d.ts +20 -0
- package/dist/adapters/drizzle/index.js +265 -0
- package/dist/adapters/drizzle/index.js.map +14 -0
- package/dist/adapters/drizzle/predicate.d.ts +20 -0
- package/dist/adapters/drizzle/read.d.ts +31 -0
- package/dist/adapters/drizzle/topics.d.ts +41 -0
- package/dist/adapters/drizzle/write.d.ts +69 -0
- package/dist/adapters/mysql/index.d.ts +75 -0
- package/dist/adapters/mysql/index.js +171 -0
- package/dist/adapters/mysql/index.js.map +11 -0
- package/dist/adapters/postgres/index.d.ts +53 -0
- package/dist/adapters/postgres/index.js +86 -0
- package/dist/adapters/postgres/index.js.map +10 -0
- package/dist/adapters/prisma/collection.d.ts +39 -0
- package/dist/adapters/prisma/index.d.ts +23 -0
- package/dist/adapters/prisma/index.js +231 -0
- package/dist/adapters/prisma/index.js.map +14 -0
- package/dist/adapters/prisma/predicate.d.ts +20 -0
- package/dist/adapters/prisma/read.d.ts +28 -0
- package/dist/adapters/prisma/topics.d.ts +29 -0
- package/dist/adapters/prisma/write.d.ts +65 -0
- package/dist/adapters/sqlite/index.d.ts +32 -0
- package/dist/adapters/sqlite/index.js +128 -0
- package/dist/adapters/sqlite/index.js.map +11 -0
- package/dist/angular/index.d.ts +1 -0
- package/dist/angular/index.js +347 -0
- package/dist/angular/index.js.map +11 -0
- package/dist/angular/sync-collection.service.d.ts +20 -0
- package/dist/client/index.d.ts +12 -30
- package/dist/client/index.js +1099 -3
- package/dist/client/index.js.map +10 -4
- package/dist/client/liveQuery.d.ts +75 -0
- package/dist/client/presence.d.ts +37 -0
- package/dist/client/subscriber.d.ts +30 -0
- package/dist/client/syncClient.d.ts +53 -0
- package/dist/client/syncCollection.d.ts +102 -0
- package/dist/client/syncStore.d.ts +81 -0
- package/dist/engine/aggregate.d.ts +45 -0
- package/dist/engine/cluster.d.ts +41 -0
- package/dist/engine/collection.d.ts +87 -0
- package/dist/engine/connection.d.ts +103 -0
- package/dist/engine/dataflow.d.ts +109 -0
- package/dist/engine/equiJoin.d.ts +51 -0
- package/dist/engine/graph.d.ts +85 -0
- package/dist/engine/index.d.ts +40 -0
- package/dist/engine/index.js +1774 -0
- package/dist/engine/index.js.map +23 -0
- package/dist/engine/materializedView.d.ts +53 -0
- package/dist/engine/mutation.d.ts +66 -0
- package/dist/engine/pollingSource.d.ts +42 -0
- package/dist/engine/presence.d.ts +46 -0
- package/dist/engine/reactive.d.ts +67 -0
- package/dist/engine/routes.d.ts +40 -0
- package/dist/engine/socket.d.ts +67 -0
- package/dist/engine/syncEngine.d.ts +132 -0
- package/dist/engine/types.d.ts +45 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +327 -3
- package/dist/index.js.map +8 -5
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +332 -0
- package/dist/react/index.js.map +11 -0
- package/dist/react/useSyncCollection.d.ts +16 -0
- package/dist/reactiveHub.d.ts +6 -0
- package/dist/svelte/createSyncCollectionStore.d.ts +15 -0
- package/dist/svelte/index.d.ts +1 -0
- package/dist/svelte/index.js +338 -0
- package/dist/svelte/index.js.map +11 -0
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/index.js +331 -0
- package/dist/vue/index.js.map +11 -0
- package/dist/vue/useSyncCollection.d.ts +17 -0
- package/package.json +104 -6
package/README.md
CHANGED
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
# @absolutejs/sync
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
and
|
|
3
|
+
Reactive data primitives for [Elysia](https://elysiajs.com) and the AbsoluteJS
|
|
4
|
+
ecosystem — kill polling and keep a remote store off your hot path, **on your own
|
|
5
|
+
database and ORM** (Drizzle _or_ Prisma, any DB they support).
|
|
5
6
|
|
|
6
|
-
- **`createReactiveHub` + `sync` plugin** — push-on-change over SSE
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- **`createReactiveHub` + `sync` plugin** — push-on-change over SSE. A view
|
|
8
|
+
subscribes to the topics its data depends on; a mutation publishes those topics;
|
|
9
|
+
subscribers refetch (or read the pushed payload) the instant data changes.
|
|
10
|
+
- **ORM adapters (`/drizzle`, `/prisma`)** — _derive_ those topics automatically
|
|
11
|
+
from a query, so you stop hand-naming them. A read maps to a `table` topic (or a
|
|
12
|
+
`table:key` row topic for a primary-key lookup); a mutation publishes the matching
|
|
13
|
+
topics.
|
|
14
|
+
- **`createLiveQuery`** — a client query that hydrates once, then refetches whenever
|
|
15
|
+
one of its topics fires. Framework-agnostic (`get` + `subscribe`).
|
|
16
|
+
- **Sync engine (`/engine`, `/postgres`)** — row-level reactive query results:
|
|
17
|
+
hydrate a collection once, then maintain it from `{ added, removed, changed }`
|
|
18
|
+
diffs over a WebSocket, with optimistic mutations, an offline queue, and access
|
|
19
|
+
control. CDC catches out-of-band writes; aggregations are incremental.
|
|
10
20
|
- **`createWriteBehindCache`** — an in-memory hot cache with write-behind
|
|
11
21
|
persistence, so a latency-sensitive hot path doesn't pay a round-trip to a remote
|
|
12
22
|
store on every read/write.
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
DB off your hot path, reach for this.
|
|
24
|
+
Unlike Convex, ElectricSQL, or Zero, it does **not** own or replicate your database
|
|
25
|
+
— it stays a _library_ over the store, ORM, and transport you already have. Tier 1/2
|
|
26
|
+
keep granularity deliberately coarse (table/row topics, refetch on change); the Tier
|
|
27
|
+
3 engine adds true row-level diffs and optimistic writes. Single-table filtered
|
|
28
|
+
queries are matched incrementally; joins (inner and left), aggregations, and
|
|
29
|
+
top-N ordering are maintained incrementally through a composable operator graph
|
|
30
|
+
(`query(...).filter().join().leftJoin().groupBy().orderBy()`).
|
|
22
31
|
|
|
23
|
-
> Status: early (`0.0.1`).
|
|
24
|
-
>
|
|
25
|
-
>
|
|
32
|
+
> Status: early (`0.0.1`). Tier 1 (hub, SSE plugin, browser subscriber,
|
|
33
|
+
> write-behind cache), Tier 2 (Drizzle + Prisma topic adapters, `createLiveQuery`),
|
|
34
|
+
> and Tier 3 (sync engine: collections, WebSocket diff transport, optimistic
|
|
35
|
+
> mutations + offline queue, CDC for Postgres/MySQL/SQLite, incremental
|
|
36
|
+
> aggregations + joins, and a declarative operator graph) are in place.
|
|
37
|
+
> Everything ships as subpaths of this one package.
|
|
26
38
|
|
|
27
39
|
## Install
|
|
28
40
|
|
|
@@ -30,7 +42,8 @@ DB off your hot path, reach for this.
|
|
|
30
42
|
bun add @absolutejs/sync
|
|
31
43
|
```
|
|
32
44
|
|
|
33
|
-
`elysia` is an optional peer (only needed for the `sync` plugin).
|
|
45
|
+
`elysia` is an optional peer (only needed for the `sync` plugin). The Drizzle adapter
|
|
46
|
+
expects `drizzle-orm` if you use it; the Prisma adapter needs no Prisma import at all.
|
|
34
47
|
|
|
35
48
|
## Reactive push — kill the polling loop
|
|
36
49
|
|
|
@@ -66,6 +79,153 @@ const sub = createSyncSubscriber({
|
|
|
66
79
|
// sub.close() when the view unmounts
|
|
67
80
|
```
|
|
68
81
|
|
|
82
|
+
`resolveTopics` on the plugin lets you derive a connection's topics from the session
|
|
83
|
+
or auth instead of trusting the client's `?topics=`.
|
|
84
|
+
|
|
85
|
+
## ORM auto-reactivity — stop hand-naming topics
|
|
86
|
+
|
|
87
|
+
The adapters turn a query into the topics it touches, so reads and writes line up
|
|
88
|
+
automatically. Same function names for both ORMs; pick the matching subpath.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// server — Drizzle
|
|
92
|
+
import { eq } from 'drizzle-orm';
|
|
93
|
+
import { deriveReadTopics, publishWhere } from '@absolutejs/sync/drizzle';
|
|
94
|
+
|
|
95
|
+
new Elysia()
|
|
96
|
+
.use(sync({ hub }))
|
|
97
|
+
.get('/api/orders', () => db.select().from(orders)) // list -> topic "orders"
|
|
98
|
+
.patch('/api/orders/:id', async ({ params, body }) => {
|
|
99
|
+
const id = Number(params.id);
|
|
100
|
+
await db.update(orders).set(body).where(eq(orders.id, id));
|
|
101
|
+
publishWhere(hub, orders, eq(orders.id, id), { op: 'update' });
|
|
102
|
+
// publishes "orders" and "orders:<id>"
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// browser — createLiveQuery + Prisma topic derivation (just a model name, no deps)
|
|
108
|
+
import { createLiveQuery, jsonFetcher } from '@absolutejs/sync/client';
|
|
109
|
+
import { deriveReadTopics } from '@absolutejs/sync/prisma';
|
|
110
|
+
|
|
111
|
+
const orders = createLiveQuery({
|
|
112
|
+
topics: deriveReadTopics('order').topics, // ['order']
|
|
113
|
+
fetcher: jsonFetcher('/api/orders')
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
orders.subscribe((state) => render(state.data)); // refetches on every order change
|
|
117
|
+
// orders.close() when the view unmounts
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`createLiveQuery` is a small observable store: `get()` for the current
|
|
121
|
+
`{ data, error, loading, fetching }`, `subscribe(listener)` for changes (plugs
|
|
122
|
+
straight into React's `useSyncExternalStore`), plus `refetch()` and `close()`. It
|
|
123
|
+
supersedes overlapping fetches (last write wins), re-hydrates on reconnect, and takes
|
|
124
|
+
`initialData` (SSR seed), `manual`, and `debounceMs`.
|
|
125
|
+
|
|
126
|
+
What the adapters derive:
|
|
127
|
+
|
|
128
|
+
- `deriveReadTopics(orders)` → `{ topics: ['orders'], rowLevel: false }`
|
|
129
|
+
- `deriveReadTopics(orders, eq(orders.id, 5))` → `{ topics: ['orders:5'], rowLevel: true }`
|
|
130
|
+
- anything more complex (joins, `and`/`or`, ranges, `in`, non-key columns) falls back
|
|
131
|
+
to the table topic — over-invalidating rather than missing an update.
|
|
132
|
+
- Write side: `publishChange` (explicit keys), `publishRows` (keys from a mutation's
|
|
133
|
+
returned/created records), `publishWhere` (keys from an update/delete filter).
|
|
134
|
+
|
|
135
|
+
The Prisma adapter parses Prisma's plain `where`/result objects, so it needs no
|
|
136
|
+
`@prisma/client` import; the Drizzle adapter reads the schema's table objects.
|
|
137
|
+
|
|
138
|
+
## Live collections — the sync engine (Tier 3)
|
|
139
|
+
|
|
140
|
+
Row-level reactive results: the client holds a collection and the server pushes
|
|
141
|
+
`{ added, removed, changed }` diffs over a WebSocket, instead of refetching. Define
|
|
142
|
+
a collection once (the filter powers both the DB hydrate and the incremental
|
|
143
|
+
matcher), expose it over `syncSocket`, and drive changes from mutations.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// server
|
|
147
|
+
import { Elysia } from 'elysia';
|
|
148
|
+
import { syncSocket } from '@absolutejs/sync';
|
|
149
|
+
import { createSyncEngine, defineMutation } from '@absolutejs/sync/engine';
|
|
150
|
+
import { prismaCollection } from '@absolutejs/sync/prisma';
|
|
151
|
+
|
|
152
|
+
// `transaction` runs every mutation in your DB's transaction (any ORM), so its
|
|
153
|
+
// writes are ACID and the diff is emitted only after the commit.
|
|
154
|
+
const engine = createSyncEngine({
|
|
155
|
+
transaction: (run) => prisma.$transaction(run)
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
engine.register(
|
|
159
|
+
prismaCollection({
|
|
160
|
+
name: 'orders',
|
|
161
|
+
where: (params) => ({ userId: params.userId, status: 'open' }), // written once
|
|
162
|
+
find: (where) => prisma.order.findMany({ where }),
|
|
163
|
+
authorize: (params, ctx) => params.userId === ctx.userId // never leak rows
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Teach the engine how to persist the table once — now writes auto-emit. The
|
|
168
|
+
// third arg is the transaction handle, so the write joins the mutation's tx.
|
|
169
|
+
engine.registerWriter('orders', {
|
|
170
|
+
insert: (data, ctx, tx) =>
|
|
171
|
+
tx.order.create({ data: { ...data, userId: ctx.userId } }),
|
|
172
|
+
update: (data, _ctx, tx) =>
|
|
173
|
+
tx.order.update({ where: { id: data.id }, data }),
|
|
174
|
+
delete: (row, _ctx, tx) => tx.order.delete({ where: { id: row.id } })
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
engine.registerMutation(
|
|
178
|
+
defineMutation({
|
|
179
|
+
name: 'createOrder',
|
|
180
|
+
// Persists AND goes live in one step — you can't forget to emit, and the
|
|
181
|
+
// diff carries the stored row (db-assigned id). Commits atomically.
|
|
182
|
+
handler: (args, ctx, actions) => actions.insert('orders', args)
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
new Elysia()
|
|
187
|
+
.use(
|
|
188
|
+
syncSocket({
|
|
189
|
+
engine,
|
|
190
|
+
resolveContext: (data) => ({ userId: data.userId })
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
.listen(3000);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// browser
|
|
198
|
+
import { createSyncCollection } from '@absolutejs/sync/client';
|
|
199
|
+
|
|
200
|
+
const orders = createSyncCollection({
|
|
201
|
+
url: 'ws://localhost:3000/sync/ws',
|
|
202
|
+
collection: 'orders',
|
|
203
|
+
params: { userId }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
orders.subscribe((state) => render(state.data)); // live: diff-driven, auto-reconnect
|
|
207
|
+
|
|
208
|
+
// optimistic write — instant UI, reconciled (or rolled back) by the server
|
|
209
|
+
await orders.mutate({
|
|
210
|
+
name: 'createOrder',
|
|
211
|
+
args: { total: 42 },
|
|
212
|
+
optimistic: (draft) => draft.set({ id: tempId, total: 42, status: 'open' })
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **Incremental vs refetch.** A single-table filtered collection is matched
|
|
217
|
+
incrementally (only the changed rows move). Joins/aggregations and filters the
|
|
218
|
+
matcher can't evaluate fall back to a correct re-hydrate. `createAggregate`
|
|
219
|
+
(`/engine`) maintains `count`/`sum`/`avg`/`min`/`max` + `groupBy` incrementally.
|
|
220
|
+
- **Out-of-band writes.** Writes that bypass mutations are caught by a
|
|
221
|
+
`ChangeSource` — e.g. `postgresChangeSource` (`/postgres`) over `LISTEN/NOTIFY`,
|
|
222
|
+
wired with `engine.connectSource(...)` and the trigger SQL from
|
|
223
|
+
`postgresNotifyTrigger`.
|
|
224
|
+
- **Offline.** Pending mutations replay on reconnect; pass `storage`
|
|
225
|
+
(e.g. `localStorageMutationStorage`) to also survive a reload.
|
|
226
|
+
- **Access control is mandatory.** Each collection's `authorize` gates subscribe and
|
|
227
|
+
its filter scopes rows, so a change to a row a caller can't see never reaches them.
|
|
228
|
+
|
|
69
229
|
## Write-behind cache — keep a remote store off your hot path
|
|
70
230
|
|
|
71
231
|
```ts
|
|
@@ -90,12 +250,109 @@ it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real
|
|
|
90
250
|
|
|
91
251
|
## API
|
|
92
252
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
253
|
+
### `@absolutejs/sync`
|
|
254
|
+
|
|
255
|
+
| Export | What it is |
|
|
256
|
+
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
|
257
|
+
| `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
|
|
258
|
+
| `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
|
|
259
|
+
| `syncSocket({ engine, path?, resolveContext? })` | Elysia WebSocket plugin for the sync engine. |
|
|
260
|
+
| `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
|
|
261
|
+
|
|
262
|
+
### `@absolutejs/sync/client`
|
|
263
|
+
|
|
264
|
+
| Export | What it is |
|
|
265
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
266
|
+
| `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client. |
|
|
267
|
+
| `createLiveQuery({ topics, fetcher, ... })` | Hydrate-once, refetch-on-event observable query store. |
|
|
268
|
+
| `jsonFetcher(url, init?)` | Default `fetcher`: GET + JSON parse, forwards the abort signal. |
|
|
269
|
+
| `createSyncCollection({ url, collection, ... })` | Live diff-driven collection store with optimistic `mutate`. |
|
|
270
|
+
| `createSyncClient({ url })` | One socket, many collections (`client.collection(...)`). Applies a multi-collection mutation's diffs as one **consistent frame** — no torn cross-collection paint. |
|
|
271
|
+
| `localStorageMutationStorage(key)` | `localStorage`-backed offline queue for `createSyncCollection`. |
|
|
272
|
+
|
|
273
|
+
### Framework bindings — `@absolutejs/sync/{react,vue,svelte,angular}`
|
|
274
|
+
|
|
275
|
+
Idiomatic wrappers over `createSyncCollection`, one per framework, so a live
|
|
276
|
+
collection is one call. Each returns the same `{ data, status, error, mutate }`
|
|
277
|
+
and is SSR-safe (the socket opens on the client only).
|
|
278
|
+
|
|
279
|
+
| Subpath | Export | What it is |
|
|
280
|
+
| ---------- | ---------------------------------------- | ---------------------------------------------------- |
|
|
281
|
+
| `/react` | `useSyncCollection(options)` | React hook (re-renders on diffs). |
|
|
282
|
+
| `/vue` | `useSyncCollection(options)` | Vue composable (reactive refs). |
|
|
283
|
+
| `/svelte` | `createSyncCollectionStore(options)` | Svelte readable store (`$store` → state) + `mutate`. |
|
|
284
|
+
| `/angular` | `SyncCollectionService.connect(options)` | Angular service returning signals. |
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
// React
|
|
288
|
+
import { useSyncCollection } from '@absolutejs/sync/react';
|
|
289
|
+
|
|
290
|
+
const { data, status, mutate } = useSyncCollection<Order>({
|
|
291
|
+
url: 'ws://localhost:3000/sync/ws',
|
|
292
|
+
collection: 'orders',
|
|
293
|
+
params: { userId }
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
mutate({
|
|
297
|
+
name: 'createOrder',
|
|
298
|
+
args: { total: 42 },
|
|
299
|
+
optimistic: (draft) => draft.set({ id: tempId, total: 42 } as Order)
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### `@absolutejs/sync/engine`
|
|
304
|
+
|
|
305
|
+
| Export | What it is |
|
|
306
|
+
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
307
|
+
| `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
|
|
308
|
+
| `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
|
|
309
|
+
| `defineMutation({ name, handler, authorize? })` | Define a server mutation. Its `handler` gets `actions.insert/update/delete` (write through a registered `TableWriter` → persists + emits in one step) plus `actions.change` (escape hatch). Changes commit atomically. |
|
|
310
|
+
| `registerWriter(table, { insert, update, delete })` | Teach the engine how to persist a table (any ORM), so writes auto-emit — you can't write without going live. |
|
|
311
|
+
| `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
|
|
312
|
+
| `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
|
|
313
|
+
| `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
|
|
314
|
+
| `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
|
|
315
|
+
| `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
|
|
316
|
+
| `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
|
|
317
|
+
| `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
|
|
318
|
+
| `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader` | Read-set-tracked query: `run(ctx)` reads via `ctx.db` (`all`/`get`/`where`) and re-runs only when the rows/ranges it read change — no `match`, no manual emit. |
|
|
319
|
+
|
|
320
|
+
### `@absolutejs/sync/postgres`
|
|
321
|
+
|
|
322
|
+
| Export | What it is |
|
|
323
|
+
| ------------------------------------------------------------ | ---------------------------------------------------------------- |
|
|
324
|
+
| `postgresChangeSource({ listen, channel?, parse? })` | CDC `ChangeSource` over `LISTEN/NOTIFY` (bring your own client). |
|
|
325
|
+
| `postgresNotifyTrigger({ tables, channel?, functionName? })` | SQL to install the notify triggers (run once). |
|
|
326
|
+
|
|
327
|
+
### `@absolutejs/sync/mysql`
|
|
328
|
+
|
|
329
|
+
| Export | What it is |
|
|
330
|
+
| ------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
|
331
|
+
| `mysqlChangelogSchema({ tables, changelogTable?, prefix? })` | SQL to install the changelog table + triggers (run once). |
|
|
332
|
+
| `createPollingChangeSource({ poll, ... })` | Tail the changelog (re-exported from the engine). |
|
|
333
|
+
| `mysqlBinlogChangeSource({ subscribe, normalize? })` | Higher-throughput CDC over the binlog (bring your own reader, e.g. zongji). |
|
|
334
|
+
| `normalizeBinlogEvent(event)` | Pure: a binlog row event → engine changes. |
|
|
335
|
+
|
|
336
|
+
### `@absolutejs/sync/sqlite`
|
|
337
|
+
|
|
338
|
+
| Export | What it is |
|
|
339
|
+
| ------------------------------------------------------------- | --------------------------------------------------------- |
|
|
340
|
+
| `sqliteChangelogSchema({ tables, changelogTable?, prefix? })` | SQL to install the changelog table + triggers (run once). |
|
|
341
|
+
| `createPollingChangeSource({ poll, ... })` | Tail the changelog (re-exported from the engine). |
|
|
342
|
+
|
|
343
|
+
### `@absolutejs/sync/drizzle` and `@absolutejs/sync/prisma`
|
|
344
|
+
|
|
345
|
+
| Export | What it is |
|
|
346
|
+
| --------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
347
|
+
| `deriveReadTopics(table\|model, where?, options?)` | Topics a read depends on (`{ topics, rowLevel }`). |
|
|
348
|
+
| `publishChange(hub, table\|model, { keys?, op? })` | Publish the table topic + a row topic per key. |
|
|
349
|
+
| `publishRows(hub, table\|model, rows, { keyField?/keyColumn?, op? })` | Publish topics for returned/created records. |
|
|
350
|
+
| `publishWhere(hub, table\|model, where, { ..., op? })` | Publish topics for an update/delete filter. |
|
|
351
|
+
| `tableTopic` / `keyTopic` | The shared topic vocabulary both sides speak. |
|
|
352
|
+
| `prismaCollection({ name, where, find, ... })` (prisma) | A sync-engine collection; one `where` → hydrate + matcher. |
|
|
353
|
+
| `matchesWhere(where, row)` (prisma) | Evaluate a Prisma `where` against a row (the matcher). |
|
|
354
|
+
| `drizzleCollection({ name, table, where, find, ... })` (drizzle) | Same one-`where`→hydrate+matcher, for Drizzle. |
|
|
355
|
+
| `matchesDrizzleWhere(table, where, row)` (drizzle) | Evaluate a Drizzle SQL `where` against a row (the matcher). |
|
|
99
356
|
|
|
100
357
|
## License
|
|
101
358
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SQL, Table } from 'drizzle-orm';
|
|
2
|
+
import type { CollectionContext, CollectionDefinition } from '../../engine/collection';
|
|
3
|
+
import type { RowKey } from '../../engine/types';
|
|
4
|
+
export type DrizzleCollectionOptions<T, P = void, Ctx = CollectionContext> = {
|
|
5
|
+
/** Collection name clients subscribe to. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** The Drizzle table this collection reads (drives change routing + key). */
|
|
8
|
+
table: Table;
|
|
9
|
+
/** The query filter, written once — powers both hydrate and the matcher. */
|
|
10
|
+
where: (params: P, ctx: Ctx) => SQL;
|
|
11
|
+
/** Run the read for `where` (your `db.select()...`), returning the rows. */
|
|
12
|
+
find: (where: SQL, params: P, ctx: Ctx) => Promise<Iterable<T>> | Iterable<T>;
|
|
13
|
+
/** Row identity. Defaults to the table's single primary key (else `row.id`). */
|
|
14
|
+
key?: (row: T) => RowKey;
|
|
15
|
+
/** Key column JS property, if not the table's primary key. */
|
|
16
|
+
keyColumn?: string;
|
|
17
|
+
/** Access control; return false (or throw) to deny the subscription. */
|
|
18
|
+
authorize?: (params: P, ctx: Ctx) => boolean | Promise<boolean>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A sync-engine collection from one Drizzle query — the Drizzle counterpart to
|
|
22
|
+
* `prismaCollection`. You write the `where` once: it drives the DB read
|
|
23
|
+
* (`hydrate`) AND the incremental `match` (via {@link matchesDrizzleWhere}), so
|
|
24
|
+
* the two can't drift and you never hand-maintain a separate predicate. A filter
|
|
25
|
+
* the matcher can't evaluate falls back to a refetch, never a wrong result.
|
|
26
|
+
*/
|
|
27
|
+
export declare const drizzleCollection: <T, P = void, Ctx = CollectionContext>(options: DrizzleCollectionOptions<T, P, Ctx>) => CollectionDefinition<T, P, Ctx>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle adapter for @absolutejs/sync (Tier 2 — ORM auto-reactivity).
|
|
3
|
+
*
|
|
4
|
+
* Derives reactive-hub topics from a Drizzle schema so you stop hand-naming
|
|
5
|
+
* them: a read subscribes to the topics it depends on ({@link deriveReadTopics})
|
|
6
|
+
* and a mutation publishes the topics it invalidates ({@link publishChange} and
|
|
7
|
+
* friends). Both speak the same {@link tableTopic}/{@link keyTopic} vocabulary,
|
|
8
|
+
* so reads and writes always line up.
|
|
9
|
+
*
|
|
10
|
+
* Granularity is deliberately coarse and DB-agnostic: table-level by default,
|
|
11
|
+
* narrowing to a single row when a filter is a simple primary-key equality.
|
|
12
|
+
*/
|
|
13
|
+
export { keyTopic, tableTopic } from './topics';
|
|
14
|
+
export { drizzleCollection } from './collection';
|
|
15
|
+
export type { DrizzleCollectionOptions } from './collection';
|
|
16
|
+
export { matchesDrizzleWhere, UnsupportedDrizzleFilterError } from './predicate';
|
|
17
|
+
export { deriveReadTopics } from './read';
|
|
18
|
+
export type { DeriveReadTopicsOptions, DerivedReadTopics } from './read';
|
|
19
|
+
export { publishChange, publishRows, publishWhere } from './write';
|
|
20
|
+
export type { ChangeOp, ChangePayload, PublishChangeOptions, PublishRowsOptions, PublishWhereOptions } from './write';
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/adapters/drizzle/topics.ts
|
|
3
|
+
import {
|
|
4
|
+
Column,
|
|
5
|
+
getTableColumns,
|
|
6
|
+
getTableName,
|
|
7
|
+
is,
|
|
8
|
+
Param,
|
|
9
|
+
SQL
|
|
10
|
+
} from "drizzle-orm";
|
|
11
|
+
var tableTopic = (table) => getTableName(table);
|
|
12
|
+
var keyTopic = (table, key) => `${getTableName(table)}:${key}`;
|
|
13
|
+
var resolveKeyColumn = (table, keyColumn) => {
|
|
14
|
+
const columns = getTableColumns(table);
|
|
15
|
+
if (keyColumn !== undefined) {
|
|
16
|
+
const column = columns[keyColumn];
|
|
17
|
+
return column === undefined ? undefined : { property: keyColumn, column: column.name };
|
|
18
|
+
}
|
|
19
|
+
const primaries = Object.entries(columns).filter(([, column]) => column.primary);
|
|
20
|
+
const primary = primaries.length === 1 ? primaries[0] : undefined;
|
|
21
|
+
return primary === undefined ? undefined : { property: primary[0], column: primary[1].name };
|
|
22
|
+
};
|
|
23
|
+
var extractKeyFromWhere = (table, where, keyColumn) => {
|
|
24
|
+
const resolved = resolveKeyColumn(table, keyColumn);
|
|
25
|
+
if (resolved === undefined) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const chunks = where.queryChunks;
|
|
29
|
+
if (!Array.isArray(chunks)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let column;
|
|
33
|
+
let param;
|
|
34
|
+
let tooComplex = false;
|
|
35
|
+
let operator = "";
|
|
36
|
+
for (const chunk of chunks) {
|
|
37
|
+
if (is(chunk, SQL)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (is(chunk, Column)) {
|
|
41
|
+
if (column !== undefined) {
|
|
42
|
+
tooComplex = true;
|
|
43
|
+
}
|
|
44
|
+
column = chunk;
|
|
45
|
+
} else if (is(chunk, Param)) {
|
|
46
|
+
if (param !== undefined) {
|
|
47
|
+
tooComplex = true;
|
|
48
|
+
}
|
|
49
|
+
param = chunk;
|
|
50
|
+
} else {
|
|
51
|
+
const value2 = chunk.value;
|
|
52
|
+
if (Array.isArray(value2)) {
|
|
53
|
+
operator += value2.join("");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (tooComplex || column === undefined || param === undefined) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (operator.trim() !== "=") {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (column.name !== resolved.column) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (getTableName(column.table) !== getTableName(table)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const value = param.value;
|
|
70
|
+
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
|
71
|
+
};
|
|
72
|
+
var extractRowKeys = (table, rows, keyColumn) => {
|
|
73
|
+
const resolved = resolveKeyColumn(table, keyColumn);
|
|
74
|
+
if (resolved === undefined) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const keys = [];
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
const value = row[resolved.property];
|
|
80
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
81
|
+
keys.push(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return keys;
|
|
85
|
+
};
|
|
86
|
+
// src/adapters/drizzle/collection.ts
|
|
87
|
+
import { getTableName as getTableName2 } from "drizzle-orm";
|
|
88
|
+
|
|
89
|
+
// src/adapters/drizzle/predicate.ts
|
|
90
|
+
import { Column as Column2, getTableColumns as getTableColumns2, is as is2, Param as Param2, SQL as SQL2 } from "drizzle-orm";
|
|
91
|
+
|
|
92
|
+
class UnsupportedDrizzleFilterError extends Error {
|
|
93
|
+
constructor(detail) {
|
|
94
|
+
super(`Cannot evaluate Drizzle filter "${detail}" incrementally`);
|
|
95
|
+
this.name = "UnsupportedDrizzleFilterError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
var isDate = (value) => value instanceof Date;
|
|
99
|
+
var equals = (value, operand) => {
|
|
100
|
+
if (operand === null) {
|
|
101
|
+
return value === null || value === undefined;
|
|
102
|
+
}
|
|
103
|
+
if (isDate(value) && isDate(operand)) {
|
|
104
|
+
return value.getTime() === operand.getTime();
|
|
105
|
+
}
|
|
106
|
+
return value === operand;
|
|
107
|
+
};
|
|
108
|
+
var order = (value) => isDate(value) ? value.getTime() : value;
|
|
109
|
+
var compare = (value, operand) => {
|
|
110
|
+
const a = order(value);
|
|
111
|
+
const b = order(operand);
|
|
112
|
+
if (a < b) {
|
|
113
|
+
return -1;
|
|
114
|
+
}
|
|
115
|
+
if (a > b) {
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
};
|
|
120
|
+
var comparable = (value) => value !== null && value !== undefined;
|
|
121
|
+
var classify = (chunks) => {
|
|
122
|
+
const cols = [];
|
|
123
|
+
const params = [];
|
|
124
|
+
const arrays = [];
|
|
125
|
+
const sqls = [];
|
|
126
|
+
const ops = [];
|
|
127
|
+
for (const chunk of chunks) {
|
|
128
|
+
if (is2(chunk, SQL2)) {
|
|
129
|
+
sqls.push(chunk);
|
|
130
|
+
} else if (is2(chunk, Column2)) {
|
|
131
|
+
cols.push(chunk);
|
|
132
|
+
} else if (is2(chunk, Param2)) {
|
|
133
|
+
params.push(chunk.value);
|
|
134
|
+
} else if (Array.isArray(chunk)) {
|
|
135
|
+
arrays.push(chunk.map((element) => is2(element, Param2) ? element.value : element));
|
|
136
|
+
} else {
|
|
137
|
+
const raw = chunk.value;
|
|
138
|
+
const text = (Array.isArray(raw) ? raw.join("") : String(raw ?? "")).trim();
|
|
139
|
+
if (text !== "" && text !== "(" && text !== ")") {
|
|
140
|
+
ops.push(text);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { arrays, cols, ops, params, sqls };
|
|
145
|
+
};
|
|
146
|
+
var evaluateLeaf = (column, op, params, arrays, row, propFor) => {
|
|
147
|
+
const prop = propFor(column);
|
|
148
|
+
if (prop === undefined) {
|
|
149
|
+
throw new UnsupportedDrizzleFilterError(`column ${column.name}`);
|
|
150
|
+
}
|
|
151
|
+
const value = row[prop];
|
|
152
|
+
const operand = params[0];
|
|
153
|
+
switch (op) {
|
|
154
|
+
case "=":
|
|
155
|
+
return equals(value, operand);
|
|
156
|
+
case "<>":
|
|
157
|
+
return !equals(value, operand);
|
|
158
|
+
case ">":
|
|
159
|
+
return comparable(value) && compare(value, operand) > 0;
|
|
160
|
+
case ">=":
|
|
161
|
+
return comparable(value) && compare(value, operand) >= 0;
|
|
162
|
+
case "<":
|
|
163
|
+
return comparable(value) && compare(value, operand) < 0;
|
|
164
|
+
case "<=":
|
|
165
|
+
return comparable(value) && compare(value, operand) <= 0;
|
|
166
|
+
case "in":
|
|
167
|
+
return (arrays[0] ?? []).some((item) => equals(value, item));
|
|
168
|
+
case "not in":
|
|
169
|
+
return !(arrays[0] ?? []).some((item) => equals(value, item));
|
|
170
|
+
case "is null":
|
|
171
|
+
return value === null || value === undefined;
|
|
172
|
+
case "is not null":
|
|
173
|
+
return value !== null && value !== undefined;
|
|
174
|
+
default:
|
|
175
|
+
throw new UnsupportedDrizzleFilterError(op);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
var evaluateCondition = (node, row, propFor) => {
|
|
179
|
+
const { cols, params, arrays, sqls, ops } = classify(node.queryChunks);
|
|
180
|
+
if (ops.length === 1 && ops[0] === "not" && sqls.length === 1 && cols.length === 0) {
|
|
181
|
+
return !evaluateCondition(sqls[0], row, propFor);
|
|
182
|
+
}
|
|
183
|
+
if (cols.length === 0 && sqls.length === 1 && ops.length === 0) {
|
|
184
|
+
return evaluateCondition(sqls[0], row, propFor);
|
|
185
|
+
}
|
|
186
|
+
if (cols.length === 0 && sqls.length >= 2 && ops.length > 0) {
|
|
187
|
+
const connective = ops[0];
|
|
188
|
+
if ((connective === "and" || connective === "or") && ops.every((op) => op === connective)) {
|
|
189
|
+
const results = sqls.map((sql) => evaluateCondition(sql, row, propFor));
|
|
190
|
+
return connective === "and" ? results.every(Boolean) : results.some(Boolean);
|
|
191
|
+
}
|
|
192
|
+
throw new UnsupportedDrizzleFilterError(ops.join(" "));
|
|
193
|
+
}
|
|
194
|
+
if (cols.length === 1 && sqls.length === 0 && ops.length === 1) {
|
|
195
|
+
return evaluateLeaf(cols[0], ops[0], params, arrays, row, propFor);
|
|
196
|
+
}
|
|
197
|
+
throw new UnsupportedDrizzleFilterError(ops.join(" ") || "unrecognized condition");
|
|
198
|
+
};
|
|
199
|
+
var matchesDrizzleWhere = (table, where, row) => {
|
|
200
|
+
const nameToProp = new Map;
|
|
201
|
+
for (const [prop, column] of Object.entries(getTableColumns2(table))) {
|
|
202
|
+
nameToProp.set(column.name, prop);
|
|
203
|
+
}
|
|
204
|
+
return evaluateCondition(where, row, (column) => nameToProp.get(column.name));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/adapters/drizzle/collection.ts
|
|
208
|
+
var drizzleCollection = (options) => {
|
|
209
|
+
const keyProp = resolveKeyColumn(options.table, options.keyColumn)?.property;
|
|
210
|
+
const key = options.key ?? ((row) => keyProp !== undefined ? row[keyProp] : row.id);
|
|
211
|
+
return {
|
|
212
|
+
name: options.name,
|
|
213
|
+
tables: [getTableName2(options.table)],
|
|
214
|
+
hydrate: (params, ctx) => options.find(options.where(params, ctx), params, ctx),
|
|
215
|
+
match: (row, params, ctx) => matchesDrizzleWhere(options.table, options.where(params, ctx), row),
|
|
216
|
+
key,
|
|
217
|
+
authorize: options.authorize
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
// src/adapters/drizzle/read.ts
|
|
221
|
+
var deriveReadTopics = (table, where, options = {}) => {
|
|
222
|
+
const key = where === undefined ? undefined : extractKeyFromWhere(table, where, options.keyColumn);
|
|
223
|
+
if (key === undefined) {
|
|
224
|
+
return { topics: [tableTopic(table)], rowLevel: false };
|
|
225
|
+
}
|
|
226
|
+
return { topics: [keyTopic(table, key)], rowLevel: true };
|
|
227
|
+
};
|
|
228
|
+
// src/adapters/drizzle/write.ts
|
|
229
|
+
var publishChange = (hub, table, options = {}) => {
|
|
230
|
+
const name = tableTopic(table);
|
|
231
|
+
const keys = options.keys === undefined ? [] : [...new Set(options.keys)];
|
|
232
|
+
const payload = { table: name, op: options.op, keys };
|
|
233
|
+
const topics = [
|
|
234
|
+
...new Set([name, ...keys.map((key) => keyTopic(table, key))])
|
|
235
|
+
];
|
|
236
|
+
for (const topic of topics) {
|
|
237
|
+
hub.publish(topic, payload);
|
|
238
|
+
}
|
|
239
|
+
return topics;
|
|
240
|
+
};
|
|
241
|
+
var publishRows = (hub, table, rows, options = {}) => publishChange(hub, table, {
|
|
242
|
+
keys: extractRowKeys(table, rows, options.keyColumn),
|
|
243
|
+
op: options.op
|
|
244
|
+
});
|
|
245
|
+
var publishWhere = (hub, table, where, options = {}) => {
|
|
246
|
+
const key = extractKeyFromWhere(table, where, options.keyColumn);
|
|
247
|
+
return publishChange(hub, table, {
|
|
248
|
+
keys: key === undefined ? [] : [key],
|
|
249
|
+
op: options.op
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
export {
|
|
253
|
+
tableTopic,
|
|
254
|
+
publishWhere,
|
|
255
|
+
publishRows,
|
|
256
|
+
publishChange,
|
|
257
|
+
matchesDrizzleWhere,
|
|
258
|
+
keyTopic,
|
|
259
|
+
drizzleCollection,
|
|
260
|
+
deriveReadTopics,
|
|
261
|
+
UnsupportedDrizzleFilterError
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
//# debugId=A28CA296A764961764756E2164756E21
|
|
265
|
+
//# sourceMappingURL=index.js.map
|