@absolutejs/sync 0.0.1 → 0.1.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 (68) hide show
  1. package/README.md +264 -24
  2. package/dist/adapters/drizzle/index.d.ts +17 -0
  3. package/dist/adapters/drizzle/index.js +128 -0
  4. package/dist/adapters/drizzle/index.js.map +12 -0
  5. package/dist/adapters/drizzle/read.d.ts +31 -0
  6. package/dist/adapters/drizzle/topics.d.ts +41 -0
  7. package/dist/adapters/drizzle/write.d.ts +69 -0
  8. package/dist/adapters/mysql/index.d.ts +75 -0
  9. package/dist/adapters/mysql/index.js +171 -0
  10. package/dist/adapters/mysql/index.js.map +11 -0
  11. package/dist/adapters/postgres/index.d.ts +53 -0
  12. package/dist/adapters/postgres/index.js +86 -0
  13. package/dist/adapters/postgres/index.js.map +10 -0
  14. package/dist/adapters/prisma/collection.d.ts +39 -0
  15. package/dist/adapters/prisma/index.d.ts +23 -0
  16. package/dist/adapters/prisma/index.js +231 -0
  17. package/dist/adapters/prisma/index.js.map +14 -0
  18. package/dist/adapters/prisma/predicate.d.ts +20 -0
  19. package/dist/adapters/prisma/read.d.ts +28 -0
  20. package/dist/adapters/prisma/topics.d.ts +29 -0
  21. package/dist/adapters/prisma/write.d.ts +65 -0
  22. package/dist/adapters/sqlite/index.d.ts +32 -0
  23. package/dist/adapters/sqlite/index.js +128 -0
  24. package/dist/adapters/sqlite/index.js.map +11 -0
  25. package/dist/angular/index.d.ts +1 -0
  26. package/dist/angular/index.js +347 -0
  27. package/dist/angular/index.js.map +11 -0
  28. package/dist/angular/sync-collection.service.d.ts +20 -0
  29. package/dist/client/index.d.ts +8 -30
  30. package/dist/client/index.js +744 -3
  31. package/dist/client/index.js.map +8 -4
  32. package/dist/client/liveQuery.d.ts +75 -0
  33. package/dist/client/subscriber.d.ts +30 -0
  34. package/dist/client/syncCollection.d.ts +102 -0
  35. package/dist/client/syncStore.d.ts +81 -0
  36. package/dist/engine/aggregate.d.ts +45 -0
  37. package/dist/engine/collection.d.ts +87 -0
  38. package/dist/engine/connection.d.ts +71 -0
  39. package/dist/engine/dataflow.d.ts +109 -0
  40. package/dist/engine/equiJoin.d.ts +51 -0
  41. package/dist/engine/graph.d.ts +85 -0
  42. package/dist/engine/index.d.ts +34 -0
  43. package/dist/engine/index.js +1269 -0
  44. package/dist/engine/index.js.map +20 -0
  45. package/dist/engine/materializedView.d.ts +53 -0
  46. package/dist/engine/mutation.d.ts +30 -0
  47. package/dist/engine/pollingSource.d.ts +42 -0
  48. package/dist/engine/routes.d.ts +40 -0
  49. package/dist/engine/socket.d.ts +64 -0
  50. package/dist/engine/syncEngine.d.ts +100 -0
  51. package/dist/engine/types.d.ts +45 -0
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.js +160 -2
  54. package/dist/index.js.map +7 -5
  55. package/dist/react/index.d.ts +1 -0
  56. package/dist/react/index.js +332 -0
  57. package/dist/react/index.js.map +11 -0
  58. package/dist/react/useSyncCollection.d.ts +16 -0
  59. package/dist/reactiveHub.d.ts +6 -0
  60. package/dist/svelte/createSyncCollectionStore.d.ts +15 -0
  61. package/dist/svelte/index.d.ts +1 -0
  62. package/dist/svelte/index.js +338 -0
  63. package/dist/svelte/index.js.map +11 -0
  64. package/dist/vue/index.d.ts +1 -0
  65. package/dist/vue/index.js +331 -0
  66. package/dist/vue/index.js.map +11 -0
  67. package/dist/vue/useSyncCollection.d.ts +17 -0
  68. package/package.json +102 -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,143 @@ 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
