@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.
Files changed (75) hide show
  1. package/README.md +281 -24
  2. package/dist/adapters/drizzle/collection.d.ts +27 -0
  3. package/dist/adapters/drizzle/index.d.ts +20 -0
  4. package/dist/adapters/drizzle/index.js +265 -0
  5. package/dist/adapters/drizzle/index.js.map +14 -0
  6. package/dist/adapters/drizzle/predicate.d.ts +20 -0
  7. package/dist/adapters/drizzle/read.d.ts +31 -0
  8. package/dist/adapters/drizzle/topics.d.ts +41 -0
  9. package/dist/adapters/drizzle/write.d.ts +69 -0
  10. package/dist/adapters/mysql/index.d.ts +75 -0
  11. package/dist/adapters/mysql/index.js +171 -0
  12. package/dist/adapters/mysql/index.js.map +11 -0
  13. package/dist/adapters/postgres/index.d.ts +53 -0
  14. package/dist/adapters/postgres/index.js +86 -0
  15. package/dist/adapters/postgres/index.js.map +10 -0
  16. package/dist/adapters/prisma/collection.d.ts +39 -0
  17. package/dist/adapters/prisma/index.d.ts +23 -0
  18. package/dist/adapters/prisma/index.js +231 -0
  19. package/dist/adapters/prisma/index.js.map +14 -0
  20. package/dist/adapters/prisma/predicate.d.ts +20 -0
  21. package/dist/adapters/prisma/read.d.ts +28 -0
  22. package/dist/adapters/prisma/topics.d.ts +29 -0
  23. package/dist/adapters/prisma/write.d.ts +65 -0
  24. package/dist/adapters/sqlite/index.d.ts +32 -0
  25. package/dist/adapters/sqlite/index.js +128 -0
  26. package/dist/adapters/sqlite/index.js.map +11 -0
  27. package/dist/angular/index.d.ts +1 -0
  28. package/dist/angular/index.js +347 -0
  29. package/dist/angular/index.js.map +11 -0
  30. package/dist/angular/sync-collection.service.d.ts +20 -0
  31. package/dist/client/index.d.ts +12 -30
  32. package/dist/client/index.js +1099 -3
  33. package/dist/client/index.js.map +10 -4
  34. package/dist/client/liveQuery.d.ts +75 -0
  35. package/dist/client/presence.d.ts +37 -0
  36. package/dist/client/subscriber.d.ts +30 -0
  37. package/dist/client/syncClient.d.ts +53 -0
  38. package/dist/client/syncCollection.d.ts +102 -0
  39. package/dist/client/syncStore.d.ts +81 -0
  40. package/dist/engine/aggregate.d.ts +45 -0
  41. package/dist/engine/cluster.d.ts +41 -0
  42. package/dist/engine/collection.d.ts +87 -0
  43. package/dist/engine/connection.d.ts +103 -0
  44. package/dist/engine/dataflow.d.ts +109 -0
  45. package/dist/engine/equiJoin.d.ts +51 -0
  46. package/dist/engine/graph.d.ts +85 -0
  47. package/dist/engine/index.d.ts +40 -0
  48. package/dist/engine/index.js +1774 -0
  49. package/dist/engine/index.js.map +23 -0
  50. package/dist/engine/materializedView.d.ts +53 -0
  51. package/dist/engine/mutation.d.ts +66 -0
  52. package/dist/engine/pollingSource.d.ts +42 -0
  53. package/dist/engine/presence.d.ts +46 -0
  54. package/dist/engine/reactive.d.ts +67 -0
  55. package/dist/engine/routes.d.ts +40 -0
  56. package/dist/engine/socket.d.ts +67 -0
  57. package/dist/engine/syncEngine.d.ts +132 -0
  58. package/dist/engine/types.d.ts +45 -0
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.js +327 -3
  61. package/dist/index.js.map +8 -5
  62. package/dist/react/index.d.ts +1 -0
  63. package/dist/react/index.js +332 -0
  64. package/dist/react/index.js.map +11 -0
  65. package/dist/react/useSyncCollection.d.ts +16 -0
  66. package/dist/reactiveHub.d.ts +6 -0
  67. package/dist/svelte/createSyncCollectionStore.d.ts +15 -0
  68. package/dist/svelte/index.d.ts +1 -0
  69. package/dist/svelte/index.js +338 -0
  70. package/dist/svelte/index.js.map +11 -0
  71. package/dist/vue/index.d.ts +1 -0
  72. package/dist/vue/index.js +331 -0
  73. package/dist/vue/index.js.map +11 -0
  74. package/dist/vue/useSyncCollection.d.ts +17 -0
  75. package/package.json +104 -6
package/README.md CHANGED
@@ -1,28 +1,40 @@
1
1
  # @absolutejs/sync
2
2
 
3
- Two small, composable primitives for live data in [Elysia](https://elysiajs.com)
4
- and the AbsoluteJS ecosystem:
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 so clients stop
7
- polling. A widget subscribes to the topics its data depends on; a mutation
8
- publishes those topics; subscribers refetch (or read the pushed payload) the
9
- instant data changes.
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
- It is **not a sync engine.** Convex, ElectricSQL, and Zero are whole backends
15
- read-set tracking, OCC/MVCC, a transaction log, client SQLite replicas. This package
16
- does **not** rebuild any of that. It's a thin reactive layer over the store and
17
- transport you already have: pair it with **Drizzle _or_ Prisma** (or any store) and
18
- your existing SSE/WebSocket. Dependencies are explicit (you name topics), not
19
- auto-tracked from query read sets. If you want a full local-first sync engine, reach
20
- for one of the above; if you just want to delete your polling loop and keep a remote
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`). In-memory hub, write-behind cache, Elysia SSE plugin, and
24
- > a browser subscriber. Durable/transport adapters land in a companion
25
- > `-adapters` package as the API settles.
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
- | Export | What it is |
94
- | --- | --- |
95
- | `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
96
- | `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
97
- | `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client (from `@absolutejs/sync/client`). |
98
- | `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
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