@absolutejs/sync 0.10.0 → 0.12.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 CHANGED
@@ -345,30 +345,33 @@ it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real
345
345
 
346
346
  ### `@absolutejs/sync/client`
347
347
 
348
- | Export | What it is |
349
- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
350
- | `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client. |
351
- | `createLiveQuery({ topics, fetcher, ... })` | Hydrate-once, refetch-on-event observable query store. |
352
- | `jsonFetcher(url, init?)` | Default `fetcher`: GET + JSON parse, forwards the abort signal. |
353
- | `createSyncCollection({ url, collection, ... })` | Live diff-driven collection store with optimistic `mutate`. |
354
- | `createSyncClient({ url })` | One socket, many collections (`client.collection(...)`). Applies a multi-collection mutation's diffs as one **consistent frame** — no torn cross-collection paint. |
355
- | `createPresence({ url, room, state })` | Join a presence room: see who's online / typing (`get` + `subscribe`) and publish your own state (`set`). |
356
- | `localStorageMutationStorage(key)` | `localStorage`-backed offline write queue for `createSyncCollection`. |
357
- | `localStorageCollectionCache(key)` | `localStorage`-backed local-first read cache: confirmed rows survive a reload, resume from the cached version. |
358
- | `indexedDbCollectionCache({ key, ... })` | IndexedDB-backed local-first read cache durable, large-capacity. Same resume semantics, async storage. |
348
+ | Export | What it is |
349
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
350
+ | `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client. |
351
+ | `createLiveQuery({ topics, fetcher, ... })` | Hydrate-once, refetch-on-event observable query store. |
352
+ | `jsonFetcher(url, init?)` | Default `fetcher`: GET + JSON parse, forwards the abort signal. |
353
+ | `createSyncCollection({ url, collection, ... })` | Live diff-driven collection store with optimistic `mutate`. |
354
+ | `createSyncClient({ url })` | One socket, many collections (`client.collection(...)`). Applies a multi-collection mutation's diffs as one **consistent frame** — no torn cross-collection paint. |
355
+ | `createPresence({ url, room, state })` | Join a presence room: see who's online / typing (`get` + `subscribe`) and publish your own state (`set`). |
356
+ | `createCollaborativeText({ url, collection, id, field, ... })` | Live CRDT collaborative-text controller (`get`/`subscribe`/`setText`/`close`): tracks a row's CRDT field, merges remote edits into a local replica, and broadcasts via the engine's `"<collection>:merge"` mutation. Backs the `useCollaborativeText` framework hooks. |
357
+ | `localStorageMutationStorage(key)` | `localStorage`-backed offline write queue for `createSyncCollection`. |
358
+ | `localStorageCollectionCache(key)` | `localStorage`-backed local-first read cache: confirmed rows survive a reload, resume from the cached version. |
359
+ | `indexedDbCollectionCache({ key, ... })` | IndexedDB-backed local-first read cache — durable, large-capacity. Same resume semantics, async storage. |
359
360
 
360
361
  ### Framework bindings — `@absolutejs/sync/{react,vue,svelte,angular}`
361
362
 
362
363
  Idiomatic wrappers over `createSyncCollection`, one per framework, so a live
363
364
  collection is one call. Each returns the same `{ data, status, error, mutate }`
364
- and is SSR-safe (the socket opens on the client only).
365
+ and is SSR-safe (the socket opens on the client only). Each also ships a
366
+ **collaborative-text** binding over `createCollaborativeText` — a CRDT shared text
367
+ field in one call (`text`/`setText`/`status`).
365
368
 