+ const engine = createSyncEngine();
153
+
154
+ engine.register(
155
+ prismaCollection({
156
+ name: 'orders',
157
+ where: (params) => ({ userId: params.userId, status: 'open' }), // written once
158
+ find: (where) => prisma.order.findMany({ where }),
159
+ authorize: (params, ctx) => params.userId === ctx.userId // never leak rows
160
+ })
161
+ );
162
+
163
+ engine.registerMutation(
164
+ defineMutation({
165
+ name: 'createOrder',
166
+ handler: async (args, ctx, actions) => {
167
+ const order = await prisma.order.create({
168
+ data: { ...args, userId: ctx.userId }
169
+ });
170
+ await actions.change('orders', { op: 'insert', row: order });
171
+ return order;
172
+ }
173
+ })
174
+ );
175
+
176
+ new Elysia()
177
+ .use(
178
+ syncSocket({
179
+ engine,
180
+ resolveContext: (data) => ({ userId: data.userId })
181
+ })
182
+ )
183
+ .listen(3000);
184
+ ```
185
+
186
+ ```ts
187
+ // browser
188
+ import { createSyncCollection } from '@absolutejs/sync/client';
189
+
190
+ const orders = createSyncCollection({
191
+ url: 'ws://localhost:3000/sync/ws',
192
+ collection: 'orders',
193
+ params: { userId }
194
+ });
195
+
196
+ orders.subscribe((state) => render(state.data)); // live: diff-driven, auto-reconnect
197
+
198
+ // optimistic write — instant UI, reconciled (or rolled back) by the server
199
+ await orders.mutate({
200
+ name: 'createOrder',
201
+ args: { total: 42 },
202
+ optimistic: (draft) => draft.set({ id: tempId, total: 42, status: 'open' })
203
+ });
204
+ ```
205
+
206
+ - **Incremental vs refetch.** A single-table filtered collection is matched
207
+ incrementally (only the changed rows move). Joins/aggregations and filters the
208
+ matcher can't evaluate fall back to a correct re-hydrate. `createAggregate`
209
+ (`/engine`) maintains `count`/`sum`/`avg`/`min`/`max` + `groupBy` incrementally.
210
+ - **Out-of-band writes.** Writes that bypass mutations are caught by a
211
+ `ChangeSource` — e.g. `postgresChangeSource` (`/postgres`) over `LISTEN/NOTIFY`,
212
+ wired with `engine.connectSource(...)` and the trigger SQL from
213
+ `postgresNotifyTrigger`.
214
+ - **Offline.** Pending mutations replay on reconnect; pass `storage`
215
+ (e.g. `localStorageMutationStorage`) to also survive a reload.
216
+ - **Access control is mandatory.** Each collection's `authorize` gates subscribe and
217
+ its filter scopes rows, so a change to a row a caller can't see never reaches them.
218
+
69
219
  ## Write-behind cache — keep a remote store off your hot path
70
220
 
71
221
  ```ts
@@ -90,12 +240,102 @@ it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real
90
240
 
91
241
  ## API
