@super-line/server 0.10.2 → 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/README.md CHANGED
@@ -76,7 +76,7 @@ replica.delete('title') // surgical key delete (the only way to remove
76
76
  replica.close()
77
77
  ```
78
78
 
79
- `open` requires a store with reactive support (throws `UNSUPPORTED` otherwise). CRDT stores (`store-sync`, `store-sync-libsql`, `store-sync-pglite`) and the LWW stores both back it.
79
+ `open` requires a store with reactive support (throws `UNSUPPORTED` otherwise). CRDT stores (`store-sync`, `store-sync-pglite`) and the LWW stores both back it.
80
80
 
81
81
  ### Control Center inspector
82
82
 
package/dist/index.cjs CHANGED
@@ -205,27 +205,18 @@ var CONN = "c:";
205
205
  var USER = "u:";
206
206
  var REPLY = "reply:";
207
207
  var PLUGIN = "x:";
208
- var STORE = "s:";
208
+ var CDOC = "d:";
209
209
  var SERVER_ORIGIN = "server";
210
210
  function createSuperLineServer(contract, opts) {
211
211
  const c = contract;
212
212
  const serializer = opts.serializer ?? import_core.jsonSerializer;
213
213
  const adapter = opts.adapter ?? createInMemoryAdapter();
214
- const storeMap = opts.stores ?? {};
215
214
  const plugins = opts.plugins ?? [];
216
215
  const pluginNames = /* @__PURE__ */ new Set();
217
216
  for (const p of plugins) {
218
217
  if (pluginNames.has(p.name)) throw new Error(`Duplicate plugin name: ${p.name}`);
219
218
  pluginNames.add(p.name);
220
219
  }
221
- for (const p of plugins) {
222
- if (!p.stores) continue;
223
- for (const [name, store] of Object.entries(p.stores)) {
224
- if (name in storeMap)
225
- throw new Error(`Plugin '${p.name}' store '${name}' collides with an existing store of that name`);
226
- storeMap[name] = store;
227
- }
228
- }
229
220
  const collectionStore = opts.collections;
230
221
  const collectionDefs = c.collections ?? {};
231
222
  const collectionPolicies = { ...opts.policies };
@@ -243,6 +234,11 @@ function createSuperLineServer(contract, opts) {
243
234
  }
244
235
  const checkReferences = opts.checkReferences ?? false;
245
236
  const collIsRelay = collectionStore?.clustering === "relay";
237
+ const crdtStore = opts.crdtCollections;
238
+ const crdtDefOf = (n) => {
239
+ const d = collectionDefs[n];
240
+ return d && (0, import_core.isCrdtCollection)(d) ? d : void 0;
241
+ };
246
242
  const COLL_CHANNEL = "cbatch";
247
243
  const collSubscribers = /* @__PURE__ */ new Map();
248
244
  const connColl = /* @__PURE__ */ new Map();
@@ -339,8 +335,8 @@ function createSuperLineServer(contract, opts) {
339
335
  handlePersonal(channel, payload);
340
336
  return;
341
337
  }
342
- if (channel.startsWith(STORE)) {
343
- handleStoreRelay(channel, payload);
338
+ if (channel.startsWith(CDOC)) {
339
+ handleCrdtRelay(channel, payload);
344
340
  return;
345
341
  }
346
342
  if (channel === COLL_CHANNEL) {
@@ -626,13 +622,12 @@ function createSuperLineServer(contract, opts) {
626
622
  else if (frame.t === "unsub") {
627
623
  const ns = topicNamespace(conn.role, frame.c);
628
624
  if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
629
- } else if (frame.t === "sopen") await handleStoreOpen(conn, frame);
630
- else if (frame.t === "srd") await handleStoreRead(conn, frame);
631
- else if (frame.t === "swr") await handleStoreWrite(conn, frame);
632
- else if (frame.t === "sclose") handleStoreClose(conn, frame);
633
- else if (frame.t === "csub") await handleCollectionSub(conn, frame);
625
+ } else if (frame.t === "csub") await handleCollectionSub(conn, frame);
634
626
  else if (frame.t === "cuns") handleCollectionUnsub(conn, frame);
635
627
  else if (frame.t === "cbat") await handleCollectionBatch(conn, frame);
628
+ else if (frame.t === "cdopen") await handleCrdtOpen(conn, frame);
629
+ else if (frame.t === "cdwr") await handleCrdtWrite(conn, frame);
630
+ else if (frame.t === "cdclose") handleCrdtClose(conn, frame);
636
631
  else if (frame.t === "sres") {
637
632
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
638
633
  } else if (frame.t === "serr") {
@@ -725,90 +720,77 @@ function createSuperLineServer(contract, opts) {
725
720
  conn.send({ t: "res", i: frame.i, d: null });
726
721
  });
727
722
  }
728
- function storeOrErr(conn, id, name) {
729
- const store = storeMap[name];
730
- if (!store) conn.send({ t: "err", i: id, code: "NOT_FOUND", m: `Unknown store: ${name}` });
731
- return store;
732
- }
733
- async function handleStoreOpen(conn, frame) {
734
- const store = storeOrErr(conn, frame.i, frame.n);
735
- if (!store) return;
736
- await dispatchOp(conn, frame.i, { kind: "subscribe", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
737
- const resource = await store.read(frame.id);
738
- if (!resource) throw new import_core.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
723
+ function crdtMissing(conn, i, n) {
724
+ if (crdtStore && crdtDefOf(n)) return false;
725
+ conn.send({ t: "err", i, code: "NOT_FOUND", m: `Unknown CRDT collection: ${n}` });
726
+ return true;
727
+ }
728
+ async function handleCrdtOpen(conn, frame) {
729
+ if (crdtMissing(conn, frame.i, frame.n)) return;
730
+ const store = crdtStore;
731
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `collection:${frame.n}/${frame.id}`, conn }, async () => {
732
+ const state = await store.read(frame.n, frame.id);
733
+ if (state === void 0) throw new import_core.SuperLineError("NOT_FOUND", `No document: ${frame.n}/${frame.id}`);
739
734
  const principal = conn.principal ?? conn.id;
740
- if (!resource.accessRules[principal]?.read)
741
- throw new import_core.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
742
- await joinChannel(conn, STORE + frame.n + ":" + frame.id);
743
- if (taps.length) emitTap({ type: "store.subscribe", connId: conn.id, store: frame.n, id: frame.id });
744
- conn.send({ t: "res", i: frame.i, d: resource.data });
745
- });
746
- }
747
- async function handleStoreRead(conn, frame) {
748
- const store = storeOrErr(conn, frame.i, frame.n);
749
- if (!store) return;
750
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
751
- const resource = await store.read(frame.id);
752
- if (!resource) throw new import_core.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
753
- const principal = conn.principal ?? conn.id;
754
- if (!resource.accessRules[principal]?.read)
735
+ const policy = collectionPolicies[frame.n];
736
+ if (!policy?.read) throw new import_core.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
737
+ const replica = store.open(frame.n, frame.id);
738
+ const snapshot = replica.getSnapshot();
739
+ replica.close();
740
+ if (!await policy.read(principal, frame.id, snapshot, conn.ctx))
755
741
  throw new import_core.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
756
- conn.send({ t: "res", i: frame.i, d: resource.data });
742
+ await joinChannel(conn, CDOC + frame.n + ":" + frame.id);
743
+ conn.send({ t: "res", i: frame.i, d: state });
757
744
  });
758
745
  }
759
- async function handleStoreWrite(conn, frame) {
760
- const store = storeOrErr(conn, frame.i, frame.n);
761
- if (!store) return;
762
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
763
- const resource = await store.read(frame.id);
764
- if (!resource) throw new import_core.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
746
+ async function handleCrdtWrite(conn, frame) {
747
+ if (crdtMissing(conn, frame.i, frame.n)) return;
748
+ const store = crdtStore;
749
+ const def = crdtDefOf(frame.n);
750
+ await dispatchOp(conn, frame.i, { kind: "request", name: `collection:${frame.n}/${frame.id}`, conn }, async () => {
765
751
  const principal = conn.principal ?? conn.id;
766
- if (!resource.accessRules[principal]?.write)
752
+ const policy = collectionPolicies[frame.n];
753
+ if (!policy?.write) throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
754
+ if (!await policy.write(principal, frame.id, conn.ctx))
767
755
  throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
768
- await store.apply({ id: frame.id, update: frame.u, origin: frame.o });
769
- if (taps.length)
770
- emitTap({
771
- type: "store.write",
772
- store: frame.n,
773
- id: frame.id,
774
- origin: frame.o,
775
- connId: conn.id,
776
- data: frame.u
777
- });
756
+ await store.apply(
757
+ { n: frame.n, id: frame.id, update: frame.u, origin: frame.o },
758
+ def.crdt,
759
+ (snapshot) => (0, import_core.validateSync)(def.schema, snapshot)
760
+ );
778
761
  conn.send({ t: "res", i: frame.i, d: null });
779
762
  });
780
763
  }
781
- function handleStoreClose(conn, frame) {
782
- if (!storeMap[frame.n]) return;
783
- leaveChannel(conn, STORE + frame.n + ":" + frame.id);
784
- if (taps.length) emitTap({ type: "store.unsubscribe", connId: conn.id, store: frame.n, id: frame.id });
764
+ function handleCrdtClose(conn, frame) {
765
+ if (!crdtDefOf(frame.n)) return;
766
+ leaveChannel(conn, CDOC + frame.n + ":" + frame.id);
785
767
  }
786
- for (const [name, store] of Object.entries(storeMap)) {
787
- const isSelf = store.clustering === "self";
788
- store.onChange((change) => {
789
- const channel = STORE + name + ":" + change.id;
768
+ if (crdtStore) {
769
+ const isSelf = crdtStore.clustering === "self";
770
+ crdtStore.onChange((change) => {
771
+ const channel = CDOC + change.n + ":" + change.id;
790
772
  if (isSelf) {
791
773
  const set = members.get(channel);
792
774
  if (!set) return;
793
- const payload = serializer.encode({ t: "sch", n: name, id: change.id, u: change.update, o: change.origin });
775
+ const payload = serializer.encode({ t: "cdchg", n: change.n, id: change.id, u: change.update, o: change.origin });
794
776
  for (const conn of set) conn.sendRaw(payload);
795
777
  return;
796
778
  }
797
779
  if (relaying) return;
798
780
  void adapter.publish(
799
781
  channel,
800
- serializer.encode({ t: "sch", n: name, id: change.id, u: change.update, o: change.origin, nd: instanceId })
782
+ serializer.encode({ t: "cdchg", n: change.n, id: change.id, u: change.update, o: change.origin, nd: instanceId })
801
783
  );
802
784
  });
803
785
  if (isSelf)
804
- store.onDelete?.((id) => {
805
- const set = members.get(STORE + name + ":" + id);
786
+ crdtStore.onDelete?.((n, id) => {
787
+ const set = members.get(CDOC + n + ":" + id);
806
788
  if (!set) return;
807
- const payload = serializer.encode({ t: "sdel", n: name, id });
789
+ const payload = serializer.encode({ t: "cddel", n, id });
808
790
  for (const conn of set) conn.sendRaw(payload);
809
791
  });
810
792
  }
811
- function handleStoreRelay(channel, payload) {
793
+ function handleCrdtRelay(channel, payload) {
812
794
  const set = members.get(channel);
813
795
  if (set) for (const conn of set) conn.sendRaw(payload);
814
796
  let frame;
@@ -818,15 +800,20 @@ function createSuperLineServer(contract, opts) {
818
800
  return;
819
801
  }
820
802
  if (frame.nd === instanceId) return;
821
- const store = storeMap[frame.n];
822
- if (!store || store.clustering !== "relay") return;
823
- if (frame.t === "sdel") {
824
- void store.delete(frame.id);
803
+ if (!crdtStore || crdtStore.clustering !== "relay") return;
804
+ const def = crdtDefOf(frame.n);
805
+ if (!def) return;
806
+ if (frame.t === "cddel") {
807
+ try {
808
+ void crdtStore.delete(frame.n, frame.id);
809
+ } catch {
810
+ }
825
811
  return;
826
812
  }
827
813
  relaying = true;
828
814
  try {
829
- void store.apply({ id: frame.id, update: frame.u, origin: frame.o });
815
+ void crdtStore.apply({ n: frame.n, id: frame.id, update: frame.u, origin: frame.o }, def.crdt, () => {
816
+ });
830
817
  } catch {
831
818
  } finally {
832
819
  relaying = false;
@@ -882,6 +869,7 @@ function createSuperLineServer(contract, opts) {
882
869
  for (const op of ops) {
883
870
  const def = collectionDefs[op.n];
884
871
  if (!def) throw new import_core.SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
872
+ if ((0, import_core.isCrdtCollection)(def)) throw new import_core.SuperLineError("NOT_FOUND", `Collection ${op.n} is a CRDT document collection \u2014 use collection(n).open(id), not a row batch`);
885
873
  const policy = collectionPolicies[op.n];
886
874
  if (!policy?.write) throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${op.n}`);