366
- | Subpath | Export | What it is |
367
- | ---------- | ---------------------------------------- | ---------------------------------------------------- |
368
- | `/react` | `useSyncCollection(options)` | React hook (re-renders on diffs). |
369
- | `/vue` | `useSyncCollection(options)` | Vue composable (reactive refs). |
370
- | `/svelte` | `createSyncCollectionStore(options)` | Svelte readable store (`$store` → state) + `mutate`. |
371
- | `/angular` | `SyncCollectionService.connect(options)` | Angular service returning signals. |
369
+ | Subpath | Collection | Collaborative text |
370
+ | ---------- | ---------------------------------------- | ----------------------------------------------- |
371
+ | `/react` | `useSyncCollection(options)` | `useCollaborativeText(options)` |
372
+ | `/vue` | `useSyncCollection(options)` | `useCollaborativeText(options)` |
373
+ | `/svelte` | `createSyncCollectionStore(options)` | `createCollaborativeTextStore(options)` |
374
+ | `/angular` | `SyncCollectionService.connect(options)` | `SyncCollectionService.collaborativeText(opts)` |
372
375
 
373
376
  ```tsx
374
377
  // React
@@ -389,38 +392,39 @@ mutate({
389
392
 
390
393
  ### `@absolutejs/sync/engine`
391
394
 
392
- | Export | What it is |
393
- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
394
- | `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
395
- | `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
396
- | `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. |
397
- | `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. |
398
- | `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
399
- | `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
400
- | `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
401
- | `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
402
- | `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
403
- | `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
404
- | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
405
- | `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. |
406
- | `definePermissions({ [table]: { read?, insert?, update?, delete?, write? } })` | Declarative row-level access control. Pass as `createSyncEngine({ permissions })` or `registerPermissions(table, rules)`. Read rules filter every row emitted; write rules gate `actions.insert/update/delete`. |
407
- | `defineSchema({ [table]: { fields, version?, migrate? } })` + `field` kit | Declarative row schema. Pass as `createSyncEngine({ schemas })` or `registerSchema(table, schema)`. Writes are validated (bad write → `SchemaError`); `migrate` lazily upcasts rows on read (no DB migration needed). |
408
- | `defineSearchCollection({ name, table, index, source, key, limit? })` + `registerSearch` | Live search collection: the subscription's `params` are the query (string/vector), the ranked top-K stream back as a normal collection, re-ranked as rows change. Each row carries its score under `_score`. |
409
- | `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
410
- | `createVectorIndex({ key, embedding, metric? })` | Incremental vector index (cosine/dot/euclidean exact k-NN) for semantic search — pairs with `@absolutejs/ai` / `@absolutejs/rag` for RAG retrieval on your own data. |
411
- | `defineSchedule({ name, pattern, run })` + `registerSchedule` / `runSchedule` | Scheduled function: `run({ db, actions })` fires on a cron `pattern`; its writes go live through the change feed. Wire triggers with the `scheduled` plugin (or call `runSchedule(name)` on demand). |
395
+ | Export | What it is |
396
+ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
+ | `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
398
+ | `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
399
+ | `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. |
400
+ | `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. |
401
+ | `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
402
+ | `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
403
+ | `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
404
+ | `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
405
+ | `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
406
+ | `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
407
+ | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
408
+ | `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. |
409
+ | `definePermissions({ [table]: { read?, insert?, update?, delete?, write? } })` | Declarative row-level access control. Pass as `createSyncEngine({ permissions })` or `registerPermissions(table, rules)`. Read rules filter every row emitted; write rules gate `actions.insert/update/delete`. |
410
+ | `defineSchema({ [table]: { fields, version?, migrate? } })` + `field` kit | Declarative row schema. Pass as `createSyncEngine({ schemas })` or `registerSchema(table, schema)`. Writes are validated (bad write → `SchemaError`); `migrate` lazily upcasts rows on read (no DB migration needed). |
411
+ | `registerCrdt(table, { [field]: mergeable })` | Declare CRDT fields (a `CrdtMergeable` like `rgaText`, or `yjsText` from `@absolutejs/sync-yjs`). The engine **merges** those fields on `actions.insert/update` instead of overwriting — conflict-free collaborative editing with no merge code and auto-registers a `"<table>:merge"` mutation the `useCollaborativeText` hooks call. |
412
+ | `defineSearchCollection({ name, table, index, source, key, limit? })` + `registerSearch` | Live search collection: the subscription's `params` are the query (string/vector), the ranked top-K stream back as a normal collection, re-ranked as rows change. Each row carries its score under `_score`. |
413
+ | `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
414
+ | `createVectorIndex({ key, embedding, metric? })` | Incremental vector index (cosine/dot/euclidean exact k-NN) for semantic search pairs with `@absolutejs/ai` / `@absolutejs/rag` for RAG retrieval on your own data. |
415
+ | `defineSchedule({ name, pattern, run })` + `registerSchedule` / `runSchedule` | Scheduled function: `run({ db, actions })` fires on a cron `pattern`; its writes go live through the change feed. Wire triggers with the `scheduled` plugin (or call `runSchedule(name)` on demand). |
412
416
 
413
417
  ### `@absolutejs/sync/crdt`
414
418
 
415
- Conflict-free replicated data types — pure, **zero-dependency**, and isomorphic (same code client and server). They merge concurrent edits from different tabs/devices without a server round-trip per keystroke and without clobbering: every `merge` is commutative, associative, and idempotent, so replicas converge no matter the order. They ride the existing engine with no engine changes — store the CRDT state as a row field and have a mutation `merge` the incoming state into the stored one.
419
+ Conflict-free replicated data types — pure, **zero-dependency**, and isomorphic (same code client and server). They merge concurrent edits from different tabs/devices without a server round-trip per keystroke and without clobbering: every `merge` is commutative, associative, and idempotent, so replicas converge no matter the order. They ride the existing engine with no engine changes — store the CRDT state as a row field. The declarative path is one line each end: server `engine.registerCrdt(table, { field: rgaText })` (auto-merges that field on write), client `useCollaborativeText({ collection, id, field, url })`. The primitives below are also usable directly.
416
420
 
417
- | Export | What it is |
418
- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
419
- | `counter` | PN-counter: `create/value/increment/decrement/merge`. Concurrent increments and decrements across replicas all survive. |
420
- | `lww` | Last-write-wins register: `create/set/merge`. The latest timestamp wins (replica id breaks ties) — for "just take the newest value" fields. |
421
- | `createTextCrdt(replica, initial?)` | Collaborative text (an RGA sequence CRDT): `text/insert/delete/setText/merge/state`. Drive it from an input via `setText`; broadcast `state()`; apply remote state via `merge` concurrent edits merge and converge. |
422
- | `textOf(state)` / `mergeTextState(a, b)` | Pure helpers for the text state — use them server-side (e.g. a merge-on-write mutation) with no live instance. |
423
- | `CrdtText<State>` / `TextCrdtAdapter<State>` | The pluggable collaborative-text contract. `rgaText` is the first-party (zero-dep) backend; swap in an adapter from the `sync-adapters` repo (e.g. `@absolutejs/sync-yjs`, which wraps the Yjs staple) behind the same call sites. |
421
+ | Export | What it is |
422
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
423
+ | `counter` | PN-counter: `create/value/increment/decrement/merge`. Concurrent increments and decrements across replicas all survive. |
424
+ | `lww` | Last-write-wins register: `create/set/merge`. The latest timestamp wins (replica id breaks ties) — for "just take the newest value" fields. |
425
+ | `createTextCrdt(replica, initial?)` | Collaborative text (an RGA sequence CRDT): `text/insert/delete/setText/merge/state` + `takeDelta`. Drive it via `setText`; apply remote state via `merge`; `takeDelta()` returns just this client's new ops (a delta-state) so uploads are O(edit), not O(doc). Concurrent edits merge and converge. |
426
+ | `textOf(state)` / `mergeTextState(a, b)` | Pure helpers for the text state — use them server-side (e.g. a merge-on-write mutation) with no live instance. |
427
+ | `CrdtText<State>` / `TextCrdtAdapter<State>` | The pluggable collaborative-text contract. `rgaText` is the first-party (zero-dep) backend; swap in an adapter from the `sync-adapters` repo (e.g. `@absolutejs/sync-yjs`, which wraps the Yjs staple) behind the same call sites. |
424
428
 
425
429
  ### `@absolutejs/sync/postgres`
426
430
 
@@ -68,6 +68,191 @@ var __decorateElement = (array, flags, name, decorators, target, extra) => {
68
68
  return k || __decoratorMetadata(array, target), desc && __defProp(target, name, desc), p ? k ^ 4 ? extra : desc : target;
69
69
  };
70
70
 
71
+ // src/crdt/index.ts
72
+ var sumValues = (counts) => Object.values(counts).reduce((total, value) => total + value, 0);
73
+ var mergeMax = (a, b) => {
74
+ const merged = { ...a };
75
+ for (const [replica, value] of Object.entries(b)) {
76
+ merged[replica] = Math.max(merged[replica] ?? 0, value);
77
+ }
78
+ return merged;
79
+ };
80
+ var counter = {
81
+ create: () => ({ increments: {}, decrements: {} }),
82
+ value: (state) => sumValues(state.increments) - sumValues(state.decrements),
83
+ increment: (state, replica, by = 1) => ({
84
+ increments: {
85
+ ...state.increments,
86
+ [replica]: (state.increments[replica] ?? 0) + by
87
+ },
88
+ decrements: state.decrements
89
+ }),
90
+ decrement: (state, replica, by = 1) => ({
91
+ increments: state.increments,
92
+ decrements: {
93
+ ...state.decrements,
94
+ [replica]: (state.decrements[replica] ?? 0) + by
95
+ }
96
+ }),
97
+ merge: (a, b) => ({
98
+ increments: mergeMax(a.increments, b.increments),
99
+ decrements: mergeMax(a.decrements, b.decrements)
100
+ })
101
+ };
102
+ var lww = {
103
+ create: (value, replica, timestamp = Date.now()) => ({ value, timestamp, replica }),
104
+ set: (value, replica, timestamp = Date.now()) => ({
105
+ value,
106
+ timestamp,
107
+ replica
108
+ }),
109
+ merge: (a, b) => {
110
+ if (b.timestamp > a.timestamp) {
111
+ return b;
112
+ }
113
+ if (b.timestamp < a.timestamp) {
114
+ return a;
115
+ }
116
+ return b.replica > a.replica ? b : a;
117
+ }
118
+ };
119
+ var compare = (a, b) => {
120
+ if (a.clock !== b.clock) {
121
+ return b.clock - a.clock;
122
+ }
123
+ if (a.replica === b.replica) {
124
+ return 0;
125
+ }
126
+ return a.replica > b.replica ? -1 : 1;
127
+ };
128
+ var linearize = (elements) => {
129
+ const children = new Map;
130
+ for (const element of elements) {
131
+ const list = children.get(element.after);
132
+ if (list === undefined) {
133
+ children.set(element.after, [element]);
134
+ } else {
135
+ list.push(element);
136
+ }
137
+ }
138
+ for (const list of children.values()) {
139
+ list.sort(compare);
140
+ }
141
+ const ordered = [];
142
+ const stack = [...children.get(null) ?? []].reverse();
143
+ while (stack.length > 0) {
144
+ const element = stack.pop();
145
+ ordered.push(element);
146
+ const kids = children.get(element.id);
147
+ if (kids !== undefined) {
148
+ for (let index = kids.length - 1;index >= 0; index -= 1) {
149
+ stack.push(kids[index]);
150
+ }
151
+ }
152
+ }
153
+ return ordered;
154
+ };
155
+ var textOf = (state) => linearize(state.elements).filter((element) => !element.deleted).map((element) => element.value).join("");
156
+ var mergeTextState = (a, b) => {
157
+ const byId = new Map;
158
+ for (const element of [...a.elements, ...b.elements]) {
159
+ const existing = byId.get(element.id);
160
+ byId.set(element.id, existing === undefined ? element : { ...existing, deleted: existing.deleted || element.deleted });
161
+ }
162
+ return { elements: [...byId.values()] };
163
+ };
164
+ var createTextCrdt = (replica, initial) => {
165
+ const elements = new Map;
166
+ const pending = new Map;
167
+ let clock = 0;
168
+ if (initial !== undefined) {
169
+ for (const element of initial.elements) {
170
+ elements.set(element.id, element);
171
+ clock = Math.max(clock, element.clock);
172
+ }
173
+ }
174
+ const visible = () => linearize([...elements.values()]).filter((element) => !element.deleted);
175
+ const insert = (index, value) => {
176
+ const seen = visible();
177
+ let after = index <= 0 ? null : seen[index - 1]?.id ?? null;
178
+ for (const char of [...value]) {
179
+ clock += 1;
180
+ const element = {
181
+ id: `${replica}:${clock}`,
182
+ replica,
183
+ clock,
184
+ after,
185
+ value: char,
186
+ deleted: false
187
+ };
188
+ elements.set(element.id, element);
189
+ pending.set(element.id, element);
190
+ after = element.id;
191
+ }
192
+ };
193
+ const remove = (index, count) => {
194
+ const seen = visible();
195
+ for (let offset = 0;offset < count; offset += 1) {
196
+ const target = seen[index + offset];
197
+ if (target !== undefined) {
198
+ const tombstoned = { ...target, deleted: true };
199
+ elements.set(target.id, tombstoned);
200
+ pending.set(target.id, tombstoned);
201
+ }
202
+ }
203
+ };
204
+ return {
205
+ text: () => textOf({ elements: [...elements.values()] }),
206
+ insert,
207
+ delete: remove,
208
+ merge: (state) => {
209
+ for (const element of state.elements) {
210
+ const existing = elements.get(element.id);
211
+ elements.set(element.id, existing === undefined ? element : {
212
+ ...existing,
213
+ deleted: existing.deleted || element.deleted
214
+ });
215
+ clock = Math.max(clock, element.clock);
216
+ }
217
+ },
218
+ setText: (next) => {
219
+ const current = textOf({ elements: [...elements.values()] });
220
+ if (current === next) {
221
+ return;
222
+ }
223
+ let prefix = 0;
224
+ const maxPrefix = Math.min(current.length, next.length);
225
+ while (prefix < maxPrefix && current[prefix] === next[prefix]) {
226
+ prefix += 1;
227
+ }
228
+ let suffix = 0;
229
+ while (suffix < maxPrefix - prefix && current[current.length - 1 - suffix] === next[next.length - 1 - suffix]) {
230
+ suffix += 1;
231
+ }
232
+ const removed = current.length - prefix - suffix;
233
+ if (removed > 0) {
234
+ remove(prefix, removed);
235
+ }
236
+ const inserted = next.slice(prefix, next.length - suffix);
237
+ if (inserted.length > 0) {
238
+ insert(prefix, inserted);
239
+ }
240
+ },
241
+ state: () => ({ elements: [...elements.values()] }),
242
+ takeDelta: () => {
243
+ const delta = { elements: [...pending.values()] };
244
+ pending.clear();
245
+ return delta;
246
+ }
247
+ };
248
+ };
249
+ var rgaText = {
250
+ create: createTextCrdt,
251
+ empty: () => ({ elements: [] }),
252
+ merge: mergeTextState,
253
+ textOf
254
+ };
255
+
71
256
  // src/angular/sync-collection.service.ts
72
257
  import { computed, Injectable, signal } from "@angular/core";
73
258
 
@@ -390,6 +575,65 @@ var createSyncCollection = (options) => {
390
575
  };
391
576
  };
392
577
 
578
+ // src/client/collaborativeText.ts
579
+ var createCollaborativeText = (options) => {
580
+ const keyField = options.keyField ?? "id";
581
+ const mutation = options.mutation ?? `${options.collection}:merge`;
582
+ const replica = options.replica ?? globalThis.crypto.randomUUID();
583
+ const make = options.create ?? ((id) => createTextCrdt(id));
584
+ const crdt = make(replica);
585
+ let current = { status: "connecting", text: "" };
586
+ const subscribers = new Set;
587
+ const emit = () => {
588
+ for (const run of subscribers) {
589
+ run(current);
590
+ }
591
+ };
592
+ const collection = createSyncCollection({
593
+ collection: options.collection,
594
+ key: (row) => row[keyField],
595
+ url: options.url
596
+ });
597
+ const apply = (state) => {
598
+ let { text } = current;
599
+ const row = state.data.find((candidate) => candidate[keyField] === options.id);
600
+ const fieldState = row?.[options.field];
601
+ if (fieldState !== undefined) {
602
+ crdt.merge(fieldState);
603
+ text = crdt.text();
604
+ }
605
+ current = { status: state.status, text };
606
+ emit();
607
+ };
608
+ apply(collection.get());
609
+ const unsubscribe = collection.subscribe(apply);
610
+ return {
611
+ get: () => current,
612
+ subscribe(run) {
613
+ subscribers.add(run);
614
+ run(current);
615
+ return () => {
616
+ subscribers.delete(run);
617
+ };
618
+ },
619
+ setText(next) {
620
+ crdt.setText(next);
621
+ current = { status: current.status, text: next };
622
+ emit();
623
+ const payload = crdt.takeDelta ? crdt.takeDelta() : crdt.state();
624
+ collection.mutate({
625
+ args: { [keyField]: options.id, [options.field]: payload },
626
+ name: mutation
627
+ });
628
+ },
629
+ close() {
630
+ unsubscribe();
631
+ collection.close();
632
+ subscribers.clear();
633
+ }
634
+ };
635
+ };
636
+
393
637
  // src/angular/sync-collection.service.ts
394
638
  var _dec = [
395
639
  Injectable({ providedIn: "root" })
@@ -398,6 +642,7 @@ var _init = __decoratorStart(undefined);
398
642
 
399
643
  class SyncCollectionService {
400
644
  collections = new Set;
645
+ texts = new Set;
401
646
  connect(options) {
402
647
  const data = signal([]);
403
648
  const status = signal("connecting");
@@ -422,11 +667,34 @@ class SyncCollectionService {
422
667
  status: computed(() => status())
423
668
  };
424
669
  }
670
+ collaborativeText(options) {
671
+ const text = signal("");
672
+ const status = signal("connecting");
673
+ let controller = null;
674
+ if (typeof window !== "undefined") {
675
+ controller = createCollaborativeText(options);
676
+ this.texts.add(controller);
677
+ controller.subscribe((state) => {
678
+ text.set(state.text);
679
+ status.set(state.status);
680
+ });
681
+ }
682
+ const setText = (next) => controller?.setText(next);
683
+ return {
684
+ setText,
685
+ status: computed(() => status()),
686
+ text: computed(() => text())
687
+ };
688
+ }
425
689
  ngOnDestroy() {
426
690
  for (const collection of this.collections) {
427
691
  collection.close();
428
692
  }
429
693
  this.collections.clear();
694
+ for (const text of this.texts) {
695
+ text.close();
696
+ }
697
+ this.texts.clear();
430
698
  }
431
699
  }
432
700
  SyncCollectionService = __decorateElement(_init, 0, "SyncCollectionService", _dec, SyncCollectionService);
@@ -437,5 +705,5 @@ export {
437
705
  SyncCollectionService
438
706
  };
439
707
 
440
- //# debugId=C534ED0FEAC9CC6664756E2164756E21
708
+ //# debugId=248B59EB4C19056664756E2164756E21
441
709
  //# sourceMappingURL=index.js.map