92
242
 
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. |
243
+ ### `@absolutejs/sync`
244
+
245
+ | Export | What it is |
246
+ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
247
+ | `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
248
+ | `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
249
+ | `syncSocket({ engine, path?, resolveContext? })` | Elysia WebSocket plugin for the sync engine. |
250
+ | `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
251
+
252
+ ### `@absolutejs/sync/client`
253
+
254
+ | Export | What it is |
255
+ | ------------------------------------------------- | --------------------------------------------------------------- |
256
+ | `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client. |
257
+ | `createLiveQuery({ topics, fetcher, ... })` | Hydrate-once, refetch-on-event observable query store. |
258
+ | `jsonFetcher(url, init?)` | Default `fetcher`: GET + JSON parse, forwards the abort signal. |
259
+ | `createSyncCollection({ url, collection, ... })` | Live diff-driven collection store with optimistic `mutate`. |
260
+ | `localStorageMutationStorage(key)` | `localStorage`-backed offline queue for `createSyncCollection`. |
261
+
262
+ ### Framework bindings — `@absolutejs/sync/{react,vue,svelte,angular}`
263
+
264
+ Idiomatic wrappers over `createSyncCollection`, one per framework, so a live
265
+ collection is one call. Each returns the same `{ data, status, error, mutate }`
266
+ and is SSR-safe (the socket opens on the client only).
267
+
268
+ | Subpath | Export | What it is |
269
+ | ---------- | ---------------------------------------- | ---------------------------------------------------- |
270
+ | `/react` | `useSyncCollection(options)` | React hook (re-renders on diffs). |
271
+ | `/vue` | `useSyncCollection(options)` | Vue composable (reactive refs). |
272
+ | `/svelte` | `createSyncCollectionStore(options)` | Svelte readable store (`$store` → state) + `mutate`. |
273
+ | `/angular` | `SyncCollectionService.connect(options)` | Angular service returning signals. |
274
+
275
+ ```tsx
276
+ // React
277
+ import { useSyncCollection } from '@absolutejs/sync/react';
278
+
279
+ const { data, status, mutate } = useSyncCollection<Order>({
280
+ url: 'ws://localhost:3000/sync/ws',
281
+ collection: 'orders',
282
+ params: { userId }
283
+ });
284
+
285
+ mutate({
286
+ name: 'createOrder',
287
+ args: { total: 42 },
288
+ optimistic: (draft) => draft.set({ id: tempId, total: 42 } as Order)
289
+ });
290
+ ```
291
+
292
+ ### `@absolutejs/sync/engine`
293
+
294
+ | Export | What it is |
295
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
296
+ | `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `runMutation`. |
297
+ | `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
298
+ | `defineMutation({ name, handler, authorize? })` | Define a server mutation that emits changes. |
299
+ | `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
300
+ | `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
301
+ | `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
302
+ | `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
303
+ | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
304
+
305
+ ### `@absolutejs/sync/postgres`
306
+
307
+ | Export | What it is |
308
+ | ------------------------------------------------------------ | ---------------------------------------------------------------- |
309
+ | `postgresChangeSource({ listen, channel?, parse? })` | CDC `ChangeSource` over `LISTEN/NOTIFY` (bring your own client). |
310
+ | `postgresNotifyTrigger({ tables, channel?, functionName? })` | SQL to install the notify triggers (run once). |
311
+
312
+ ### `@absolutejs/sync/mysql`
313
+
314
+ | Export | What it is |
315
+ | ------------------------------------------------------------ | --------------------------------------------------------------------------- |
316
+ | `mysqlChangelogSchema({ tables, changelogTable?, prefix? })` | SQL to install the changelog table + triggers (run once). |
317
+ | `createPollingChangeSource({ poll, ... })` | Tail the changelog (re-exported from the engine). |
318
+ | `mysqlBinlogChangeSource({ subscribe, normalize? })` | Higher-throughput CDC over the binlog (bring your own reader, e.g. zongji). |
319
+ | `normalizeBinlogEvent(event)` | Pure: a binlog row event → engine changes. |
320
+
321
+ ### `@absolutejs/sync/sqlite`
322
+
323
+ | Export | What it is |
324
+ | ------------------------------------------------------------- | --------------------------------------------------------- |
325
+ | `sqliteChangelogSchema({ tables, changelogTable?, prefix? })` | SQL to install the changelog table + triggers (run once). |
326
+ | `createPollingChangeSource({ poll, ... })` | Tail the changelog (re-exported from the engine). |
327
+
328
+ ### `@absolutejs/sync/drizzle` and `@absolutejs/sync/prisma`
329
+
330
+ | Export | What it is |
331
+ | --------------------------------------------------------------------- | ---------------------------------------------------------- |
332
+ | `deriveReadTopics(table\|model, where?, options?)` | Topics a read depends on (`{ topics, rowLevel }`). |
333
+ | `publishChange(hub, table\|model, { keys?, op? })` | Publish the table topic + a row topic per key. |
334
+ | `publishRows(hub, table\|model, rows, { keyField?/keyColumn?, op? })` | Publish topics for returned/created records. |
335
+ | `publishWhere(hub, table\|model, where, { ..., op? })` | Publish topics for an update/delete filter. |
336
+ | `tableTopic` / `keyTopic` | The shared topic vocabulary both sides speak. |
337
+ | `prismaCollection({ name, where, find, ... })` (prisma) | A sync-engine collection; one `where` → hydrate + matcher. |
338
+ | `matchesWhere(where, row)` (prisma) | Evaluate a Prisma `where` against a row (the matcher). |
99
339
 
100
340
  ## License
101
341
 