887
875
  const prev = await store.read(op.n, op.id);
@@ -1103,78 +1091,6 @@ function createSuperLineServer(contract, opts) {
1103
1091
  void adapter.publish(CONN + id, serializer.encode(env));
1104
1092
  });
1105
1093
  }
1106
- const storeApi = {};
1107
- for (const [name, store] of Object.entries(storeMap)) {
1108
- const readOrThrow = async (id) => {
1109
- const r = await store.read(id);
1110
- if (!r) throw new import_core.SuperLineError("NOT_FOUND", `No resource: ${name}/${id}`);
1111
- return r;
1112
- };
1113
- storeApi[name] = {
1114
- async create(id, data, accessRules) {
1115
- await store.create(id, data, accessRules);
1116
- if (taps.length) emitTap({ type: "store.create", store: name, id });
1117
- },
1118
- read(id) {
1119
- return Promise.resolve(store.read(id));
1120
- },
1121
- async write(id, data, opts2) {
1122
- const origin = opts2?.origin ?? SERVER_ORIGIN;
1123
- await store.apply({ id, update: data, origin });
1124
- if (taps.length) emitTap({ type: "store.write", store: name, id, origin, data });
1125
- },
1126
- async grant(id, principal, perms) {
1127
- const r = await readOrThrow(id);
1128
- await store.setAccess(id, { ...r.accessRules, [principal]: perms });
1129
- if (taps.length) emitTap({ type: "store.grant", store: name, id, principal, perms });
1130
- },
1131
- async revoke(id, principal) {
1132
- const r = await readOrThrow(id);
1133
- const next = { ...r.accessRules };
1134
- delete next[principal];
1135
- await store.setAccess(id, next);
1136
- if (taps.length) emitTap({ type: "store.revoke", store: name, id, principal });
1137
- },
1138
- async delete(id) {
1139
- await store.delete(id);
1140
- if (store.clustering !== "self")
1141
- void adapter.publish(
1142
- STORE + name + ":" + id,
1143
- serializer.encode({ t: "sdel", n: name, id, nd: instanceId })
1144
- );
1145
- if (taps.length) emitTap({ type: "store.delete", store: name, id });
1146
- },
1147
- list(opts2) {
1148
- return Promise.resolve(store.list(opts2));
1149
- },
1150
- searchPrincipals(opts2) {
1151
- return Promise.resolve(store.searchPrincipals(opts2));
1152
- },
1153
- open(id, openOpts) {
1154
- if (!store.open)
1155
- throw new import_core.SuperLineError("UNSUPPORTED", `Store ${name} does not support reactive open()`);
1156
- const replica = store.open(id, openOpts);
1157
- if (!taps.length) return replica;
1158
- const origin = openOpts?.origin ?? SERVER_ORIGIN;
1159
- const emit = (data) => emitTap({ type: "store.write", store: name, id, origin, data });
1160
- return {
1161
- ...replica,
1162
- set: (d) => {
1163
- replica.set(d);
1164
- emit(d);
1165
- },
1166
- update: (p) => {
1167
- replica.update(p);
1168
- emit(p);
1169
- },
1170
- delete: (path) => {
1171
- replica.delete(path);
1172
- emit({ delete: path });
1173
- }
1174
- };
1175
- }
1176
- };
1177
- }
1178
1094
  const pluginDisposers = [];
