@super-line/server 0.10.1 → 0.11.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/dist/index.js CHANGED
@@ -2,7 +2,12 @@
2
2
  import {
3
3
  jsonSerializer,
4
4
  validate,
5
- SuperLineError
5
+ validateSync,
6
+ SuperLineError,
7
+ andFilters,
8
+ orFilters,
9
+ matchesFilter,
10
+ isCrdtCollection
6
11
  } from "@super-line/core";
7
12
 
8
13
  // src/conn.ts
@@ -181,27 +186,43 @@ var CONN = "c:";
181
186
  var USER = "u:";
182
187
  var REPLY = "reply:";
183
188
  var PLUGIN = "x:";
184
- var STORE = "s:";
189
+ var CDOC = "d:";
185
190
  var SERVER_ORIGIN = "server";
186
191
  function createSuperLineServer(contract, opts) {
187
192
  const c = contract;
188
193
  const serializer = opts.serializer ?? jsonSerializer;
189
194
  const adapter = opts.adapter ?? createInMemoryAdapter();
190
- const storeMap = opts.stores ?? {};
191
195
  const plugins = opts.plugins ?? [];
192
196
  const pluginNames = /* @__PURE__ */ new Set();
193
197
  for (const p of plugins) {
194
198
  if (pluginNames.has(p.name)) throw new Error(`Duplicate plugin name: ${p.name}`);
195
199
  pluginNames.add(p.name);
196
200
  }
201
+ const collectionStore = opts.collections;
202
+ const collectionDefs = c.collections ?? {};
203
+ const collectionPolicies = { ...opts.policies };
197
204
  for (const p of plugins) {
198
- if (!p.stores) continue;
199
- for (const [name, store] of Object.entries(p.stores)) {
200
- if (name in storeMap)
201
- throw new Error(`Plugin '${p.name}' store '${name}' collides with an existing store of that name`);
202
- storeMap[name] = store;
205
+ if (!p.policies) continue;
206
+ for (const [name, policy] of Object.entries(p.policies)) {
207
+ if (!(name in collectionDefs))
208
+ throw new Error(
209
+ `Plugin '${p.name}' policy references unknown collection '${name}' \u2014 register its contract fragment on defineContract({ plugins })`
210
+ );
211
+ if (name in collectionPolicies)
212
+ throw new Error(`Plugin '${p.name}' policy for collection '${name}' collides with an existing policy`);
213
+ collectionPolicies[name] = policy;
203
214
  }
204
215
  }
216
+ const checkReferences = opts.checkReferences ?? false;
217
+ const collIsRelay = collectionStore?.clustering === "relay";
218
+ const crdtStore = opts.crdtCollections;
219
+ const crdtDefOf = (n) => {
220
+ const d = collectionDefs[n];
221
+ return d && isCrdtCollection(d) ? d : void 0;
222
+ };
223
+ const COLL_CHANNEL = "cbatch";
224
+ const collSubscribers = /* @__PURE__ */ new Map();
225
+ const connColl = /* @__PURE__ */ new Map();
205
226
  const reserved = [];
206
227
  for (const p of plugins) {
207
228
  if (!p.connection) continue;
@@ -295,8 +316,12 @@ function createSuperLineServer(contract, opts) {
295
316
  handlePersonal(channel, payload);
296
317
  return;
297
318
  }
298
- if (channel.startsWith(STORE)) {
299
- handleStoreRelay(channel, payload);
319
+ if (channel.startsWith(CDOC)) {
320
+ handleCrdtRelay(channel, payload);
321
+ return;
322
+ }
323
+ if (channel === COLL_CHANNEL) {
324
+ handleCollectionRelay(payload);
300
325
  return;
301
326
  }
302
327
  if (channel.startsWith(PLUGIN)) {
@@ -309,6 +334,7 @@ function createSuperLineServer(contract, opts) {
309
334
  if (busSet) deliverBus(payload, busSet);
310
335
  });
311
336
  void adapter.subscribe(replyChannel);
337
+ if (collectionStore && collIsRelay) void adapter.subscribe(COLL_CHANNEL);
312
338
  function handlePersonal(channel, payload) {
313
339
  const set = members.get(channel);
314
340
  if (!set) return;
@@ -542,6 +568,7 @@ function createSuperLineServer(contract, opts) {
542
568
  raw.onClose((code) => {
543
569
  conns.delete(conn);
544
570
  for (const channel of conn.channels) leaveChannel(conn, channel);
571
+ collUnsubAll(conn);
545
572
  void adapter.presence?.del(conn.id);
546
573
  const goneUserId = opts.identify?.(conn);
547
574
  emitTap({
@@ -576,10 +603,12 @@ function createSuperLineServer(contract, opts) {
576
603
  else if (frame.t === "unsub") {
577
604
  const ns = topicNamespace(conn.role, frame.c);
578
605
  if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
579
- } else if (frame.t === "sopen") await handleStoreOpen(conn, frame);
580
- else if (frame.t === "srd") await handleStoreRead(conn, frame);
581
- else if (frame.t === "swr") await handleStoreWrite(conn, frame);
582
- else if (frame.t === "sclose") handleStoreClose(conn, frame);
606
+ } else if (frame.t === "csub") await handleCollectionSub(conn, frame);
607
+ else if (frame.t === "cuns") handleCollectionUnsub(conn, frame);
608
+ else if (frame.t === "cbat") await handleCollectionBatch(conn, frame);
609
+ else if (frame.t === "cdopen") await handleCrdtOpen(conn, frame);
610
+ else if (frame.t === "cdwr") await handleCrdtWrite(conn, frame);
611
+ else if (frame.t === "cdclose") handleCrdtClose(conn, frame);
583
612
  else if (frame.t === "sres") {
584
613
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
585
614
  } else if (frame.t === "serr") {
@@ -672,90 +701,77 @@ function createSuperLineServer(contract, opts) {
672
701
  conn.send({ t: "res", i: frame.i, d: null });
673
702
  });
674
703
  }
675
- function storeOrErr(conn, id, name) {
676
- const store = storeMap[name];
677
- if (!store) conn.send({ t: "err", i: id, code: "NOT_FOUND", m: `Unknown store: ${name}` });
678
- return store;
679
- }
680
- async function handleStoreOpen(conn, frame) {
681
- const store = storeOrErr(conn, frame.i, frame.n);
682
- if (!store) return;
683
- await dispatchOp(conn, frame.i, { kind: "subscribe", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
684
- const resource = await store.read(frame.id);
685
- if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
704
+ function crdtMissing(conn, i, n) {
705
+ if (crdtStore && crdtDefOf(n)) return false;
706
+ conn.send({ t: "err", i, code: "NOT_FOUND", m: `Unknown CRDT collection: ${n}` });
707
+ return true;
708
+ }
709
+ async function handleCrdtOpen(conn, frame) {
710
+ if (crdtMissing(conn, frame.i, frame.n)) return;
711
+ const store = crdtStore;
712
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `collection:${frame.n}/${frame.id}`, conn }, async () => {
713
+ const state = await store.read(frame.n, frame.id);
714
+ if (state === void 0) throw new SuperLineError("NOT_FOUND", `No document: ${frame.n}/${frame.id}`);
686
715
  const principal = conn.principal ?? conn.id;
687
- if (!resource.accessRules[principal]?.read)
716
+ const policy = collectionPolicies[frame.n];
717
+ if (!policy?.read) throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
718
+ const replica = store.open(frame.n, frame.id);
719
+ const snapshot = replica.getSnapshot();
720
+ replica.close();
721
+ if (!await policy.read(principal, frame.id, snapshot, conn.ctx))
688
722
  throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
689
- await joinChannel(conn, STORE + frame.n + ":" + frame.id);
690
- if (taps.length) emitTap({ type: "store.subscribe", connId: conn.id, store: frame.n, id: frame.id });
691
- conn.send({ t: "res", i: frame.i, d: resource.data });
723
+ await joinChannel(conn, CDOC + frame.n + ":" + frame.id);
724
+ conn.send({ t: "res", i: frame.i, d: state });
692
725
  });
693
726
  }
694
- async function handleStoreRead(conn, frame) {
695
- const store = storeOrErr(conn, frame.i, frame.n);
696
- if (!store) return;
697
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
698
- const resource = await store.read(frame.id);
699
- if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
727
+ async function handleCrdtWrite(conn, frame) {
728
+ if (crdtMissing(conn, frame.i, frame.n)) return;
729
+ const store = crdtStore;
730
+ const def = crdtDefOf(frame.n);
731
+ await dispatchOp(conn, frame.i, { kind: "request", name: `collection:${frame.n}/${frame.id}`, conn }, async () => {
700
732
  const principal = conn.principal ?? conn.id;
701
- if (!resource.accessRules[principal]?.read)
702
- throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
703
- conn.send({ t: "res", i: frame.i, d: resource.data });
704
- });
705
- }
706
- async function handleStoreWrite(conn, frame) {
707
- const store = storeOrErr(conn, frame.i, frame.n);
708
- if (!store) return;
709
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
710
- const resource = await store.read(frame.id);
711
- if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
712
- const principal = conn.principal ?? conn.id;
713
- if (!resource.accessRules[principal]?.write)
733
+ const policy = collectionPolicies[frame.n];
734
+ if (!policy?.write) throw new SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
735
+ if (!await policy.write(principal, frame.id, conn.ctx))
714
736
  throw new SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
715
- await store.apply({ id: frame.id, update: frame.u, origin: frame.o });
716
- if (taps.length)
717
- emitTap({
718
- type: "store.write",
719
- store: frame.n,
720
- id: frame.id,
721
- origin: frame.o,
722
- connId: conn.id,
723
- data: frame.u
724
- });
737
+ await store.apply(
738
+ { n: frame.n, id: frame.id, update: frame.u, origin: frame.o },
739
+ def.crdt,
740
+ (snapshot) => validateSync(def.schema, snapshot)
741
+ );
725
742
  conn.send({ t: "res", i: frame.i, d: null });
726
743
  });
727
744
  }
728
- function handleStoreClose(conn, frame) {
729
- if (!storeMap[frame.n]) return;
730
- leaveChannel(conn, STORE + frame.n + ":" + frame.id);
731
- if (taps.length) emitTap({ type: "store.unsubscribe", connId: conn.id, store: frame.n, id: frame.id });
745
+ function handleCrdtClose(conn, frame) {
746
+ if (!crdtDefOf(frame.n)) return;
747
+ leaveChannel(conn, CDOC + frame.n + ":" + frame.id);
732
748
  }
733
- for (const [name, store] of Object.entries(storeMap)) {
734
- const isSelf = store.clustering === "self";
735
- store.onChange((change) => {
736
- const channel = STORE + name + ":" + change.id;
749
+ if (crdtStore) {
750
+ const isSelf = crdtStore.clustering === "self";
751
+ crdtStore.onChange((change) => {
752
+ const channel = CDOC + change.n + ":" + change.id;
737
753
  if (isSelf) {
738
754
  const set = members.get(channel);
739
755
  if (!set) return;
740
- const payload = serializer.encode({ t: "sch", n: name, id: change.id, u: change.update, o: change.origin });
756
+ const payload = serializer.encode({ t: "cdchg", n: change.n, id: change.id, u: change.update, o: change.origin });
741
757
  for (const conn of set) conn.sendRaw(payload);
742
758
  return;
743
759
  }
744
760
  if (relaying) return;
745
761
  void adapter.publish(
746
762
  channel,
747
- serializer.encode({ t: "sch", n: name, id: change.id, u: change.update, o: change.origin, nd: instanceId })
763
+ serializer.encode({ t: "cdchg", n: change.n, id: change.id, u: change.update, o: change.origin, nd: instanceId })
748
764
  );
749
765
  });
750
766
  if (isSelf)
751
- store.onDelete?.((id) => {
752
- const set = members.get(STORE + name + ":" + id);
767
+ crdtStore.onDelete?.((n, id) => {
768
+ const set = members.get(CDOC + n + ":" + id);
753
769
  if (!set) return;
754
- const payload = serializer.encode({ t: "sdel", n: name, id });
770
+ const payload = serializer.encode({ t: "cddel", n, id });
755
771
  for (const conn of set) conn.sendRaw(payload);
756
772
  });
757
773
  }
758
- function handleStoreRelay(channel, payload) {
774
+ function handleCrdtRelay(channel, payload) {
759
775
  const set = members.get(channel);
760
776
  if (set) for (const conn of set) conn.sendRaw(payload);
761
777
  let frame;
@@ -765,20 +781,153 @@ function createSuperLineServer(contract, opts) {
765
781
  return;
766
782
  }
767
783
  if (frame.nd === instanceId) return;
768
- const store = storeMap[frame.n];
769
- if (!store || store.clustering !== "relay") return;
770
- if (frame.t === "sdel") {
771
- void store.delete(frame.id);
784
+ if (!crdtStore || crdtStore.clustering !== "relay") return;
785
+ const def = crdtDefOf(frame.n);
786
+ if (!def) return;
787
+ if (frame.t === "cddel") {
788
+ try {
789
+ void crdtStore.delete(frame.n, frame.id);
790
+ } catch {
791
+ }
772
792
  return;
773
793
  }
774
794
  relaying = true;
775
795
  try {
776
- void store.apply({ id: frame.id, update: frame.u, origin: frame.o });
796
+ void crdtStore.apply({ n: frame.n, id: frame.id, update: frame.u, origin: frame.o }, def.crdt, () => {
797
+ });
777
798
  } catch {
778
799
  } finally {
779
800
  relaying = false;
780
801
  }
781
802
  }
803
+ const collConnState = (conn) => {
804
+ let s = connColl.get(conn);
805
+ if (!s) connColl.set(conn, s = { subs: /* @__PURE__ */ new Map(), policy: /* @__PURE__ */ new Map() });
806
+ return s;
807
+ };
808
+ function collUnsubAll(conn) {
809
+ connColl.delete(conn);
810
+ for (const set of collSubscribers.values()) set.delete(conn);
811
+ }
812
+ async function handleCollectionSub(conn, frame) {
813
+ if (!collectionStore || !collectionDefs[frame.n]) {
814
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown collection: ${frame.n}` });
815
+ return;
816
+ }
817
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `collection:${frame.n}`, conn }, async () => {
818
+ const principal = conn.principal ?? conn.id;
819
+ const policy = collectionPolicies[frame.n];
820
+ if (!policy?.read) throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}`);
821
+ const policyFilter = await policy.read(principal, conn.ctx);
822
+ const eff = andFilters(policyFilter, frame.q.filter);
823
+ const rows = await collectionStore.snapshot(frame.n, { ...frame.q, filter: eff });
824
+ const state = collConnState(conn);
825
+ let subs = state.subs.get(frame.n);
826
+ if (!subs) state.subs.set(frame.n, subs = /* @__PURE__ */ new Map());
827
+ subs.set(frame.s, frame.q);
828
+ state.policy.set(frame.n, policyFilter);
829
+ let set = collSubscribers.get(frame.n);
830
+ if (!set) collSubscribers.set(frame.n, set = /* @__PURE__ */ new Set());
831
+ set.add(conn);
832
+ conn.send({ t: "res", i: frame.i, d: rows });
833
+ });
834
+ }
835
+ function handleCollectionUnsub(conn, frame) {
836
+ const state = connColl.get(conn);
837
+ const subs = state?.subs.get(frame.n);
838
+ if (!state || !subs) return;
839
+ subs.delete(frame.s);
840
+ if (subs.size === 0) {
841
+ state.subs.delete(frame.n);
842
+ state.policy.delete(frame.n);
843
+ collSubscribers.get(frame.n)?.delete(conn);
844
+ }
845
+ }
846
+ async function resolveCollectionOps(ops, principal, ctx) {
847
+ const store = collectionStore;
848
+ if (!store) throw new SuperLineError("NOT_FOUND", "No collection backend configured");
849
+ const out = [];
850
+ for (const op of ops) {
851
+ const def = collectionDefs[op.n];
852
+ if (!def) throw new SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
853
+ if (isCrdtCollection(def)) throw new SuperLineError("NOT_FOUND", `Collection ${op.n} is a CRDT document collection \u2014 use collection(n).open(id), not a row batch`);
854
+ const policy = collectionPolicies[op.n];
855
+ if (!policy?.write) throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}`);
856
+ const prev = await store.read(op.n, op.id);
857
+ if (op.op === "delete") {
858
+ if (!await policy.write(principal, "delete", void 0, prev, ctx))
859
+ throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
860
+ out.push({ op: "delete", n: op.n, id: op.id });
861
+ continue;
862
+ }
863
+ const row = await validate(def.schema, op.d);
864
+ const key = row[def.key];
865
+ if (typeof key !== "string")
866
+ throw new SuperLineError("VALIDATION", `Collection ${op.n} row is missing string key '${def.key}'`);
867
+ if (key !== op.id) throw new SuperLineError("VALIDATION", `Row key '${key}' does not match op id '${op.id}'`);
868
+ if (checkReferences && def.references) {
869
+ for (const [field, refCollection] of Object.entries(def.references)) {
870
+ const ref = row[field];
871
+ if (ref === void 0 || ref === null) continue;
872
+ if (await store.read(refCollection, String(ref)) === void 0)
873
+ throw new SuperLineError("VALIDATION", `Dangling reference: ${op.n}.${field} \u2192 ${refCollection}/${String(ref)} does not exist`);
874
+ }
875
+ }
876
+ const kind = op.op;
877
+ if (!await policy.write(principal, kind, row, prev, ctx))
878
+ throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
879
+ out.push({ op: kind, n: op.n, id: op.id, row });
880
+ }
881
+ return out;
882
+ }
883
+ async function commitCollectionBatch(ops, origin, relay) {
884
+ if (ops.length === 0 || !collectionStore) return;
885
+ await collectionStore.apply(ops, origin);
886
+ if (relay && collIsRelay)
887
+ void adapter.publish(COLL_CHANNEL, serializer.encode({ ops, origin, nd: instanceId }));
888
+ }
889
+ async function handleCollectionBatch(conn, frame) {
890
+ if (!collectionStore) {
891
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: "No collection backend configured" });
892
+ return;
893
+ }
894
+ await dispatchOp(conn, frame.i, { kind: "request", name: "collection:batch", conn }, async () => {
895
+ const principal = conn.principal ?? conn.id;
896
+ const resolved = await resolveCollectionOps(frame.ops, principal, conn.ctx);
897
+ await commitCollectionBatch(resolved, principal, true);
898
+ conn.send({ t: "res", i: frame.i, d: null });
899
+ });
900
+ }
901
+ function routeRowChange(change) {
902
+ const conns2 = collSubscribers.get(change.n);
903
+ if (!conns2 || conns2.size === 0) return;
904
+ for (const conn of conns2) {
905
+ const state = connColl.get(conn);
906
+ const subs = state?.subs.get(change.n);
907
+ if (!state || !subs || subs.size === 0) continue;
908
+ const eff = andFilters(state.policy.get(change.n), orFilters([...subs.values()].map((q) => q.filter)));
909
+ const prevlessDelete = change.k === "delete" && change.prev === void 0;
910
+ const inPrev = prevlessDelete || change.prev !== void 0 && matchesFilter(eff, change.prev);
911
+ const inNext = change.next !== void 0 && matchesFilter(eff, change.next);
912
+ if (!inPrev && !inNext) continue;
913
+ conn.send({ t: "cchg", n: change.n, k: change.k, id: change.id, d: change.next });
914
+ }
915
+ }
916
+ function handleCollectionRelay(payload) {
917
+ let env;
918
+ try {
919
+ env = serializer.decode(payload);
920
+ } catch {
921
+ return;
922
+ }
923
+ if (env.nd === instanceId) return;
924
+ if (!collectionStore || !collIsRelay) return;
925
+ try {
926
+ void collectionStore.apply(env.ops, env.origin);
927
+ } catch {
928
+ }
929
+ }
930
+ if (collectionStore) collectionStore.onChange(routeRowChange);
782
931
  function room(name) {
783
932
  const channel = ROOM + name;
784
933
  return {
@@ -923,78 +1072,6 @@ function createSuperLineServer(contract, opts) {
923
1072
  void adapter.publish(CONN + id, serializer.encode(env));
924
1073
  });
925
1074
  }
926
- const storeApi = {};
927
- for (const [name, store] of Object.entries(storeMap)) {
928
- const readOrThrow = async (id) => {
929
- const r = await store.read(id);
930
- if (!r) throw new SuperLineError("NOT_FOUND", `No resource: ${name}/${id}`);
931
- return r;
932
- };
933
- storeApi[name] = {
934
- async create(id, data, accessRules) {
935
- await store.create(id, data, accessRules);
936
- if (taps.length) emitTap({ type: "store.create", store: name, id });
937
- },
938
- read(id) {
939
- return Promise.resolve(store.read(id));
940
- },
941
- async write(id, data, opts2) {
942
- const origin = opts2?.origin ?? SERVER_ORIGIN;
943
- await store.apply({ id, update: data, origin });
944
- if (taps.length) emitTap({ type: "store.write", store: name, id, origin, data });
945
- },
946
- async grant(id, principal, perms) {
947
- const r = await readOrThrow(id);
948
- await store.setAccess(id, { ...r.accessRules, [principal]: perms });
949
- if (taps.length) emitTap({ type: "store.grant", store: name, id, principal, perms });
950
- },
951
- async revoke(id, principal) {
952
- const r = await readOrThrow(id);
953
- const next = { ...r.accessRules };
954
- delete next[principal];
955
- await store.setAccess(id, next);
956
- if (taps.length) emitTap({ type: "store.revoke", store: name, id, principal });
957
- },
958
- async delete(id) {
959
- await store.delete(id);
960
- if (store.clustering !== "self")
961
- void adapter.publish(
962
- STORE + name + ":" + id,
963
- serializer.encode({ t: "sdel", n: name, id, nd: instanceId })
964
- );
965
- if (taps.length) emitTap({ type: "store.delete", store: name, id });
966
- },
967
- list(opts2) {
968
- return Promise.resolve(store.list(opts2));
969
- },
970
- searchPrincipals(opts2) {
971
- return Promise.resolve(store.searchPrincipals(opts2));
972
- },
973
- open(id, openOpts) {
974
- if (!store.open)
975
- throw new SuperLineError("UNSUPPORTED", `Store ${name} does not support reactive open()`);
976
- const replica = store.open(id, openOpts);
977
- if (!taps.length) return replica;
978
- const origin = openOpts?.origin ?? SERVER_ORIGIN;
979
- const emit = (data) => emitTap({ type: "store.write", store: name, id, origin, data });
980
- return {
981
- ...replica,
982
- set: (d) => {
983
- replica.set(d);
984
- emit(d);
985
- },
986
- update: (p) => {
987
- replica.update(p);
988
- emit(p);
989
- },
990
- delete: (path) => {
991
- replica.delete(path);
992
- emit({ delete: path });
993
- }
994
- };
995
- }
996
- };
997
- }
998
1075
  const pluginDisposers = [];
999
1076
  const pluginHandlers = {};
1000
1077
  const api = {
@@ -1095,9 +1172,67 @@ function createSuperLineServer(contract, opts) {
1095
1172
  }
1096
1173
  };
1097
1174
  },
1098
- store(name) {
1099
- const handle = storeApi[name];
1100
- if (!handle) throw new SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1175
+ collection(name) {
1176
+ const def = collectionDefs[name];
1177
+ if (!def) throw new SuperLineError("NOT_FOUND", `Collection not declared: ${name}`);
1178
+ if (isCrdtCollection(def)) {
1179
+ if (!crdtStore) throw new SuperLineError("NOT_FOUND", "No CRDT collection backend configured");
1180
+ const cstore = crdtStore;
1181
+ const handle2 = {
1182
+ async create(id, data) {
1183
+ const v = await validate(def.schema, data);
1184
+ await cstore.create(name, id, v, def.crdt);
1185
+ },
1186
+ open(id, o) {
1187
+ return cstore.open(name, id, { ...o, doc: def.crdt });
1188
+ },
1189
+ async read(id) {
1190
+ const state = await cstore.read(name, id);
1191
+ if (state === void 0) return void 0;
1192
+ const r = cstore.open(name, id, { doc: def.crdt });
1193
+ const s = r.getSnapshot();
1194
+ r.close();
1195
+ return s;
1196
+ },
1197
+ async delete(id) {
1198
+ await cstore.delete(name, id);
1199
+ if (cstore.clustering !== "self")
1200
+ void adapter.publish(CDOC + name + ":" + id, serializer.encode({ t: "cddel", n: name, id, nd: instanceId }));
1201
+ },
1202
+ list(o) {
1203
+ return Promise.resolve(cstore.list(name, o));
1204
+ }
1205
+ };
1206
+ return handle2;
1207
+ }
1208
+ if (!collectionStore) throw new SuperLineError("NOT_FOUND", "No collection backend configured");
1209
+ const store = collectionStore;
1210
+ const resolve = async (row) => {
1211
+ const v = await validate(def.schema, row);
1212
+ const key = v[def.key];
1213
+ if (typeof key !== "string")
1214
+ throw new SuperLineError("VALIDATION", `Collection ${name} row is missing string key '${def.key}'`);
1215
+ return { id: key, row: v };
1216
+ };
1217
+ const handle = {
1218
+ async insert(row) {
1219
+ const { id, row: v } = await resolve(row);
1220
+ await commitCollectionBatch([{ op: "insert", n: name, id, row: v }], SERVER_ORIGIN, true);
1221
+ },
1222
+ async update(row) {
1223
+ const { id, row: v } = await resolve(row);
1224
+ await commitCollectionBatch([{ op: "update", n: name, id, row: v }], SERVER_ORIGIN, true);
1225
+ },
1226
+ async delete(id) {
1227
+ await commitCollectionBatch([{ op: "delete", n: name, id }], SERVER_ORIGIN, true);
1228
+ },
1229
+ read(id) {
1230
+ return Promise.resolve(store.read(name, id));
1231
+ },
1232
+ snapshot(query) {
1233
+ return Promise.resolve(store.snapshot(name, query ?? {}));
1234
+ }
1235
+ };
1101
1236
  return handle;
1102
1237
  },
1103
1238
  async close() {
@@ -1156,8 +1291,14 @@ function createSuperLineServer(contract, opts) {
1156
1291
  }
1157
1292
  };
1158
1293
  },
1159
- store: (name) => api.store(name),
1160
- storeInfos: () => Object.entries(storeMap).map(([name, store]) => ({ name, model: store.model })),
1294
+ collection: (name) => api.collection(name),
1295
+ collectionInfos: () => (
1296
+ // CRDT document collections surface with a synthetic `id` key + no references — the inspector's
1297
+ // queryCollection synthesizes doc-rows for them (they're open-by-id, not row-queryable).
1298
+ Object.entries(collectionDefs).map(
1299
+ ([name, def]) => isCrdtCollection(def) ? { name, key: "id", references: {} } : { name, key: def.key, references: def.references ?? {} }
1300
+ )
1301
+ ),
1161
1302
  describe: (conn) => buildDescriptor(conn),
1162
1303
  connectionById: (id) => Promise.resolve(presenceOrThrow().get(id)),
1163
1304
  channel: (name) => pluginChannel(pluginName, name)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@super-line/server",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
- "description": "Realtime data-bus server for super-line — requests, events, rooms, topics, stores, pluggable transports & adapters.",
5
+ "description": "Realtime data-bus server for super-line — requests, events, rooms, topics, collections, pluggable transports & adapters.",
6
6
  "license": "MIT",
7
7
  "author": "Mert",
8
8
  "keywords": [
@@ -47,7 +47,7 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@super-line/core": "^0.10.0"
50
+ "@super-line/core": "^0.11.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@chainsafe/libp2p-noise": "^17.0.0",
@@ -68,15 +68,15 @@
68
68
  "zeromq": "^6.0.0",
69
69
  "zod": "^3.24.1",
70
70
  "zod-to-json-schema": "^3.25.2",
71
- "@super-line/client": "0.8.0",
72
71
  "@super-line/adapter-zeromq": "0.5.0",
73
- "@super-line/adapter-rabbitmq": "0.5.0",
74
72
  "@super-line/adapter-redis": "0.5.0",
75
- "@super-line/plugin-inspector": "0.1.0",
76
- "@super-line/transport-http": "0.5.0",
77
- "@super-line/transport-websocket": "0.6.0",
73
+ "@super-line/adapter-libp2p": "0.6.0",
74
+ "@super-line/client": "0.9.0",
75
+ "@super-line/plugin-inspector": "0.2.0",
76
+ "@super-line/adapter-rabbitmq": "0.5.0",
78
77
  "@super-line/transport-loopback": "0.5.0",
79
- "@super-line/adapter-libp2p": "0.6.0"
78
+ "@super-line/transport-http": "0.5.0",
79
+ "@super-line/transport-websocket": "0.6.0"
80
80
  },
81
81
  "optionalDependencies": {
82
82
  "@standard-community/standard-json": "^0.3.5"