@@ -0,0 +1,17 @@
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 { deriveReadTopics } from './read';
15
+ export type { DeriveReadTopicsOptions, DerivedReadTopics } from './read';
16
+ export { publishChange, publishRows, publishWhere } from './write';
17
+ export type { ChangeOp, ChangePayload, PublishChangeOptions, PublishRowsOptions, PublishWhereOptions } from './write';
@@ -0,0 +1,128 @@
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/read.ts
87
+ var deriveReadTopics = (table, where, options = {}) => {
88
+ const key = where === undefined ? undefined : extractKeyFromWhere(table, where, options.keyColumn);
89
+ if (key === undefined) {
90
+ return { topics: [tableTopic(table)], rowLevel: false };
91
+ }
92
+ return { topics: [keyTopic(table, key)], rowLevel: true };
93
+ };
94
+ // src/adapters/drizzle/write.ts
95
+ var publishChange = (hub, table, options = {}) => {
96
+ const name = tableTopic(table);
97
+ const keys = options.keys === undefined ? [] : [...new Set(options.keys)];
98
+ const payload = { table: name, op: options.op, keys };
99
+ const topics = [
100
+ ...new Set([name, ...keys.map((key) => keyTopic(table, key))])
101
+ ];
102
+ for (const topic of topics) {
103
+ hub.publish(topic, payload);
104
+ }
105
+ return topics;
106
+ };
107
+ var publishRows = (hub, table, rows, options = {}) => publishChange(hub, table, {
108
+ keys: extractRowKeys(table, rows, options.keyColumn),
109
+ op: options.op
110
+ });
111
+ var publishWhere = (hub, table, where, options = {}) => {
112
+ const key = extractKeyFromWhere(table, where, options.keyColumn);
113
+ return publishChange(hub, table, {
114
+ keys: key === undefined ? [] : [key],
115
+ op: options.op
116
+ });
117
+ };
118
+ export {
119
+ tableTopic,
120
+ publishWhere,
121
+ publishRows,
122
+ publishChange,
123
+ keyTopic,
124
+ deriveReadTopics
125
+ };
126
+
127
+ //# debugId=ADD7C375EE239C1664756E2164756E21
128
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/adapters/drizzle/topics.ts", "../src/adapters/drizzle/read.ts", "../src/adapters/drizzle/write.ts"],
4
+ "sourcesContent": [
5
+ "import {\n\tColumn,\n\tgetTableColumns,\n\tgetTableName,\n\tis,\n\tParam,\n\tSQL\n} from 'drizzle-orm';\nimport type { Table } from 'drizzle-orm';\n\n/**\n * Shared topic vocabulary + key resolution for the Drizzle adapter. Both the\n * read side (derive the topics a query depends on) and the write side (publish\n * the topics a mutation invalidates) build on these so the two always agree.\n */\n\n/** The coarse topic every read/write of `table` touches, e.g. `users`. */\nexport const tableTopic = (table: Table): string => getTableName(table);\n\n/** The row-level topic for one key of `table`, e.g. `users:5`. */\nexport const keyTopic = (table: Table, key: string | number): string =>\n\t`${getTableName(table)}:${key}`;\n\ntype ResolvedKey = {\n\t/** JS property name of the key column on the table / result rows. */\n\tproperty: string;\n\t/** Underlying DB column name, as it appears in a SQL expression. */\n\tcolumn: string;\n};\n\n/**\n * Resolve the column to use as the row key: an explicitly requested column (by\n * JS property name), otherwise the table's sole primary key. Returns `undefined`\n * when no single key column applies (composite or missing primary key).\n */\nexport const resolveKeyColumn = (\n\ttable: Table,\n\tkeyColumn?: string\n): ResolvedKey | undefined => {\n\tconst columns = getTableColumns(table);\n\tif (keyColumn !== undefined) {\n\t\tconst column = columns[keyColumn];\n\t\treturn column === undefined\n\t\t\t? undefined\n\t\t\t: { property: keyColumn, column: column.name };\n\t}\n\tconst primaries = Object.entries(columns).filter(\n\t\t([, column]) => column.primary\n\t);\n\tconst primary = primaries.length === 1 ? primaries[0] : undefined;\n\treturn primary === undefined\n\t\t? undefined\n\t\t: { property: primary[0], column: primary[1].name };\n};\n\n/**\n * Best-effort: pull a single key-column equality value out of a Drizzle `where`\n * expression. Recognises only the simple `eq(keyColumn, scalar)` shape — any\n * nesting (`and`/`or`), extra columns/params, a non-`=` operator, or a\n * non-key/cross-table column yields `undefined`.\n *\n * Reads Drizzle's internal `queryChunks`, which is not a stable public API;\n * every branch degrades to `undefined` (coarser topic) rather than throwing, so\n * a Drizzle version bump can only cost precision, never correctness.\n */\nexport const extractKeyFromWhere = (\n\ttable: Table,\n\twhere: SQL,\n\tkeyColumn?: string\n): string | number | undefined => {\n\tconst resolved = resolveKeyColumn(table, keyColumn);\n\tif (resolved === undefined) {\n\t\treturn undefined;\n\t}\n\n\tconst chunks: unknown = (where as { queryChunks?: unknown }).queryChunks;\n\tif (!Array.isArray(chunks)) {\n\t\treturn undefined;\n\t}\n\n\tlet column: Column | undefined;\n\tlet param: Param | undefined;\n\tlet tooComplex = false;\n\tlet operator = '';\n\n\tfor (const chunk of chunks) {\n\t\tif (is(chunk, SQL)) {\n\t\t\t// Nested expression (e.g. and/or) — not a simple equality.\n\t\t\treturn undefined;\n\t\t}\n\t\tif (is(chunk, Column)) {\n\t\t\tif (column !== undefined) {\n\t\t\t\ttooComplex = true;\n\t\t\t}\n\t\t\tcolumn = chunk;\n\t\t} else if (is(chunk, Param)) {\n\t\t\tif (param !== undefined) {\n\t\t\t\ttooComplex = true;\n\t\t\t}\n\t\t\tparam = chunk;\n\t\t} else {\n\t\t\tconst value: unknown = (chunk as { value?: unknown }).value;\n\t\t\tif (Array.isArray(value)) {\n\t\t\t\toperator += value.join('');\n\t\t\t}\n\t\t}\n\t}\n\n\tif (tooComplex || column === undefined || param === undefined) {\n\t\treturn undefined;\n\t}\n\tif (operator.trim() !== '=') {\n\t\treturn undefined;\n\t}\n\tif (column.name !== resolved.column) {\n\t\treturn undefined;\n\t}\n\tif (getTableName(column.table) !== getTableName(table)) {\n\t\treturn undefined;\n\t}\n\n\tconst value: unknown = param.value;\n\treturn typeof value === 'string' || typeof value === 'number'\n\t\t? value\n\t\t: undefined;\n};\n\n/**\n * Read the key value from each row (e.g. the output of a mutation's\n * `.returning()`), using the table's primary-key column or an explicit\n * `keyColumn`. Rows without a string/number key are skipped.\n */\nexport const extractRowKeys = (\n\ttable: Table,\n\trows: ReadonlyArray<Record<string, unknown>>,\n\tkeyColumn?: string\n): (string | number)[] => {\n\tconst resolved = resolveKeyColumn(table, keyColumn);\n\tif (resolved === undefined) {\n\t\treturn [];\n\t}\n\tconst keys: (string | number)[] = [];\n\tfor (const row of rows) {\n\t\tconst value = row[resolved.property];\n\t\tif (typeof value === 'string' || typeof value === 'number') {\n\t\t\tkeys.push(value);\n\t\t}\n\t}\n\treturn keys;\n};\n",
6
+ "import type { SQL, Table } from 'drizzle-orm';\nimport { extractKeyFromWhere, keyTopic, tableTopic } from './topics';\n\nexport type DeriveReadTopicsOptions = {\n\t/**\n\t * Column (its JS property name on the table) to treat as the row key when\n\t * narrowing to a `table:key` topic. Defaults to the table's single\n\t * primary-key column; composite or absent primary keys disable row-level\n\t * narrowing.\n\t */\n\tkeyColumn?: string;\n};\n\nexport type DerivedReadTopics = {\n\t/** Topics this read depends on — subscribe to all of them. */\n\ttopics: string[];\n\t/**\n\t * `true` when derivation narrowed to a specific row (`table:key`); `false`\n\t * when it fell back to the whole-table topic.\n\t */\n\trowLevel: boolean;\n};\n\n/**\n * Derive the reactive topics a read of `table` (optionally filtered by `where`)\n * depends on. A recognised primary-key equality narrows to a single `table:key`\n * topic; everything else subscribes to the whole-table topic, over-invalidating\n * a little rather than missing an update.\n *\n * @example\n * deriveReadTopics(users); // { topics: ['users'], rowLevel: false }\n * deriveReadTopics(users, eq(users.id, 5)); // { topics: ['users:5'], rowLevel: true }\n * deriveReadTopics(users, gt(users.id, 5)); // { topics: ['users'], rowLevel: false }\n */\nexport const deriveReadTopics = (\n\ttable: Table,\n\twhere?: SQL,\n\toptions: DeriveReadTopicsOptions = {}\n): DerivedReadTopics => {\n\tconst key =\n\t\twhere === undefined\n\t\t\t? undefined\n\t\t\t: extractKeyFromWhere(table, where, options.keyColumn);\n\n\tif (key === undefined) {\n\t\treturn { topics: [tableTopic(table)], rowLevel: false };\n\t}\n\treturn { topics: [keyTopic(table, key)], rowLevel: true };\n};\n",
7
+ "import type { SQL, Table } from 'drizzle-orm';\nimport type { ReactiveHub } from '../../reactiveHub';\nimport {\n\textractKeyFromWhere,\n\textractRowKeys,\n\tkeyTopic,\n\ttableTopic\n} from './topics';\n\n/**\n * Drizzle write-side topic publishing (Tier 2).\n *\n * The mirror of {@link deriveReadTopics}: after a mutation commits, publish the\n * topics it invalidates so subscribed reads refetch. Every change publishes the\n * **table** topic (so list/table queries refresh) plus a **row** topic per\n * affected key (so row-level queries refresh) — the exact topics the read side\n * subscribes to.\n *\n * These are the \"route mutations through us\" change source from the roadmap:\n * call them right after your durable write. They work on any DB Drizzle supports\n * and never touch DB-specific machinery; out-of-band writes (caught later by CDC\n * adapters) are the only thing they miss.\n */\n\n/** The kind of mutation, forwarded in the change-event payload. */\nexport type ChangeOp = 'insert' | 'update' | 'delete';\n\n/** Payload carried by every change event the write side publishes. */\nexport type ChangePayload = {\n\t/** Name of the table that changed. */\n\ttable: string;\n\t/** Mutation kind, when the caller provided it. */\n\top?: ChangeOp;\n\t/** Affected row keys (empty for a table-wide change). */\n\tkeys: (string | number)[];\n};\n\nexport type PublishChangeOptions = {\n\t/** Row keys that changed; each emits a `table:key` topic. */\n\tkeys?: ReadonlyArray<string | number>;\n\top?: ChangeOp;\n};\n\n/**\n * Publish the reactive topics a change to `table` invalidates: the whole-table\n * topic (always) plus a `table:key` topic per affected row. Call after the\n * durable write commits. Returns the (de-duplicated) topics published.\n */\nexport const publishChange = (\n\thub: Pick<ReactiveHub, 'publish'>,\n\ttable: Table,\n\toptions: PublishChangeOptions = {}\n): string[] => {\n\tconst name = tableTopic(table);\n\tconst keys = options.keys === undefined ? [] : [...new Set(options.keys)];\n\tconst payload: ChangePayload = { table: name, op: options.op, keys };\n\tconst topics = [\n\t\t...new Set([name, ...keys.map((key) => keyTopic(table, key))])\n\t];\n\tfor (const topic of topics) {\n\t\thub.publish(topic, payload);\n\t}\n\treturn topics;\n};\n\nexport type PublishRowsOptions = {\n\t/** Key column (JS property name); defaults to the table's primary key. */\n\tkeyColumn?: string;\n\top?: ChangeOp;\n};\n\n/**\n * Publish change topics for a set of rows — typically the output of a mutation's\n * `.returning()`, which yields real keys including auto-generated ones. Reads\n * each row's primary-key column (or `keyColumn`) to emit `table:key` topics.\n *\n * @example\n * const rows = await db.insert(users).values(input).returning();\n * publishRows(hub, users, rows, { op: 'insert' });\n */\nexport const publishRows = (\n\thub: Pick<ReactiveHub, 'publish'>,\n\ttable: Table,\n\trows: ReadonlyArray<Record<string, unknown>>,\n\toptions: PublishRowsOptions = {}\n): string[] =>\n\tpublishChange(hub, table, {\n\t\tkeys: extractRowKeys(table, rows, options.keyColumn),\n\t\top: options.op\n\t});\n\nexport type PublishWhereOptions = {\n\t/** Key column (JS property name); defaults to the table's primary key. */\n\tkeyColumn?: string;\n\top?: ChangeOp;\n};\n\n/**\n * Publish change topics for an `update`/`delete` identified by a `where` filter.\n * A simple primary-key equality narrows to that row's topic; any other filter\n * publishes just the table topic, so every affected subscriber refetches and\n * re-evaluates.\n *\n * @example\n * await db.update(users).set(patch).where(eq(users.id, id));\n * publishWhere(hub, users, eq(users.id, id), { op: 'update' });\n */\nexport const publishWhere = (\n\thub: Pick<ReactiveHub, 'publish'>,\n\ttable: Table,\n\twhere: SQL,\n\toptions: PublishWhereOptions = {}\n): string[] => {\n\tconst key = extractKeyFromWhere(table, where, options.keyColumn);\n\treturn publishChange(hub, table, {\n\t\tkeys: key === undefined ? [] : [key],\n\t\top: options.op\n\t});\n};\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "ADD7C375EE239C1664756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,31 @@
1
+ import type { SQL, Table } from 'drizzle-orm';
2
+ export type DeriveReadTopicsOptions = {
3
+ /**
4
+ * Column (its JS property name on the table) to treat as the row key when
5
+ * narrowing to a `table:key` topic. Defaults to the table's single
6
+ * primary-key column; composite or absent primary keys disable row-level
7
+ * narrowing.
8
+ */
9
+ keyColumn?: string;
10
+ };
11
+ export type DerivedReadTopics = {
12
+ /** Topics this read depends on — subscribe to all of them. */
13
+ topics: string[];
14
+ /**
15
+ * `true` when derivation narrowed to a specific row (`table:key`); `false`
16
+ * when it fell back to the whole-table topic.
17
+ */
18
+ rowLevel: boolean;
19
+ };
20
+ /**
21
+ * Derive the reactive topics a read of `table` (optionally filtered by `where`)
22
+ * depends on. A recognised primary-key equality narrows to a single `table:key`
23
+ * topic; everything else subscribes to the whole-table topic, over-invalidating
24
+ * a little rather than missing an update.
25
+ *
26
+ * @example
27
+ * deriveReadTopics(users); // { topics: ['users'], rowLevel: false }
28
+ * deriveReadTopics(users, eq(users.id, 5)); // { topics: ['users:5'], rowLevel: true }
29
+ * deriveReadTopics(users, gt(users.id, 5)); // { topics: ['users'], rowLevel: false }
30
+ */
31
+ export declare const deriveReadTopics: (table: Table, where?: SQL, options?: DeriveReadTopicsOptions) => DerivedReadTopics;
@@ -0,0 +1,41 @@
1
+ import { SQL } from 'drizzle-orm';
2
+ import type { Table } from 'drizzle-orm';
3
+ /**
4
+ * Shared topic vocabulary + key resolution for the Drizzle adapter. Both the
5
+ * read side (derive the topics a query depends on) and the write side (publish
6
+ * the topics a mutation invalidates) build on these so the two always agree.
7
+ */
8
+ /** The coarse topic every read/write of `table` touches, e.g. `users`. */
9
+ export declare const tableTopic: (table: Table) => string;
10
+ /** The row-level topic for one key of `table`, e.g. `users:5`. */
11
+ export declare const keyTopic: (table: Table, key: string | number) => string;
12
+ type ResolvedKey = {
13
+ /** JS property name of the key column on the table / result rows. */
14
+ property: string;
15
+ /** Underlying DB column name, as it appears in a SQL expression. */
16
+ column: string;
17
+ };
18
+ /**
19
+ * Resolve the column to use as the row key: an explicitly requested column (by
20
+ * JS property name), otherwise the table's sole primary key. Returns `undefined`
21
+ * when no single key column applies (composite or missing primary key).
22
+ */
23
+ export declare const resolveKeyColumn: (table: Table, keyColumn?: string) => ResolvedKey | undefined;
24
+ /**
25
+ * Best-effort: pull a single key-column equality value out of a Drizzle `where`
26
+ * expression. Recognises only the simple `eq(keyColumn, scalar)` shape — any
27
+ * nesting (`and`/`or`), extra columns/params, a non-`=` operator, or a
28
+ * non-key/cross-table column yields `undefined`.
29
+ *
30
+ * Reads Drizzle's internal `queryChunks`, which is not a stable public API;
31
+ * every branch degrades to `undefined` (coarser topic) rather than throwing, so
32
+ * a Drizzle version bump can only cost precision, never correctness.
33
+ */
34
+ export declare const extractKeyFromWhere: (table: Table, where: SQL, keyColumn?: string) => string | number | undefined;
35
+ /**
36
+ * Read the key value from each row (e.g. the output of a mutation's
37
+ * `.returning()`), using the table's primary-key column or an explicit
38
+ * `keyColumn`. Rows without a string/number key are skipped.
39
+ */
40
+ export declare const extractRowKeys: (table: Table, rows: ReadonlyArray<Record<string, unknown>>, keyColumn?: string) => (string | number)[];
41
+ export {};
@@ -0,0 +1,69 @@
1
+ import type { SQL, Table } from 'drizzle-orm';
2
+ import type { ReactiveHub } from '../../reactiveHub';
3
+ /**
4
+ * Drizzle write-side topic publishing (Tier 2).
5
+ *
6
+ * The mirror of {@link deriveReadTopics}: after a mutation commits, publish the
7
+ * topics it invalidates so subscribed reads refetch. Every change publishes the
8
+ * **table** topic (so list/table queries refresh) plus a **row** topic per
9
+ * affected key (so row-level queries refresh) — the exact topics the read side
10
+ * subscribes to.
11
+ *
12
+ * These are the "route mutations through us" change source from the roadmap:
13
+ * call them right after your durable write. They work on any DB Drizzle supports
14
+ * and never touch DB-specific machinery; out-of-band writes (caught later by CDC
15
+ * adapters) are the only thing they miss.
16
+ */
17
+ /** The kind of mutation, forwarded in the change-event payload. */
18
+ export type ChangeOp = 'insert' | 'update' | 'delete';
19
+ /** Payload carried by every change event the write side publishes. */
20
+ export type ChangePayload = {
21
+ /** Name of the table that changed. */
22
+ table: string;
23
+ /** Mutation kind, when the caller provided it. */
24
+ op?: ChangeOp;
25
+ /** Affected row keys (empty for a table-wide change). */
26
+ keys: (string | number)[];
27
+ };
28
+ export type PublishChangeOptions = {
29
+ /** Row keys that changed; each emits a `table:key` topic. */
30
+ keys?: ReadonlyArray<string | number>;
31
+ op?: ChangeOp;
32
+ };
33
+ /**
34
+ * Publish the reactive topics a change to `table` invalidates: the whole-table
35
+ * topic (always) plus a `table:key` topic per affected row. Call after the
36
+ * durable write commits. Returns the (de-duplicated) topics published.
37
+ */
38
+ export declare const publishChange: (hub: Pick<ReactiveHub, "publish">, table: Table, options?: PublishChangeOptions) => string[];
39
+ export type PublishRowsOptions = {
40
+ /** Key column (JS property name); defaults to the table's primary key. */
41
+ keyColumn?: string;
42
+ op?: ChangeOp;
43
+ };
44
+ /**
45
+ * Publish change topics for a set of rows — typically the output of a mutation's
46
+ * `.returning()`, which yields real keys including auto-generated ones. Reads
47
+ * each row's primary-key column (or `keyColumn`) to emit `table:key` topics.
48
+ *
49
+ * @example
50
+ * const rows = await db.insert(users).values(input).returning();
51
+ * publishRows(hub, users, rows, { op: 'insert' });
52
+ */
53
+ export declare const publishRows: (hub: Pick<ReactiveHub, "publish">, table: Table, rows: ReadonlyArray<Record<string, unknown>>, options?: PublishRowsOptions) => string[];
54
+ export type PublishWhereOptions = {
55
+ /** Key column (JS property name); defaults to the table's primary key. */
56
+ keyColumn?: string;
57
+ op?: ChangeOp;
58
+ };
59
+ /**
60
+ * Publish change topics for an `update`/`delete` identified by a `where` filter.
61
+ * A simple primary-key equality narrows to that row's topic; any other filter
62
+ * publishes just the table topic, so every affected subscriber refetches and
63
+ * re-evaluates.
64
+ *
65
+ * @example
66
+ * await db.update(users).set(patch).where(eq(users.id, id));
67
+ * publishWhere(hub, users, eq(users.id, id), { op: 'update' });
68
+ */
69
+ export declare const publishWhere: (hub: Pick<ReactiveHub, "publish">, table: Table, where: SQL, options?: PublishWhereOptions) => string[];