1179
1095
  const pluginHandlers = {};
1180
1096
  const api = {
@@ -1275,15 +1191,40 @@ function createSuperLineServer(contract, opts) {
1275
1191
  }
1276
1192
  };
1277
1193
  },
1278
- store(name) {
1279
- const handle = storeApi[name];
1280
- if (!handle) throw new import_core.SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1281
- return handle;
1282
- },
1283
1194
  collection(name) {
1284
- if (!collectionStore) throw new import_core.SuperLineError("NOT_FOUND", "No collection backend configured");
1285
1195
  const def = collectionDefs[name];
1286
1196
  if (!def) throw new import_core.SuperLineError("NOT_FOUND", `Collection not declared: ${name}`);
1197
+ if ((0, import_core.isCrdtCollection)(def)) {
1198
+ if (!crdtStore) throw new import_core.SuperLineError("NOT_FOUND", "No CRDT collection backend configured");
1199
+ const cstore = crdtStore;
1200
+ const handle2 = {
1201
+ async create(id, data) {
1202
+ const v = await (0, import_core.validate)(def.schema, data);
1203
+ await cstore.create(name, id, v, def.crdt);
1204
+ },
1205
+ open(id, o) {
1206
+ return cstore.open(name, id, { ...o, doc: def.crdt });
1207
+ },
1208
+ async read(id) {
1209
+ const state = await cstore.read(name, id);
1210
+ if (state === void 0) return void 0;
1211
+ const r = cstore.open(name, id, { doc: def.crdt });
1212
+ const s = r.getSnapshot();
1213
+ r.close();
1214
+ return s;
1215
+ },
1216
+ async delete(id) {
1217
+ await cstore.delete(name, id);
1218
+ if (cstore.clustering !== "self")
1219
+ void adapter.publish(CDOC + name + ":" + id, serializer.encode({ t: "cddel", n: name, id, nd: instanceId }));
1220
+ },
1221
+ list(o) {
1222
+ return Promise.resolve(cstore.list(name, o));
1223
+ }
1224
+ };
1225
+ return handle2;
1226
+ }
1227
+ if (!collectionStore) throw new import_core.SuperLineError("NOT_FOUND", "No collection backend configured");
1287
1228
  const store = collectionStore;
1288
1229
  const resolve = async (row) => {
1289
1230
  const v = await (0, import_core.validate)(def.schema, row);
@@ -1292,7 +1233,7 @@ function createSuperLineServer(contract, opts) {
1292
1233
  throw new import_core.SuperLineError("VALIDATION", `Collection ${name} row is missing string key '${def.key}'`);
1293
1234
  return { id: key, row: v };
1294
1235
  };
1295
- return {
1236
+ const handle = {
1296
1237
  async insert(row) {
1297
1238
  const { id, row: v } = await resolve(row);
1298
1239
  await commitCollectionBatch([{ op: "insert", n: name, id, row: v }], SERVER_ORIGIN, true);
@@ -1311,6 +1252,7 @@ function createSuperLineServer(contract, opts) {
1311
1252
  return Promise.resolve(store.snapshot(name, query ?? {}));
1312
1253
  }
1313
1254
  };
1255
+ return handle;
1314
1256
  },
1315
1257
  async close() {
1316
1258
  if (closing) return;
@@ -1368,10 +1310,14 @@ function createSuperLineServer(contract, opts) {
1368
1310
  }
1369
1311
  };
1370
1312
  },
1371
- store: (name) => api.store(name),
1372
- storeInfos: () => Object.entries(storeMap).map(([name, store]) => ({ name, model: store.model })),
1373
1313
  collection: (name) => api.collection(name),
1374
- collectionInfos: () => Object.entries(collectionDefs).map(([name, def]) => ({ name, key: def.key, references: def.references ?? {} })),
1314
+ collectionInfos: () => (
1315
+ // CRDT document collections surface with a synthetic `id` key + no references — the inspector's
1316
+ // queryCollection synthesizes doc-rows for them (they're open-by-id, not row-queryable).
1317
+ Object.entries(collectionDefs).map(
1318
+ ([name, def]) => (0, import_core.isCrdtCollection)(def) ? { name, key: "id", references: {} } : { name, key: def.key, references: def.references ?? {} }
1319
+ )
1320
+ ),
1375
1321
  describe: (conn) => buildDescriptor(conn),
1376
1322
  connectionById: (id) => Promise.resolve(presenceOrThrow().get(id)),
1377
1323
  channel: (name) => pluginChannel(pluginName, name)
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, Expr, CollectionQuery, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, Directional, TapEvent, AccessRules, Resource, Perms, ListOpts, ResourceSummary, SearchOpts, ServerReplica, StoreInfo, CtsOf, ServerInput, ServerStore, Handshake, SharedRequests, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, CollectionName, RowOf, ServerTransport, CollectionStore } from '@super-line/core';
1
+ import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, Expr, CollectionQuery, CrdtServerReplica, DocListOpts, DocSummary, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, Directional, TapEvent, CtsOf, ServerInput, Handshake, SharedRequests, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, CollectionName, CrdtCollectionName, DocOf, RowOf, ServerTransport, CollectionStore, CrdtCollectionStore } from '@super-line/core';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -104,6 +104,36 @@ interface ServerCollectionHandle<Row = unknown> {
104
104
  /** Materialize a snapshot (filter/sort/limit); omit the query for the whole collection. Server-side, policy-free. */
105
105
  snapshot(query?: CollectionQuery): Promise<Row[]>;
106
106
  }
107
+ /**
108
+ * Access policy for a CRDT document collection (ADR-0007, Q7). Guard-shaped — not the RLS filter shape of
109
+ * {@link CollectionPolicy}, because CRDT collections are opened by id (no subset to filter). **Deny-by-default**.
110
+ * Server co-writes via `srv.collection(n)` bypass both.
111
+ */
112
+ interface CrdtCollectionPolicy<Ctx = unknown, Doc = unknown> {
113
+ /** May this principal open `id`? Gets the post-merge plaintext `snapshot` (content-based auth like RLS reading a row). */
114
+ read?: (principal: string, id: string, snapshot: Doc | undefined, ctx: Ctx) => Awaitable$1<boolean>;
115
+ /** May this principal write to `id`? (Creation is server-authoritative — Q10 — so there is no `create` op here.) */
116
+ write?: (principal: string, id: string, ctx: Ctx) => Awaitable$1<boolean>;
117
+ }
118
+ /**
119
+ * Server-authoritative handle for a CRDT document collection (`srv.collection('scenes')`). Creation is
120
+ * server-only (Q10): `create` validates the initial doc and materializes it; `open` returns a reactive
121
+ * co-writer whose mutations fan out like a relayed client delta. Policy-free (server is authoritative).
122
+ */
123
+ interface ServerCrdtCollectionHandle<Doc = unknown> {
124
+ /** Create a document with pre-validated initial data. Throws `CONFLICT` if the id exists. */
125
+ create(id: string, data: Doc): Promise<void>;
126
+ /** Open a reactive co-writer over an existing document. `origin` (default `"server"`) attributes its writes. */
127
+ open(id: string, opts?: {
128
+ origin?: string;
129
+ }): CrdtServerReplica;
130
+ /** Read the current plaintext snapshot of a document, or undefined if absent. */
131
+ read(id: string): Promise<Doc | undefined>;
132
+ /** Delete a document (idempotent), fanning the deletion cluster-wide. */
133
+ delete(id: string): Promise<void>;
134
+ /** Enumerate document ids + summaries (no content query). */
135
+ list(opts?: DocListOpts): Promise<DocSummary[]>;
136
+ }
107
137
 
108
138
  /**
109
139
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
@@ -266,46 +296,6 @@ interface RoleLens<C extends Contract, R extends RoleOf<C>> {
266
296
  /** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
267
297
  publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
268
298
  }
269
- /**
270
- * Server-side handle for one configured Store, reached via `srv.store.<name>`. The server is
271
- * authoritative: it creates Resources, grants/revokes access, and may co-write. `data` is untyped
272
- * (stores are off-contract — see ADR-0003); callers assert the shape.
273
- */
274
- interface ServerStoreHandle {
275
- /** Create a Resource with initial data + access rules (deny-by-default for everyone unlisted). */
276
- create(id: string, data: unknown, accessRules: AccessRules): Promise<void>;
277
- /** Read a Resource (data + accessRules), or undefined if absent. */
278
- read(id: string): Promise<Resource | undefined>;
279
- /**
280
- * Server co-write: replace the Resource's value (LWW), fanned out to subscribers stamped with `origin`
281
- * (default `"server"`) for echo-break + inspector attribution — mirrors {@link ServerStore.open}'s origin.
282
- */
283
- write(id: string, data: unknown, opts?: {
284
- origin?: string;
285
- }): Promise<void>;
286
- /** Grant a principal read/write on a Resource. */
287
- grant(id: string, principal: string, perms: Perms): Promise<void>;
288
- /** Revoke a principal's access to a Resource entirely. */
289
- revoke(id: string, principal: string): Promise<void>;
290
- /** Delete a Resource. */
291
- delete(id: string): Promise<void>;
292
- /**
293
- * Summaries of this store's Resources, server-side filtered / sorted / paginated per {@link ListOpts}
294
- * (omit `opts` for every Resource, id-ascending). Forwards to {@link ServerStore.list}.
295
- */
296
- list(opts?: ListOpts): Promise<ResourceSummary[]>;
297
- /** Distinct principals granted anywhere in this store (store-global). Forwards to {@link ServerStore.searchPrincipals}. */
298
- searchPrincipals(opts: SearchOpts): Promise<string[]>;
299
- /**
300
- * Open a reactive in-process co-writer over a Resource's canonical state — the server half's mirror of
301
- * `client.store(name).open(id)`. Reactive reads, merging `update`, and surgical `delete(path)` (the only
302
- * way to remove a key server-side), all server-authoritative and fanned out to subscribers. `origin`
303
- * (default `"server"`) tags its Changes for inspector attribution. Throws if the store has no `open`.
304
- */
305
- open(id: string, opts?: {
306
- origin?: string;
307
- }): ServerReplica;
308
- }
309
299
  /**
310
300
  * Handlers a plugin provides for its paired surface `S` (ADR-0004): one per `clientToServer` key,
311
301
  * typed from `S`. `ctx`/`conn` are loose — a plugin is written independently of the host's per-role ctx.
@@ -357,11 +347,6 @@ interface SuperLinePlugin<S extends Directional = {}> {
357
347
  * throws at construction.
358
348
  */
359
349
  handlers?: (ctx: PluginContext) => HandlersFor<S>;
360
- /**
361
- * Server halves of Store pairs the plugin contributes, merged into the host's `stores` and reachable
362
- * via `srv.store(name)`. A name colliding with a host store or another plugin's store throws at construction.
363
- */
364
- stores?: Record<string, ServerStore>;
365
350
  /**
366
351
  * Row-security policies for the collections the plugin's contract fragment declares (see {@link CollectionPolicy}).
367
352
  * Merged into the host's `policies`; a collection already policied by the host or another plugin throws at
@@ -457,11 +442,7 @@ interface PluginContext {
457
442
  readonly size: number;
458
443
  readonly connections: readonly Conn[];
459
444
  };
460
- /** Server-authoritative handle for a configured store (incl. plugin-contributed stores). */
461
- store(name: string): ServerStoreHandle;
462
- /** Configured stores (host + plugin) and their models. */
463
- storeInfos(): StoreInfo[];
464
- /** Server-authoritative handle for a contract collection (loosely typed here); throws if none is configured. */
445
+ /** Server-authoritative handle for a contract collection (loosely typed here as the LWW row handle — the surface plugins/inspector use); throws if none is configured. */
465
446
  collection(name: string): ServerCollectionHandle;
466
447
  /** Declared collections (name + key + advisory references) for the schema graph. */
467
448
  collectionInfos(): {
@@ -511,12 +492,6 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
511
492
  interval?: number;
512
493
  maxMissed?: number;
513
494
  } | false;
514
- /**
515
- * Pluggable persisted-state Stores, keyed by name (`{ scene: crdtStoreServer(), config: memoryStoreServer() }`).
516
- * Each is the server half of a Store pair; the client passes the matching client halves. Surfaced as
517
- * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
518
- */
519
- stores?: Record<string, ServerStore>;
520
495
  /**
521
496
  * The single {@link CollectionStore} backend serving every collection this contract declares (typed rows;
522
497
  * ADR-0006), e.g. `collections: memoryCollections()`. One backend ⇒ one transaction domain, so a
@@ -524,11 +499,18 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
524
499
  */
525
500
  collections?: CollectionStore;
526
501
  /**
527
- * Row-security policies per collection (see {@link CollectionPolicy}). Deny-by-default: a collection with no
528
- * policy is server-only (clients can neither read nor write it). Server co-writes via `srv.collection(n)` bypass them.
502
+ * The {@link CrdtCollectionStore} backend serving this contract's CRDT document collections (ADR-0007),
503
+ * e.g. `crdtCollections: crdtMemoryCollections()`. A backend per family: CRDT docs never join a
504
+ * cross-collection atomic batch, so they get their own backend, separate from `collections`.
505
+ */
506
+ crdtCollections?: CrdtCollectionStore;
507
+ /**
508
+ * Access policies per collection. Deny-by-default: a collection with no policy is server-only. LWW row
509
+ * collections take an RLS-style {@link CollectionPolicy} (read → filter); CRDT document collections take a
510
+ * guard-shaped {@link CrdtCollectionPolicy} (read → bool). Server co-writes via `srv.collection(n)` bypass them.
529
511
  */
530
512
  policies?: {
531
- [N in CollectionName<C>]?: CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
513
+ [N in CollectionName<C>]?: N extends CrdtCollectionName<C> ? CrdtCollectionPolicy<CtxUnion<A>, DocOf<C, N>> : CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
532
514
  };
533
515
  /**
534
516
  * Opt-in advisory foreign-key checks (see ADR-0006, decision 8). When true, an insert/update whose
@@ -575,10 +557,13 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
575
557
  subscribe<T extends keyof SharedTopics<C>>(topic: T, handler: (data: EventData<SharedTopics<C>[T]>, meta: BusMeta) => void): () => void;
576
558
  /** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
577
559
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
578
- /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
579
- store(name: string): ServerStoreHandle;
580
- /** Server-authoritative handle for a contract collection (`srv.collection('messages').insert(...)`), typed by the contract. Throws `NOT_FOUND` if no collection backend is configured or the name isn't declared. */
581
- collection<N extends CollectionName<C>>(name: N): ServerCollectionHandle<RowOf<C, N>>;
560
+ /**
561
+ * Server-authoritative handle for a contract collection, typed by the contract: an LWW row collection gives
562
+ * `ServerCollectionHandle` (`insert`/`update`/`batch`), a CRDT document collection gives
563
+ * `ServerCrdtCollectionHandle` (`create`/`open`). Throws `NOT_FOUND` if no matching backend is configured
564
+ * or the name isn't declared.
565
+ */
566
+ collection<N extends CollectionName<C>>(name: N): N extends CrdtCollectionName<C> ? ServerCrdtCollectionHandle<DocOf<C, N>> : ServerCollectionHandle<RowOf<C, N>>;
582
567
  close(): Promise<void>;
583
568
  }
584
569
  /**
@@ -605,4 +590,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
605
590
  */
606
591
  declare function createSuperLineServer<C extends Contract, A extends AuthResult<C>, const P extends readonly SuperLinePlugin<any>[] = []>(contract: C, opts: SuperLineServerOptions<C, A, P>): SuperLineServer<C, A, HandledKeys<P>>;
607
592
 
608
- export { type AuthResult, type BusMeta, type ClusterView, type CollectionPolicy, Conn, type ConnTarget, type HandledKeys, type Handlers, type HandlersFor, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type PluginChannel, type PluginConnection, type PluginContext, type PluginMiddleware, type RoleLens, type Room, type ServerCollectionHandle, type ServerStoreHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, type WriteOp, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
593
+ export { type AuthResult, type BusMeta, type ClusterView, type CollectionPolicy, Conn, type ConnTarget, type CrdtCollectionPolicy, type HandledKeys, type Handlers, type HandlersFor, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type PluginChannel, type PluginConnection, type PluginContext, type PluginMiddleware, type RoleLens, type Room, type ServerCollectionHandle, type ServerCrdtCollectionHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, type WriteOp, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, Expr, CollectionQuery, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, Directional, TapEvent, AccessRules, Resource, Perms, ListOpts, ResourceSummary, SearchOpts, ServerReplica, StoreInfo, CtsOf, ServerInput, ServerStore, Handshake, SharedRequests, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, CollectionName, RowOf, ServerTransport, CollectionStore } from '@super-line/core';
1
+ import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, Expr, CollectionQuery, CrdtServerReplica, DocListOpts, DocSummary, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, Directional, TapEvent, CtsOf, ServerInput, Handshake, SharedRequests, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, CollectionName, CrdtCollectionName, DocOf, RowOf, ServerTransport, CollectionStore, CrdtCollectionStore } from '@super-line/core';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -104,6 +104,36 @@ interface ServerCollectionHandle<Row = unknown> {
104
104
  /** Materialize a snapshot (filter/sort/limit); omit the query for the whole collection. Server-side, policy-free. */
105
105
  snapshot(query?: CollectionQuery): Promise<Row[]>;
106
106
  }
107
+ /**
108
+ * Access policy for a CRDT document collection (ADR-0007, Q7). Guard-shaped — not the RLS filter shape of
109
+ * {@link CollectionPolicy}, because CRDT collections are opened by id (no subset to filter). **Deny-by-default**.
110
+ * Server co-writes via `srv.collection(n)` bypass both.
111
+ */
112
+ interface CrdtCollectionPolicy<Ctx = unknown, Doc = unknown> {
113
+ /** May this principal open `id`? Gets the post-merge plaintext `snapshot` (content-based auth like RLS reading a row). */
114
+ read?: (principal: string, id: string, snapshot: Doc | undefined, ctx: Ctx) => Awaitable$1<boolean>;
115
+ /** May this principal write to `id`? (Creation is server-authoritative — Q10 — so there is no `create` op here.) */
116
+ write?: (principal: string, id: string, ctx: Ctx) => Awaitable$1<boolean>;
117
+ }
118
+ /**
119
+ * Server-authoritative handle for a CRDT document collection (`srv.collection('scenes')`). Creation is
120
+ * server-only (Q10): `create` validates the initial doc and materializes it; `open` returns a reactive
121
+ * co-writer whose mutations fan out like a relayed client delta. Policy-free (server is authoritative).
122
+ */
123
+ interface ServerCrdtCollectionHandle<Doc = unknown> {
124
+ /** Create a document with pre-validated initial data. Throws `CONFLICT` if the id exists. */
125
+ create(id: string, data: Doc): Promise<void>;
126
+ /** Open a reactive co-writer over an existing document. `origin` (default `"server"`) attributes its writes. */
127
+ open(id: string, opts?: {
128
+ origin?: string;
129
+ }): CrdtServerReplica;
130
+ /** Read the current plaintext snapshot of a document, or undefined if absent. */
131
+ read(id: string): Promise<Doc | undefined>;
132
+ /** Delete a document (idempotent), fanning the deletion cluster-wide. */
133
+ delete(id: string): Promise<void>;
134
+ /** Enumerate document ids + summaries (no content query). */
135
+ list(opts?: DocListOpts): Promise<DocSummary[]>;
136
+ }
107
137
 
108
138
  /**
109
139
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
@@ -266,46 +296,6 @@ interface RoleLens<C extends Contract, R extends RoleOf<C>> {
266
296
  /** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
267
297
  publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
268
298
  }
269
- /**
270
- * Server-side handle for one configured Store, reached via `srv.store.<name>`. The server is
271
- * authoritative: it creates Resources, grants/revokes access, and may co-write. `data` is untyped
272
- * (stores are off-contract — see ADR-0003); callers assert the shape.
273
- */
274
- interface ServerStoreHandle {
275
- /** Create a Resource with initial data + access rules (deny-by-default for everyone unlisted). */
276
- create(id: string, data: unknown, accessRules: AccessRules): Promise<void>;
277
- /** Read a Resource (data + accessRules), or undefined if absent. */
278
- read(id: string): Promise<Resource | undefined>;
279
- /**
280
- * Server co-write: replace the Resource's value (LWW), fanned out to subscribers stamped with `origin`
281
- * (default `"server"`) for echo-break + inspector attribution — mirrors {@link ServerStore.open}'s origin.
282
- */
283
- write(id: string, data: unknown, opts?: {
284
- origin?: string;
285
- }): Promise<void>;
286
- /** Grant a principal read/write on a Resource. */
287
- grant(id: string, principal: string, perms: Perms): Promise<void>;
288
- /** Revoke a principal's access to a Resource entirely. */
289
- revoke(id: string, principal: string): Promise<void>;
290
- /** Delete a Resource. */
291
- delete(id: string): Promise<void>;
292
- /**
293
- * Summaries of this store's Resources, server-side filtered / sorted / paginated per {@link ListOpts}
294
- * (omit `opts` for every Resource, id-ascending). Forwards to {@link ServerStore.list}.
295
- */
296
- list(opts?: ListOpts): Promise<ResourceSummary[]>;
297
- /** Distinct principals granted anywhere in this store (store-global). Forwards to {@link ServerStore.searchPrincipals}. */
298
- searchPrincipals(opts: SearchOpts): Promise<string[]>;
299
- /**
300
- * Open a reactive in-process co-writer over a Resource's canonical state — the server half's mirror of
301
- * `client.store(name).open(id)`. Reactive reads, merging `update`, and surgical `delete(path)` (the only
302
- * way to remove a key server-side), all server-authoritative and fanned out to subscribers. `origin`
303
- * (default `"server"`) tags its Changes for inspector attribution. Throws if the store has no `open`.
304
- */
305
- open(id: string, opts?: {
306
- origin?: string;
307
- }): ServerReplica;
308
- }
309
299
  /**
310
300
  * Handlers a plugin provides for its paired surface `S` (ADR-0004): one per `clientToServer` key,
311
301
  * typed from `S`. `ctx`/`conn` are loose — a plugin is written independently of the host's per-role ctx.
@@ -357,11 +347,6 @@ interface SuperLinePlugin<S extends Directional = {}> {
357
347
  * throws at construction.
358
348
  */
359
349
  handlers?: (ctx: PluginContext) => HandlersFor<S>;
360
- /**
361
- * Server halves of Store pairs the plugin contributes, merged into the host's `stores` and reachable
362
- * via `srv.store(name)`. A name colliding with a host store or another plugin's store throws at construction.
363
- */
364
- stores?: Record<string, ServerStore>;
365
350
  /**
366
351
  * Row-security policies for the collections the plugin's contract fragment declares (see {@link CollectionPolicy}).
367
352
  * Merged into the host's `policies`; a collection already policied by the host or another plugin throws at
@@ -457,11 +442,7 @@ interface PluginContext {
457
442
  readonly size: number;
458
443
  readonly connections: readonly Conn[];
459
444
  };
460
- /** Server-authoritative handle for a configured store (incl. plugin-contributed stores). */
461
- store(name: string): ServerStoreHandle;
462
- /** Configured stores (host + plugin) and their models. */
463
- storeInfos(): StoreInfo[];
464
- /** Server-authoritative handle for a contract collection (loosely typed here); throws if none is configured. */
445
+ /** Server-authoritative handle for a contract collection (loosely typed here as the LWW row handle — the surface plugins/inspector use); throws if none is configured. */
465
446
  collection(name: string): ServerCollectionHandle;
466
447
  /** Declared collections (name + key + advisory references) for the schema graph. */
467
448
  collectionInfos(): {
@@ -511,12 +492,6 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
511
492
  interval?: number;
512
493
  maxMissed?: number;
513
494
  } | false;
514
- /**
515
- * Pluggable persisted-state Stores, keyed by name (`{ scene: crdtStoreServer(), config: memoryStoreServer() }`).
516
- * Each is the server half of a Store pair; the client passes the matching client halves. Surfaced as
517
- * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
518
- */
519
- stores?: Record<string, ServerStore>;
520
495
  /**
521
496
  * The single {@link CollectionStore} backend serving every collection this contract declares (typed rows;
522
497
  * ADR-0006), e.g. `collections: memoryCollections()`. One backend ⇒ one transaction domain, so a
@@ -524,11 +499,18 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
524
499
  */
525
500
  collections?: CollectionStore;
526
501
  /**
527
- * Row-security policies per collection (see {@link CollectionPolicy}). Deny-by-default: a collection with no
528
- * policy is server-only (clients can neither read nor write it). Server co-writes via `srv.collection(n)` bypass them.
502
+ * The {@link CrdtCollectionStore} backend serving this contract's CRDT document collections (ADR-0007),
503
+ * e.g. `crdtCollections: crdtMemoryCollections()`. A backend per family: CRDT docs never join a
504
+ * cross-collection atomic batch, so they get their own backend, separate from `collections`.
505
+ */
506
+ crdtCollections?: CrdtCollectionStore;
507
+ /**
508
+ * Access policies per collection. Deny-by-default: a collection with no policy is server-only. LWW row
509
+ * collections take an RLS-style {@link CollectionPolicy} (read → filter); CRDT document collections take a
510
+ * guard-shaped {@link CrdtCollectionPolicy} (read → bool). Server co-writes via `srv.collection(n)` bypass them.
529
511
  */
530
512
  policies?: {
531
- [N in CollectionName<C>]?: CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
513
+ [N in CollectionName<C>]?: N extends CrdtCollectionName<C> ? CrdtCollectionPolicy<CtxUnion<A>, DocOf<C, N>> : CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
532
514
  };
533
515
  /**
534
516
  * Opt-in advisory foreign-key checks (see ADR-0006, decision 8). When true, an insert/update whose
@@ -575,10 +557,13 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
575
557
  subscribe<T extends keyof SharedTopics<C>>(topic: T, handler: (data: EventData<SharedTopics<C>[T]>, meta: BusMeta) => void): () => void;
576
558
  /** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
577
559
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
578
- /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
579
- store(name: string): ServerStoreHandle;
580
- /** Server-authoritative handle for a contract collection (`srv.collection('messages').insert(...)`), typed by the contract. Throws `NOT_FOUND` if no collection backend is configured or the name isn't declared. */
581
- collection<N extends CollectionName<C>>(name: N): ServerCollectionHandle<RowOf<C, N>>;
560
+ /**
561
+ * Server-authoritative handle for a contract collection, typed by the contract: an LWW row collection gives
562
+ * `ServerCollectionHandle` (`insert`/`update`/`batch`), a CRDT document collection gives
563
+ * `ServerCrdtCollectionHandle` (`create`/`open`). Throws `NOT_FOUND` if no matching backend is configured
564
+ * or the name isn't declared.
565
+ */
566
+ collection<N extends CollectionName<C>>(name: N): N extends CrdtCollectionName<C> ? ServerCrdtCollectionHandle<DocOf<C, N>> : ServerCollectionHandle<RowOf<C, N>>;
582
567
  close(): Promise<void>;
583
568
  }
584
569
  /**
@@ -605,4 +590,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
605
590
  */
606
591
  declare function createSuperLineServer<C extends Contract, A extends AuthResult<C>, const P extends readonly SuperLinePlugin<any>[] = []>(contract: C, opts: SuperLineServerOptions<C, A, P>): SuperLineServer<C, A, HandledKeys<P>>;
607
592
 
608
- export { type AuthResult, type BusMeta, type ClusterView, type CollectionPolicy, Conn, type ConnTarget, type HandledKeys, type Handlers, type HandlersFor, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type PluginChannel, type PluginConnection, type PluginContext, type PluginMiddleware, type RoleLens, type Room, type ServerCollectionHandle, type ServerStoreHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, type WriteOp, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
593
+ export { type AuthResult, type BusMeta, type ClusterView, type CollectionPolicy, Conn, type ConnTarget, type CrdtCollectionPolicy, type HandledKeys, type Handlers, type HandlersFor, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type PluginChannel, type PluginConnection, type PluginContext, type PluginMiddleware, type RoleLens, type Room, type ServerCollectionHandle, type ServerCrdtCollectionHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, type WriteOp, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
package/dist/index.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import {
3
3
  jsonSerializer,
4
4
  validate,
5
+ validateSync,
5
6
  SuperLineError,
6
7
  andFilters,
7
8
  orFilters,
8
- matchesFilter
9
+ matchesFilter,
10
+ isCrdtCollection
9
11
  } from "@super-line/core";
10
12
 
11
13
  // src/conn.ts
@@ -184,27 +186,18 @@ var CONN = "c:";
184
186
  var USER = "u:";
185
187
  var REPLY = "reply:";
186
188
  var PLUGIN = "x:";
187
- var STORE = "s:";
189
+ var CDOC = "d:";
188
190
  var SERVER_ORIGIN = "server";
189
191
  function createSuperLineServer(contract, opts) {
190
192
  const c = contract;
191
193
  const serializer = opts.serializer ?? jsonSerializer;
192
194
  const adapter = opts.adapter ?? createInMemoryAdapter();
193
- const storeMap = opts.stores ?? {};
194
195
  const plugins = opts.plugins ?? [];
195
196
  const pluginNames = /* @__PURE__ */ new Set();
196
197
  for (const p of plugins) {
197
198
  if (pluginNames.has(p.name)) throw new Error(`Duplicate plugin name: ${p.name}`);
198
199
  pluginNames.add(p.name);
199
200
  }
200
- for (const p of plugins) {
201
- if (!p.stores) continue;
202
- for (const [name, store] of Object.entries(p.stores)) {
203
- if (name in storeMap)
204
- throw new Error(`Plugin '${p.name}' store '${name}' collides with an existing store of that name`);
205
- storeMap[name] = store;
206
- }
207
- }
208
201
  const collectionStore = opts.collections;
209
202
  const collectionDefs = c.collections ?? {};
210
203
  const collectionPolicies = { ...opts.policies };
@@ -222,6 +215,11 @@ function createSuperLineServer(contract, opts) {
222
215
  }
223
216
  const checkReferences = opts.checkReferences ?? false;
224
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
+ };
225
223
  const COLL_CHANNEL = "cbatch";
226
224
  const collSubscribers = /* @__PURE__ */ new Map();
227
225
  const connColl = /* @__PURE__ */ new Map();
@@ -318,8 +316,8 @@ function createSuperLineServer(contract, opts) {
318
316
  handlePersonal(channel, payload);
319
317
  return;
320
318
  }
321
- if (channel.startsWith(STORE)) {
322
- handleStoreRelay(channel, payload);
319
+ if (channel.startsWith(CDOC)) {
320
+ handleCrdtRelay(channel, payload);
323
321
  return;
324
322
  }
325
323
  if (channel === COLL_CHANNEL) {
@@ -605,13 +603,12 @@ function createSuperLineServer(contract, opts) {
605
603
  else if (frame.t === "unsub") {
606
604
  const ns = topicNamespace(conn.role, frame.c);
607
605
  if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
608
- } else if (frame.t === "sopen") await handleStoreOpen(conn, frame);
609
- else if (frame.t === "srd") await handleStoreRead(conn, frame);
610
- else if (frame.t === "swr") await handleStoreWrite(conn, frame);
611
- else if (frame.t === "sclose") handleStoreClose(conn, frame);
612
- else if (frame.t === "csub") await handleCollectionSub(conn, frame);
606
+ } else if (frame.t === "csub") await handleCollectionSub(conn, frame);
613
607
  else if (frame.t === "cuns") handleCollectionUnsub(conn, frame);
614
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);
615
612
  else if (frame.t === "sres") {
616
613
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
617
614
  } else if (frame.t === "serr") {
@@ -704,90 +701,77 @@ function createSuperLineServer(contract, opts) {
704
701
  conn.send({ t: "res", i: frame.i, d: null });
705
702
  });
706
703
  }
707
- function storeOrErr(conn, id, name) {
708
- const store = storeMap[name];
709
- if (!store) conn.send({ t: "err", i: id, code: "NOT_FOUND", m: `Unknown store: ${name}` });
710
- return store;
711
- }
712
- async function handleStoreOpen(conn, frame) {
713
- const store = storeOrErr(conn, frame.i, frame.n);
714
- if (!store) return;
715
- await dispatchOp(conn, frame.i, { kind: "subscribe", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
716
- const resource = await store.read(frame.id);
717
- 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}`);
718
715
  const principal = conn.principal ?? conn.id;
719
- if (!resource.accessRules[principal]?.read)
720
- throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
721
- await joinChannel(conn, STORE + frame.n + ":" + frame.id);
722
- if (taps.length) emitTap({ type: "store.subscribe", connId: conn.id, store: frame.n, id: frame.id });
723
- conn.send({ t: "res", i: frame.i, d: resource.data });
724
- });
725
- }
726
- async function handleStoreRead(conn, frame) {
727
- const store = storeOrErr(conn, frame.i, frame.n);
728
- if (!store) return;
729
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
730
- const resource = await store.read(frame.id);
731
- if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
732
- const principal = conn.principal ?? conn.id;
733
- 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))
734
722
  throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
735
- 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 });
736
725
  });
737
726
  }
738
- async function handleStoreWrite(conn, frame) {
739
- const store = storeOrErr(conn, frame.i, frame.n);
740
- if (!store) return;
741
- await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
742
- const resource = await store.read(frame.id);
743
- 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 () => {
744
732
  const principal = conn.principal ?? conn.id;
745
- 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))
746
736
  throw new SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
747
- await store.apply({ id: frame.id, update: frame.u, origin: frame.o });
748
- if (taps.length)
749
- emitTap({
750
- type: "store.write",
751
- store: frame.n,
752
- id: frame.id,
753
- origin: frame.o,
754
- connId: conn.id,
755
- data: frame.u
756
- });
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
+ );
757
742
  conn.send({ t: "res", i: frame.i, d: null });
758
743
  });
759
744
  }
760
- function handleStoreClose(conn, frame) {
761
- if (!storeMap[frame.n]) return;
762
- leaveChannel(conn, STORE + frame.n + ":" + frame.id);
763
- 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);
764
748
  }
765
- for (const [name, store] of Object.entries(storeMap)) {
766
- const isSelf = store.clustering === "self";
767
- store.onChange((change) => {
768
- 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;
769
753
  if (isSelf) {
770
754
  const set = members.get(channel);
771
755
  if (!set) return;
772
- 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 });
773
757
  for (const conn of set) conn.sendRaw(payload);
774
758
  return;
775
759
  }
776
760
  if (relaying) return;
777
761
  void adapter.publish(
778
762
  channel,
779
- 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 })
780
764
  );
781
765
  });
782
766
  if (isSelf)
783
- store.onDelete?.((id) => {
784
- const set = members.get(STORE + name + ":" + id);
767
+ crdtStore.onDelete?.((n, id) => {
768
+ const set = members.get(CDOC + n + ":" + id);
785
769
  if (!set) return;
786
- const payload = serializer.encode({ t: "sdel", n: name, id });
770
+ const payload = serializer.encode({ t: "cddel", n, id });
787
771
  for (const conn of set) conn.sendRaw(payload);
788
772
  });
789
773
  }
790
- function handleStoreRelay(channel, payload) {
774
+ function handleCrdtRelay(channel, payload) {
791
775
  const set = members.get(channel);
792
776
  if (set) for (const conn of set) conn.sendRaw(payload);
793
777
  let frame;
@@ -797,15 +781,20 @@ function createSuperLineServer(contract, opts) {
797
781
  return;
798
782
  }
799
783
  if (frame.nd === instanceId) return;
800
- const store = storeMap[frame.n];
801
- if (!store || store.clustering !== "relay") return;
802
- if (frame.t === "sdel") {
803
- 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
+ }
804
792
  return;
805
793
  }
806
794
  relaying = true;
807
795
  try {
808
- 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
+ });
809
798
  } catch {
810
799
  } finally {
811
800
  relaying = false;
@@ -861,6 +850,7 @@ function createSuperLineServer(contract, opts) {
861
850
  for (const op of ops) {
862
851
  const def = collectionDefs[op.n];
863
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`);
864
854
  const policy = collectionPolicies[op.n];
865
855
  if (!policy?.write) throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}`);
866
856
  const prev = await store.read(op.n, op.id);
@@ -1082,78 +1072,6 @@ function createSuperLineServer(contract, opts) {
1082
1072
  void adapter.publish(CONN + id, serializer.encode(env));
1083
1073
  });
1084
1074
  }
1085
- const storeApi = {};
1086
- for (const [name, store] of Object.entries(storeMap)) {
1087
- const readOrThrow = async (id) => {
1088
- const r = await store.read(id);
1089
- if (!r) throw new SuperLineError("NOT_FOUND", `No resource: ${name}/${id}`);
1090
- return r;
1091
- };
1092
- storeApi[name] = {
1093
- async create(id, data, accessRules) {
1094
- await store.create(id, data, accessRules);
1095
- if (taps.length) emitTap({ type: "store.create", store: name, id });
1096
- },
1097
- read(id) {
1098
- return Promise.resolve(store.read(id));
1099
- },
1100
- async write(id, data, opts2) {
1101
- const origin = opts2?.origin ?? SERVER_ORIGIN;
1102
- await store.apply({ id, update: data, origin });
1103
- if (taps.length) emitTap({ type: "store.write", store: name, id, origin, data });
1104
- },
1105
- async grant(id, principal, perms) {
1106
- const r = await readOrThrow(id);
1107
- await store.setAccess(id, { ...r.accessRules, [principal]: perms });
1108
- if (taps.length) emitTap({ type: "store.grant", store: name, id, principal, perms });
1109
- },
1110
- async revoke(id, principal) {
1111
- const r = await readOrThrow(id);
1112
- const next = { ...r.accessRules };
1113
- delete next[principal];
1114
- await store.setAccess(id, next);
1115
- if (taps.length) emitTap({ type: "store.revoke", store: name, id, principal });
1116
- },
1117
- async delete(id) {
1118
- await store.delete(id);
1119
- if (store.clustering !== "self")
1120
- void adapter.publish(
1121
- STORE + name + ":" + id,
1122
- serializer.encode({ t: "sdel", n: name, id, nd: instanceId })
1123
- );
1124
- if (taps.length) emitTap({ type: "store.delete", store: name, id });
1125
- },
1126
- list(opts2) {
1127
- return Promise.resolve(store.list(opts2));
1128
- },
1129
- searchPrincipals(opts2) {
1130
- return Promise.resolve(store.searchPrincipals(opts2));
1131
- },
1132
- open(id, openOpts) {
1133
- if (!store.open)
1134
- throw new SuperLineError("UNSUPPORTED", `Store ${name} does not support reactive open()`);
1135
- const replica = store.open(id, openOpts);
1136
- if (!taps.length) return replica;
1137
- const origin = openOpts?.origin ?? SERVER_ORIGIN;
1138
- const emit = (data) => emitTap({ type: "store.write", store: name, id, origin, data });
1139
- return {
1140
- ...replica,
1141
- set: (d) => {
1142
- replica.set(d);
1143
- emit(d);
1144
- },
1145
- update: (p) => {
1146
- replica.update(p);
1147
- emit(p);
1148
- },
1149
- delete: (path) => {
1150
- replica.delete(path);
1151
- emit({ delete: path });
1152
- }
1153
- };
1154
- }
1155
- };
1156
- }
1157
1075
  const pluginDisposers = [];
1158
1076
  const pluginHandlers = {};
1159
1077
  const api = {
@@ -1254,15 +1172,40 @@ function createSuperLineServer(contract, opts) {
1254
1172
  }
1255
1173
  };
1256
1174
  },
1257
- store(name) {
1258
- const handle = storeApi[name];
1259
- if (!handle) throw new SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1260
- return handle;
1261
- },
1262
1175
  collection(name) {
1263
- if (!collectionStore) throw new SuperLineError("NOT_FOUND", "No collection backend configured");
1264
1176
  const def = collectionDefs[name];
1265
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");
1266
1209
  const store = collectionStore;
1267
1210
  const resolve = async (row) => {
1268
1211
  const v = await validate(def.schema, row);
@@ -1271,7 +1214,7 @@ function createSuperLineServer(contract, opts) {
1271
1214
  throw new SuperLineError("VALIDATION", `Collection ${name} row is missing string key '${def.key}'`);
1272
1215
  return { id: key, row: v };
1273
1216
  };
1274
- return {
1217
+ const handle = {
1275
1218
  async insert(row) {
1276
1219
  const { id, row: v } = await resolve(row);
1277
1220
  await commitCollectionBatch([{ op: "insert", n: name, id, row: v }], SERVER_ORIGIN, true);
@@ -1290,6 +1233,7 @@ function createSuperLineServer(contract, opts) {
1290
1233
  return Promise.resolve(store.snapshot(name, query ?? {}));
1291
1234
  }
1292
1235
  };
1236
+ return handle;
1293
1237
  },
1294
1238
  async close() {
1295
1239
  if (closing) return;
@@ -1347,10 +1291,14 @@ function createSuperLineServer(contract, opts) {
1347
1291
  }
1348
1292
  };
1349
1293
  },
1350
- store: (name) => api.store(name),
1351
- storeInfos: () => Object.entries(storeMap).map(([name, store]) => ({ name, model: store.model })),
1352
1294
  collection: (name) => api.collection(name),
1353
- collectionInfos: () => Object.entries(collectionDefs).map(([name, def]) => ({ name, key: def.key, references: def.references ?? {} })),
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
+ ),
1354
1302
  describe: (conn) => buildDescriptor(conn),
1355
1303
  connectionById: (id) => Promise.resolve(presenceOrThrow().get(id)),
1356
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.2",
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.1"
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/adapter-libp2p": "0.6.0",
72
71
  "@super-line/adapter-zeromq": "0.5.0",
73
72
  "@super-line/adapter-redis": "0.5.0",
74
- "@super-line/plugin-inspector": "0.1.0",
73
+ "@super-line/adapter-libp2p": "0.6.0",
74
+ "@super-line/client": "0.9.0",
75
+ "@super-line/plugin-inspector": "0.2.0",
75
76
  "@super-line/adapter-rabbitmq": "0.5.0",
76
- "@super-line/client": "0.8.0",
77
77
  "@super-line/transport-loopback": "0.5.0",
78
- "@super-line/transport-websocket": "0.6.0",
79
- "@super-line/transport-http": "0.5.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"