@super-line/server 0.10.1 → 0.10.2

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.cjs CHANGED
@@ -226,6 +226,26 @@ function createSuperLineServer(contract, opts) {
226
226
  storeMap[name] = store;
227
227
  }
228
228
  }
229
+ const collectionStore = opts.collections;
230
+ const collectionDefs = c.collections ?? {};
231
+ const collectionPolicies = { ...opts.policies };
232
+ for (const p of plugins) {
233
+ if (!p.policies) continue;
234
+ for (const [name, policy] of Object.entries(p.policies)) {
235
+ if (!(name in collectionDefs))
236
+ throw new Error(
237
+ `Plugin '${p.name}' policy references unknown collection '${name}' \u2014 register its contract fragment on defineContract({ plugins })`
238
+ );
239
+ if (name in collectionPolicies)
240
+ throw new Error(`Plugin '${p.name}' policy for collection '${name}' collides with an existing policy`);
241
+ collectionPolicies[name] = policy;
242
+ }
243
+ }
244
+ const checkReferences = opts.checkReferences ?? false;
245
+ const collIsRelay = collectionStore?.clustering === "relay";
246
+ const COLL_CHANNEL = "cbatch";
247
+ const collSubscribers = /* @__PURE__ */ new Map();
248
+ const connColl = /* @__PURE__ */ new Map();
229
249
  const reserved = [];
230
250
  for (const p of plugins) {
231
251
  if (!p.connection) continue;
@@ -323,6 +343,10 @@ function createSuperLineServer(contract, opts) {
323
343
  handleStoreRelay(channel, payload);
324
344
  return;
325
345
  }
346
+ if (channel === COLL_CHANNEL) {
347
+ handleCollectionRelay(payload);
348
+ return;
349
+ }
326
350
  if (channel.startsWith(PLUGIN)) {
327
351
  handlePluginChannel(channel, payload);
328
352
  return;
@@ -333,6 +357,7 @@ function createSuperLineServer(contract, opts) {
333
357
  if (busSet) deliverBus(payload, busSet);
334
358
  });
335
359
  void adapter.subscribe(replyChannel);
360
+ if (collectionStore && collIsRelay) void adapter.subscribe(COLL_CHANNEL);
336
361
  function handlePersonal(channel, payload) {
337
362
  const set = members.get(channel);
338
363
  if (!set) return;
@@ -566,6 +591,7 @@ function createSuperLineServer(contract, opts) {
566
591
  raw.onClose((code) => {
567
592
  conns.delete(conn);
568
593
  for (const channel of conn.channels) leaveChannel(conn, channel);
594
+ collUnsubAll(conn);
569
595
  void adapter.presence?.del(conn.id);
570
596
  const goneUserId = opts.identify?.(conn);
571
597
  emitTap({
@@ -604,6 +630,9 @@ function createSuperLineServer(contract, opts) {
604
630
  else if (frame.t === "srd") await handleStoreRead(conn, frame);
605
631
  else if (frame.t === "swr") await handleStoreWrite(conn, frame);
606
632
  else if (frame.t === "sclose") handleStoreClose(conn, frame);
633
+ else if (frame.t === "csub") await handleCollectionSub(conn, frame);
634
+ else if (frame.t === "cuns") handleCollectionUnsub(conn, frame);
635
+ else if (frame.t === "cbat") await handleCollectionBatch(conn, frame);
607
636
  else if (frame.t === "sres") {
608
637
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
609
638
  } else if (frame.t === "serr") {
@@ -803,6 +832,133 @@ function createSuperLineServer(contract, opts) {
803
832
  relaying = false;
804
833
  }
805
834
  }
835
+ const collConnState = (conn) => {
836
+ let s = connColl.get(conn);
837
+ if (!s) connColl.set(conn, s = { subs: /* @__PURE__ */ new Map(), policy: /* @__PURE__ */ new Map() });
838
+ return s;
839
+ };
840
+ function collUnsubAll(conn) {
841
+ connColl.delete(conn);
842
+ for (const set of collSubscribers.values()) set.delete(conn);
843
+ }
844
+ async function handleCollectionSub(conn, frame) {
845
+ if (!collectionStore || !collectionDefs[frame.n]) {
846
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown collection: ${frame.n}` });
847
+ return;
848
+ }
849
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `collection:${frame.n}`, conn }, async () => {
850
+ const principal = conn.principal ?? conn.id;
851
+ const policy = collectionPolicies[frame.n];
852
+ if (!policy?.read) throw new import_core.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}`);
853
+ const policyFilter = await policy.read(principal, conn.ctx);
854
+ const eff = (0, import_core.andFilters)(policyFilter, frame.q.filter);
855
+ const rows = await collectionStore.snapshot(frame.n, { ...frame.q, filter: eff });
856
+ const state = collConnState(conn);
857
+ let subs = state.subs.get(frame.n);
858
+ if (!subs) state.subs.set(frame.n, subs = /* @__PURE__ */ new Map());
859
+ subs.set(frame.s, frame.q);
860
+ state.policy.set(frame.n, policyFilter);
861
+ let set = collSubscribers.get(frame.n);
862
+ if (!set) collSubscribers.set(frame.n, set = /* @__PURE__ */ new Set());
863
+ set.add(conn);
864
+ conn.send({ t: "res", i: frame.i, d: rows });
865
+ });
866
+ }
867
+ function handleCollectionUnsub(conn, frame) {
868
+ const state = connColl.get(conn);
869
+ const subs = state?.subs.get(frame.n);
870
+ if (!state || !subs) return;
871
+ subs.delete(frame.s);
872
+ if (subs.size === 0) {
873
+ state.subs.delete(frame.n);
874
+ state.policy.delete(frame.n);
875
+ collSubscribers.get(frame.n)?.delete(conn);
876
+ }
877
+ }
878
+ async function resolveCollectionOps(ops, principal, ctx) {
879
+ const store = collectionStore;
880
+ if (!store) throw new import_core.SuperLineError("NOT_FOUND", "No collection backend configured");
881
+ const out = [];
882
+ for (const op of ops) {
883
+ const def = collectionDefs[op.n];
884
+ if (!def) throw new import_core.SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
885
+ const policy = collectionPolicies[op.n];
886
+ if (!policy?.write) throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${op.n}`);
887
+ const prev = await store.read(op.n, op.id);
888
+ if (op.op === "delete") {
889
+ if (!await policy.write(principal, "delete", void 0, prev, ctx))
890
+ throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
891
+ out.push({ op: "delete", n: op.n, id: op.id });
892
+ continue;
893
+ }
894
+ const row = await (0, import_core.validate)(def.schema, op.d);
895
+ const key = row[def.key];
896
+ if (typeof key !== "string")
897
+ throw new import_core.SuperLineError("VALIDATION", `Collection ${op.n} row is missing string key '${def.key}'`);
898
+ if (key !== op.id) throw new import_core.SuperLineError("VALIDATION", `Row key '${key}' does not match op id '${op.id}'`);
899
+ if (checkReferences && def.references) {
900
+ for (const [field, refCollection] of Object.entries(def.references)) {
901
+ const ref = row[field];
902
+ if (ref === void 0 || ref === null) continue;
903
+ if (await store.read(refCollection, String(ref)) === void 0)
904
+ throw new import_core.SuperLineError("VALIDATION", `Dangling reference: ${op.n}.${field} \u2192 ${refCollection}/${String(ref)} does not exist`);
905
+ }
906
+ }
907
+ const kind = op.op;
908
+ if (!await policy.write(principal, kind, row, prev, ctx))
909
+ throw new import_core.SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
910
+ out.push({ op: kind, n: op.n, id: op.id, row });
911
+ }
912
+ return out;
913
+ }
914
+ async function commitCollectionBatch(ops, origin, relay) {
915
+ if (ops.length === 0 || !collectionStore) return;
916
+ await collectionStore.apply(ops, origin);
917
+ if (relay && collIsRelay)
918
+ void adapter.publish(COLL_CHANNEL, serializer.encode({ ops, origin, nd: instanceId }));
919
+ }
920
+ async function handleCollectionBatch(conn, frame) {
921
+ if (!collectionStore) {
922
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: "No collection backend configured" });
923
+ return;
924
+ }
925
+ await dispatchOp(conn, frame.i, { kind: "request", name: "collection:batch", conn }, async () => {
926
+ const principal = conn.principal ?? conn.id;
927
+ const resolved = await resolveCollectionOps(frame.ops, principal, conn.ctx);
928
+ await commitCollectionBatch(resolved, principal, true);
929
+ conn.send({ t: "res", i: frame.i, d: null });
930
+ });
931
+ }
932
+ function routeRowChange(change) {
933
+ const conns2 = collSubscribers.get(change.n);
934
+ if (!conns2 || conns2.size === 0) return;
935
+ for (const conn of conns2) {
936
+ const state = connColl.get(conn);
937
+ const subs = state?.subs.get(change.n);
938
+ if (!state || !subs || subs.size === 0) continue;
939
+ const eff = (0, import_core.andFilters)(state.policy.get(change.n), (0, import_core.orFilters)([...subs.values()].map((q) => q.filter)));
940
+ const prevlessDelete = change.k === "delete" && change.prev === void 0;
941
+ const inPrev = prevlessDelete || change.prev !== void 0 && (0, import_core.matchesFilter)(eff, change.prev);
942
+ const inNext = change.next !== void 0 && (0, import_core.matchesFilter)(eff, change.next);
943
+ if (!inPrev && !inNext) continue;
944
+ conn.send({ t: "cchg", n: change.n, k: change.k, id: change.id, d: change.next });
945
+ }
946
+ }
947
+ function handleCollectionRelay(payload) {
948
+ let env;
949
+ try {
950
+ env = serializer.decode(payload);
951
+ } catch {
952
+ return;
953
+ }
954
+ if (env.nd === instanceId) return;
955
+ if (!collectionStore || !collIsRelay) return;
956
+ try {
957
+ void collectionStore.apply(env.ops, env.origin);
958
+ } catch {
959
+ }
960
+ }
961
+ if (collectionStore) collectionStore.onChange(routeRowChange);
806
962
  function room(name) {
807
963
  const channel = ROOM + name;
808
964
  return {
@@ -1124,6 +1280,38 @@ function createSuperLineServer(contract, opts) {
1124
1280
  if (!handle) throw new import_core.SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1125
1281
  return handle;
1126
1282
  },
1283
+ collection(name) {
1284
+ if (!collectionStore) throw new import_core.SuperLineError("NOT_FOUND", "No collection backend configured");
1285
+ const def = collectionDefs[name];
1286
+ if (!def) throw new import_core.SuperLineError("NOT_FOUND", `Collection not declared: ${name}`);
1287
+ const store = collectionStore;
1288
+ const resolve = async (row) => {
1289
+ const v = await (0, import_core.validate)(def.schema, row);
1290
+ const key = v[def.key];
1291
+ if (typeof key !== "string")
1292
+ throw new import_core.SuperLineError("VALIDATION", `Collection ${name} row is missing string key '${def.key}'`);
1293
+ return { id: key, row: v };
1294
+ };
1295
+ return {
1296
+ async insert(row) {
1297
+ const { id, row: v } = await resolve(row);
1298
+ await commitCollectionBatch([{ op: "insert", n: name, id, row: v }], SERVER_ORIGIN, true);
1299
+ },
1300
+ async update(row) {
1301
+ const { id, row: v } = await resolve(row);
1302
+ await commitCollectionBatch([{ op: "update", n: name, id, row: v }], SERVER_ORIGIN, true);
1303
+ },
1304
+ async delete(id) {
1305
+ await commitCollectionBatch([{ op: "delete", n: name, id }], SERVER_ORIGIN, true);
1306
+ },
1307
+ read(id) {
1308
+ return Promise.resolve(store.read(name, id));
1309
+ },
1310
+ snapshot(query) {
1311
+ return Promise.resolve(store.snapshot(name, query ?? {}));
1312
+ }
1313
+ };
1314
+ },
1127
1315
  async close() {
1128
1316
  if (closing) return;
1129
1317
  closing = true;
@@ -1182,6 +1370,8 @@ function createSuperLineServer(contract, opts) {
1182
1370
  },
1183
1371
  store: (name) => api.store(name),
1184
1372
  storeInfos: () => Object.entries(storeMap).map(([name, store]) => ({ name, model: store.model })),
1373
+ collection: (name) => api.collection(name),
1374
+ collectionInfos: () => Object.entries(collectionDefs).map(([name, def]) => ({ name, key: def.key, references: def.references ?? {} })),
1185
1375
  describe: (conn) => buildDescriptor(conn),
1186
1376
  connectionById: (id) => Promise.resolve(presenceOrThrow().get(id)),
1187
1377
  channel: (name) => pluginChannel(pluginName, name)
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, 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, ServerTransport } from '@super-line/core';
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';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -65,6 +65,46 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
65
65
  */
66
66
  declare function resolvePrincipal(conn: Conn, identify?: (conn: Conn) => string | undefined): string;
67
67
 
68
+ type Awaitable$1<T> = T | Promise<T>;
69
+ /** The three row mutations a write policy guards. */
70
+ type WriteOp = 'insert' | 'update' | 'delete';
71
+ /**
72
+ * Row-security policy for one collection (see ADR-0006, decision 7). Server-side only — callbacks, never
73
+ * serialized to clients. **Deny-by-default**: omit `read` and clients cannot read the collection at all;
74
+ * omit `write` and clients cannot write it. Server co-writes via `srv.collection(n)` bypass both.
75
+ */
76
+ interface CollectionPolicy<Ctx = unknown, Row = unknown> {
77
+ /**
78
+ * A caller's visibility filter, ANDed into every snapshot, subscription, and change-route for that caller.
79
+ * Return `undefined` for "no filter" (the whole collection is visible). Return an {@link Expr} to restrict.
80
+ * Caveat: it is evaluated at subscribe time; principal-side state captured here (e.g. the caller's channel
81
+ * list) goes stale until the client resubscribes — row-side predicates re-evaluate on every change naturally.
82
+ */
83
+ read?: (principal: string, ctx: Ctx) => Awaitable$1<Expr | undefined>;
84
+ /**
85
+ * Per-row write guard. `next` is the incoming row (absent on delete), `prev` the current row (absent on
86
+ * insert). Return `false` to reject the op — which aborts the whole atomic batch it belongs to.
87
+ */
88
+ write?: (principal: string, op: WriteOp, next: Row | undefined, prev: Row | undefined, ctx: Ctx) => Awaitable$1<boolean>;
89
+ }
90
+ /**
91
+ * Server-authoritative handle for a collection (`srv.collection('messages')`). Writes bypass row policy
92
+ * (server is authoritative) but are still schema-validated, fan out, and — under relay — replicate across
93
+ * nodes exactly like a client batch. The door for business-logic mutations that a contract request handler owns.
94
+ */
95
+ interface ServerCollectionHandle<Row = unknown> {
96
+ /** Insert a row (its `key` field becomes the id). Throws `CONFLICT` if the id already exists. */
97
+ insert(row: Row): Promise<void>;
98
+ /** Replace a row by its `key` (LWW). Throws `NOT_FOUND` if absent. */
99
+ update(row: Row): Promise<void>;
100
+ /** Delete a row by id (idempotent). */
101
+ delete(id: string): Promise<void>;
102
+ /** Read one row by id, or undefined. */
103
+ read(id: string): Promise<Row | undefined>;
104
+ /** Materialize a snapshot (filter/sort/limit); omit the query for the whole collection. Server-side, policy-free. */
105
+ snapshot(query?: CollectionQuery): Promise<Row[]>;
106
+ }
107
+
68
108
  /**
69
109
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
70
110
  * multiple nodes in a test (each server gets its own adapter bound to the bus).
@@ -277,9 +317,15 @@ type HandlersFor<S extends Directional> = {
277
317
  type PluginHandledKeys<U> = U extends SuperLinePlugin<infer S> ? keyof CtsOf<S> & string : never;
278
318
  /** Union of the `clientToServer` keys handled across a plugin tuple `P` (subtracted from `implement`). */
279
319
  type HandledKeys<P extends readonly SuperLinePlugin<any>[]> = PluginHandledKeys<P[number]>;
280
- /** Remove the plugin-handled keys `HK` from every block (each role + `shared`) of a {@link Handlers} map. */
320
+ /**
321
+ * Remove the plugin-handled keys `HK` from every block (each role + `shared`) of a {@link Handlers} map. A block
322
+ * a plugin fully owns collapses to `{}` and becomes OPTIONAL — so a host needn't pass an empty `shared: {}` /
323
+ * `guest: {}` for a role or shared surface a plugin handles entirely.
324
+ */
281
325
  type SubtractHandlers<H, HK extends string> = {
282
- [B in keyof H]: Omit<H[B], HK>;
326
+ [B in keyof H as [keyof Omit<H[B], HK>] extends [never] ? never : B]: Omit<H[B], HK>;
327
+ } & {
328
+ [B in keyof H as [keyof Omit<H[B], HK>] extends [never] ? B : never]?: Omit<H[B], HK>;
283
329
  };
284
330
  /**
285
331
  * A named, declarative bundle of runtime contributions registered on `plugins: [...]`. All fields
@@ -316,6 +362,14 @@ interface SuperLinePlugin<S extends Directional = {}> {
316
362
  * via `srv.store(name)`. A name colliding with a host store or another plugin's store throws at construction.
317
363
  */
318
364
  stores?: Record<string, ServerStore>;
365
+ /**
366
+ * Row-security policies for the collections the plugin's contract fragment declares (see {@link CollectionPolicy}).
367
+ * Merged into the host's `policies`; a collection already policied by the host or another plugin throws at
368
+ * construction, and a policy for a collection no fragment declared throws too. Deny-by-default still holds —
369
+ * a collection nobody policies is server-only. Lets a plugin ship its collections locked down (e.g. deny-all
370
+ * on secret tables) without the host hand-spreading them.
371
+ */
372
+ policies?: Record<string, CollectionPolicy<unknown, unknown>>;
319
373
  /**
320
374
  * A plugin-owned (reserved) connection class — its own role, handshake negotiation, and parallel contract,
321
375
  * served over observer-invisible connections. See {@link PluginConnection}. (Phase 2.)
@@ -407,6 +461,14 @@ interface PluginContext {
407
461
  store(name: string): ServerStoreHandle;
408
462
  /** Configured stores (host + plugin) and their models. */
409
463
  storeInfos(): StoreInfo[];
464
+ /** Server-authoritative handle for a contract collection (loosely typed here); throws if none is configured. */
465
+ collection(name: string): ServerCollectionHandle;
466
+ /** Declared collections (name + key + advisory references) for the schema graph. */
467
+ collectionInfos(): {
468
+ name: string;
469
+ key: string;
470
+ references: Record<string, string>;
471
+ }[];
410
472
  /** Full cluster descriptor for a local connection (identity + rooms + `describeConn` extras). */
411
473
  describe(conn: Conn): ConnDescriptor;
412
474
  /** A connection's descriptor anywhere in the cluster (rejects without presence support); undefined if absent. */
@@ -455,6 +517,26 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
455
517
  * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
456
518
  */
457
519
  stores?: Record<string, ServerStore>;
520
+ /**
521
+ * The single {@link CollectionStore} backend serving every collection this contract declares (typed rows;
522
+ * ADR-0006), e.g. `collections: memoryCollections()`. One backend ⇒ one transaction domain, so a
523
+ * cross-collection batch commits atomically.
524
+ */
525
+ collections?: CollectionStore;
526
+ /**
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.
529
+ */
530
+ policies?: {
531
+ [N in CollectionName<C>]?: CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
532
+ };
533
+ /**
534
+ * Opt-in advisory foreign-key checks (see ADR-0006, decision 8). When true, an insert/update whose
535
+ * `references` field points at a non-existent row is rejected at the accepting node. Best-effort under
536
+ * relay clustering (no global serialization point) and does NOT resolve intra-batch parent-then-child
537
+ * references; there are no cascades. Off by default.
538
+ */
539
+ checkReferences?: boolean;
458
540
  /** Called once per accepted connection. */
459
541
  onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
460
542
  /** Called when a connection closes, with the WebSocket close `code`. */
@@ -495,6 +577,8 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
495
577
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
496
578
  /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
497
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>>;
498
582
  close(): Promise<void>;
499
583
  }
500
584
  /**
@@ -521,4 +605,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
521
605
  */
522
606
  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>>;
523
607
 
524
- export { type AuthResult, type BusMeta, type ClusterView, 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 ServerStoreHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
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 };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, 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, ServerTransport } from '@super-line/core';
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';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -65,6 +65,46 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
65
65
  */
66
66
  declare function resolvePrincipal(conn: Conn, identify?: (conn: Conn) => string | undefined): string;
67
67
 
68
+ type Awaitable$1<T> = T | Promise<T>;
69
+ /** The three row mutations a write policy guards. */
70
+ type WriteOp = 'insert' | 'update' | 'delete';
71
+ /**
72
+ * Row-security policy for one collection (see ADR-0006, decision 7). Server-side only — callbacks, never
73
+ * serialized to clients. **Deny-by-default**: omit `read` and clients cannot read the collection at all;
74
+ * omit `write` and clients cannot write it. Server co-writes via `srv.collection(n)` bypass both.
75
+ */
76
+ interface CollectionPolicy<Ctx = unknown, Row = unknown> {
77
+ /**
78
+ * A caller's visibility filter, ANDed into every snapshot, subscription, and change-route for that caller.
79
+ * Return `undefined` for "no filter" (the whole collection is visible). Return an {@link Expr} to restrict.
80
+ * Caveat: it is evaluated at subscribe time; principal-side state captured here (e.g. the caller's channel
81
+ * list) goes stale until the client resubscribes — row-side predicates re-evaluate on every change naturally.
82
+ */
83
+ read?: (principal: string, ctx: Ctx) => Awaitable$1<Expr | undefined>;
84
+ /**
85
+ * Per-row write guard. `next` is the incoming row (absent on delete), `prev` the current row (absent on
86
+ * insert). Return `false` to reject the op — which aborts the whole atomic batch it belongs to.
87
+ */
88
+ write?: (principal: string, op: WriteOp, next: Row | undefined, prev: Row | undefined, ctx: Ctx) => Awaitable$1<boolean>;
89
+ }
90
+ /**
91
+ * Server-authoritative handle for a collection (`srv.collection('messages')`). Writes bypass row policy
92
+ * (server is authoritative) but are still schema-validated, fan out, and — under relay — replicate across
93
+ * nodes exactly like a client batch. The door for business-logic mutations that a contract request handler owns.
94
+ */
95
+ interface ServerCollectionHandle<Row = unknown> {
96
+ /** Insert a row (its `key` field becomes the id). Throws `CONFLICT` if the id already exists. */
97
+ insert(row: Row): Promise<void>;
98
+ /** Replace a row by its `key` (LWW). Throws `NOT_FOUND` if absent. */
99
+ update(row: Row): Promise<void>;
100
+ /** Delete a row by id (idempotent). */
101
+ delete(id: string): Promise<void>;
102
+ /** Read one row by id, or undefined. */
103
+ read(id: string): Promise<Row | undefined>;
104
+ /** Materialize a snapshot (filter/sort/limit); omit the query for the whole collection. Server-side, policy-free. */
105
+ snapshot(query?: CollectionQuery): Promise<Row[]>;
106
+ }
107
+
68
108
  /**
69
109
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
70
110
  * multiple nodes in a test (each server gets its own adapter bound to the bus).
@@ -277,9 +317,15 @@ type HandlersFor<S extends Directional> = {
277
317
  type PluginHandledKeys<U> = U extends SuperLinePlugin<infer S> ? keyof CtsOf<S> & string : never;
278
318
  /** Union of the `clientToServer` keys handled across a plugin tuple `P` (subtracted from `implement`). */
279
319
  type HandledKeys<P extends readonly SuperLinePlugin<any>[]> = PluginHandledKeys<P[number]>;
280
- /** Remove the plugin-handled keys `HK` from every block (each role + `shared`) of a {@link Handlers} map. */
320
+ /**
321
+ * Remove the plugin-handled keys `HK` from every block (each role + `shared`) of a {@link Handlers} map. A block
322
+ * a plugin fully owns collapses to `{}` and becomes OPTIONAL — so a host needn't pass an empty `shared: {}` /
323
+ * `guest: {}` for a role or shared surface a plugin handles entirely.
324
+ */
281
325
  type SubtractHandlers<H, HK extends string> = {
282
- [B in keyof H]: Omit<H[B], HK>;
326
+ [B in keyof H as [keyof Omit<H[B], HK>] extends [never] ? never : B]: Omit<H[B], HK>;
327
+ } & {
328
+ [B in keyof H as [keyof Omit<H[B], HK>] extends [never] ? B : never]?: Omit<H[B], HK>;
283
329
  };
284
330
  /**
285
331
  * A named, declarative bundle of runtime contributions registered on `plugins: [...]`. All fields
@@ -316,6 +362,14 @@ interface SuperLinePlugin<S extends Directional = {}> {
316
362
  * via `srv.store(name)`. A name colliding with a host store or another plugin's store throws at construction.
317
363
  */
318
364
  stores?: Record<string, ServerStore>;
365
+ /**
366
+ * Row-security policies for the collections the plugin's contract fragment declares (see {@link CollectionPolicy}).
367
+ * Merged into the host's `policies`; a collection already policied by the host or another plugin throws at
368
+ * construction, and a policy for a collection no fragment declared throws too. Deny-by-default still holds —
369
+ * a collection nobody policies is server-only. Lets a plugin ship its collections locked down (e.g. deny-all
370
+ * on secret tables) without the host hand-spreading them.
371
+ */
372
+ policies?: Record<string, CollectionPolicy<unknown, unknown>>;
319
373
  /**
320
374
  * A plugin-owned (reserved) connection class — its own role, handshake negotiation, and parallel contract,
321
375
  * served over observer-invisible connections. See {@link PluginConnection}. (Phase 2.)
@@ -407,6 +461,14 @@ interface PluginContext {
407
461
  store(name: string): ServerStoreHandle;
408
462
  /** Configured stores (host + plugin) and their models. */
409
463
  storeInfos(): StoreInfo[];
464
+ /** Server-authoritative handle for a contract collection (loosely typed here); throws if none is configured. */
465
+ collection(name: string): ServerCollectionHandle;
466
+ /** Declared collections (name + key + advisory references) for the schema graph. */
467
+ collectionInfos(): {
468
+ name: string;
469
+ key: string;
470
+ references: Record<string, string>;
471
+ }[];
410
472
  /** Full cluster descriptor for a local connection (identity + rooms + `describeConn` extras). */
411
473
  describe(conn: Conn): ConnDescriptor;
412
474
  /** A connection's descriptor anywhere in the cluster (rejects without presence support); undefined if absent. */
@@ -455,6 +517,26 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>, P
455
517
  * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
456
518
  */
457
519
  stores?: Record<string, ServerStore>;
520
+ /**
521
+ * The single {@link CollectionStore} backend serving every collection this contract declares (typed rows;
522
+ * ADR-0006), e.g. `collections: memoryCollections()`. One backend ⇒ one transaction domain, so a
523
+ * cross-collection batch commits atomically.
524
+ */
525
+ collections?: CollectionStore;
526
+ /**
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.
529
+ */
530
+ policies?: {
531
+ [N in CollectionName<C>]?: CollectionPolicy<CtxUnion<A>, RowOf<C, N>>;
532
+ };
533
+ /**
534
+ * Opt-in advisory foreign-key checks (see ADR-0006, decision 8). When true, an insert/update whose
535
+ * `references` field points at a non-existent row is rejected at the accepting node. Best-effort under
536
+ * relay clustering (no global serialization point) and does NOT resolve intra-batch parent-then-child
537
+ * references; there are no cascades. Off by default.
538
+ */
539
+ checkReferences?: boolean;
458
540
  /** Called once per accepted connection. */
459
541
  onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
460
542
  /** Called when a connection closes, with the WebSocket close `code`. */
@@ -495,6 +577,8 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
495
577
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
496
578
  /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
497
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>>;
498
582
  close(): Promise<void>;
499
583
  }
500
584
  /**
@@ -521,4 +605,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>, HK extend
521
605
  */
522
606
  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>>;
523
607
 
524
- export { type AuthResult, type BusMeta, type ClusterView, 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 ServerStoreHandle, type SubtractHandlers, type SuperLinePlugin, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
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 };
package/dist/index.js CHANGED
@@ -2,7 +2,10 @@
2
2
  import {
3
3
  jsonSerializer,
4
4
  validate,
5
- SuperLineError
5
+ SuperLineError,
6
+ andFilters,
7
+ orFilters,
8
+ matchesFilter
6
9
  } from "@super-line/core";
7
10
 
8
11
  // src/conn.ts
@@ -202,6 +205,26 @@ function createSuperLineServer(contract, opts) {
202
205
  storeMap[name] = store;
203
206
  }
204
207
  }
208
+ const collectionStore = opts.collections;
209
+ const collectionDefs = c.collections ?? {};
210
+ const collectionPolicies = { ...opts.policies };
211
+ for (const p of plugins) {
212
+ if (!p.policies) continue;
213
+ for (const [name, policy] of Object.entries(p.policies)) {
214
+ if (!(name in collectionDefs))
215
+ throw new Error(
216
+ `Plugin '${p.name}' policy references unknown collection '${name}' \u2014 register its contract fragment on defineContract({ plugins })`
217
+ );
218
+ if (name in collectionPolicies)
219
+ throw new Error(`Plugin '${p.name}' policy for collection '${name}' collides with an existing policy`);
220
+ collectionPolicies[name] = policy;
221
+ }
222
+ }
223
+ const checkReferences = opts.checkReferences ?? false;
224
+ const collIsRelay = collectionStore?.clustering === "relay";
225
+ const COLL_CHANNEL = "cbatch";
226
+ const collSubscribers = /* @__PURE__ */ new Map();
227
+ const connColl = /* @__PURE__ */ new Map();
205
228
  const reserved = [];
206
229
  for (const p of plugins) {
207
230
  if (!p.connection) continue;
@@ -299,6 +322,10 @@ function createSuperLineServer(contract, opts) {
299
322
  handleStoreRelay(channel, payload);
300
323
  return;
301
324
  }
325
+ if (channel === COLL_CHANNEL) {
326
+ handleCollectionRelay(payload);
327
+ return;
328
+ }
302
329
  if (channel.startsWith(PLUGIN)) {
303
330
  handlePluginChannel(channel, payload);
304
331
  return;
@@ -309,6 +336,7 @@ function createSuperLineServer(contract, opts) {
309
336
  if (busSet) deliverBus(payload, busSet);
310
337
  });
311
338
  void adapter.subscribe(replyChannel);
339
+ if (collectionStore && collIsRelay) void adapter.subscribe(COLL_CHANNEL);
312
340
  function handlePersonal(channel, payload) {
313
341
  const set = members.get(channel);
314
342
  if (!set) return;
@@ -542,6 +570,7 @@ function createSuperLineServer(contract, opts) {
542
570
  raw.onClose((code) => {
543
571
  conns.delete(conn);
544
572
  for (const channel of conn.channels) leaveChannel(conn, channel);
573
+ collUnsubAll(conn);
545
574
  void adapter.presence?.del(conn.id);
546
575
  const goneUserId = opts.identify?.(conn);
547
576
  emitTap({
@@ -580,6 +609,9 @@ function createSuperLineServer(contract, opts) {
580
609
  else if (frame.t === "srd") await handleStoreRead(conn, frame);
581
610
  else if (frame.t === "swr") await handleStoreWrite(conn, frame);
582
611
  else if (frame.t === "sclose") handleStoreClose(conn, frame);
612
+ else if (frame.t === "csub") await handleCollectionSub(conn, frame);
613
+ else if (frame.t === "cuns") handleCollectionUnsub(conn, frame);
614
+ else if (frame.t === "cbat") await handleCollectionBatch(conn, frame);
583
615
  else if (frame.t === "sres") {
584
616
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
585
617
  } else if (frame.t === "serr") {
@@ -779,6 +811,133 @@ function createSuperLineServer(contract, opts) {
779
811
  relaying = false;
780
812
  }
781
813
  }
814
+ const collConnState = (conn) => {
815
+ let s = connColl.get(conn);
816
+ if (!s) connColl.set(conn, s = { subs: /* @__PURE__ */ new Map(), policy: /* @__PURE__ */ new Map() });
817
+ return s;
818
+ };
819
+ function collUnsubAll(conn) {
820
+ connColl.delete(conn);
821
+ for (const set of collSubscribers.values()) set.delete(conn);
822
+ }
823
+ async function handleCollectionSub(conn, frame) {
824
+ if (!collectionStore || !collectionDefs[frame.n]) {
825
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown collection: ${frame.n}` });
826
+ return;
827
+ }
828
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `collection:${frame.n}`, conn }, async () => {
829
+ const principal = conn.principal ?? conn.id;
830
+ const policy = collectionPolicies[frame.n];
831
+ if (!policy?.read) throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}`);
832
+ const policyFilter = await policy.read(principal, conn.ctx);
833
+ const eff = andFilters(policyFilter, frame.q.filter);
834
+ const rows = await collectionStore.snapshot(frame.n, { ...frame.q, filter: eff });
835
+ const state = collConnState(conn);
836
+ let subs = state.subs.get(frame.n);
837
+ if (!subs) state.subs.set(frame.n, subs = /* @__PURE__ */ new Map());
838
+ subs.set(frame.s, frame.q);
839
+ state.policy.set(frame.n, policyFilter);
840
+ let set = collSubscribers.get(frame.n);
841
+ if (!set) collSubscribers.set(frame.n, set = /* @__PURE__ */ new Set());
842
+ set.add(conn);
843
+ conn.send({ t: "res", i: frame.i, d: rows });
844
+ });
845
+ }
846
+ function handleCollectionUnsub(conn, frame) {
847
+ const state = connColl.get(conn);
848
+ const subs = state?.subs.get(frame.n);
849
+ if (!state || !subs) return;
850
+ subs.delete(frame.s);
851
+ if (subs.size === 0) {
852
+ state.subs.delete(frame.n);
853
+ state.policy.delete(frame.n);
854
+ collSubscribers.get(frame.n)?.delete(conn);
855
+ }
856
+ }
857
+ async function resolveCollectionOps(ops, principal, ctx) {
858
+ const store = collectionStore;
859
+ if (!store) throw new SuperLineError("NOT_FOUND", "No collection backend configured");
860
+ const out = [];
861
+ for (const op of ops) {
862
+ const def = collectionDefs[op.n];
863
+ if (!def) throw new SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
864
+ const policy = collectionPolicies[op.n];
865
+ if (!policy?.write) throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}`);
866
+ const prev = await store.read(op.n, op.id);
867
+ if (op.op === "delete") {
868
+ if (!await policy.write(principal, "delete", void 0, prev, ctx))
869
+ throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
870
+ out.push({ op: "delete", n: op.n, id: op.id });
871
+ continue;
872
+ }
873
+ const row = await validate(def.schema, op.d);
874
+ const key = row[def.key];
875
+ if (typeof key !== "string")
876
+ throw new SuperLineError("VALIDATION", `Collection ${op.n} row is missing string key '${def.key}'`);
877
+ if (key !== op.id) throw new SuperLineError("VALIDATION", `Row key '${key}' does not match op id '${op.id}'`);
878
+ if (checkReferences && def.references) {
879
+ for (const [field, refCollection] of Object.entries(def.references)) {
880
+ const ref = row[field];
881
+ if (ref === void 0 || ref === null) continue;
882
+ if (await store.read(refCollection, String(ref)) === void 0)
883
+ throw new SuperLineError("VALIDATION", `Dangling reference: ${op.n}.${field} \u2192 ${refCollection}/${String(ref)} does not exist`);
884
+ }
885
+ }
886
+ const kind = op.op;
887
+ if (!await policy.write(principal, kind, row, prev, ctx))
888
+ throw new SuperLineError("FORBIDDEN", `Write denied: ${op.n}/${op.id}`);
889
+ out.push({ op: kind, n: op.n, id: op.id, row });
890
+ }
891
+ return out;
892
+ }
893
+ async function commitCollectionBatch(ops, origin, relay) {
894
+ if (ops.length === 0 || !collectionStore) return;
895
+ await collectionStore.apply(ops, origin);
896
+ if (relay && collIsRelay)
897
+ void adapter.publish(COLL_CHANNEL, serializer.encode({ ops, origin, nd: instanceId }));
898
+ }
899
+ async function handleCollectionBatch(conn, frame) {
900
+ if (!collectionStore) {
901
+ conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: "No collection backend configured" });
902
+ return;
903
+ }
904
+ await dispatchOp(conn, frame.i, { kind: "request", name: "collection:batch", conn }, async () => {
905
+ const principal = conn.principal ?? conn.id;
906
+ const resolved = await resolveCollectionOps(frame.ops, principal, conn.ctx);
907
+ await commitCollectionBatch(resolved, principal, true);
908
+ conn.send({ t: "res", i: frame.i, d: null });
909
+ });
910
+ }
911
+ function routeRowChange(change) {
912
+ const conns2 = collSubscribers.get(change.n);
913
+ if (!conns2 || conns2.size === 0) return;
914
+ for (const conn of conns2) {
915
+ const state = connColl.get(conn);
916
+ const subs = state?.subs.get(change.n);
917
+ if (!state || !subs || subs.size === 0) continue;
918
+ const eff = andFilters(state.policy.get(change.n), orFilters([...subs.values()].map((q) => q.filter)));
919
+ const prevlessDelete = change.k === "delete" && change.prev === void 0;
920
+ const inPrev = prevlessDelete || change.prev !== void 0 && matchesFilter(eff, change.prev);
921
+ const inNext = change.next !== void 0 && matchesFilter(eff, change.next);
922
+ if (!inPrev && !inNext) continue;
923
+ conn.send({ t: "cchg", n: change.n, k: change.k, id: change.id, d: change.next });
924
+ }
925
+ }
926
+ function handleCollectionRelay(payload) {
927
+ let env;
928
+ try {
929
+ env = serializer.decode(payload);
930
+ } catch {
931
+ return;
932
+ }
933
+ if (env.nd === instanceId) return;
934
+ if (!collectionStore || !collIsRelay) return;
935
+ try {
936
+ void collectionStore.apply(env.ops, env.origin);
937
+ } catch {
938
+ }
939
+ }
940
+ if (collectionStore) collectionStore.onChange(routeRowChange);
782
941
  function room(name) {
783
942
  const channel = ROOM + name;
784
943
  return {
@@ -1100,6 +1259,38 @@ function createSuperLineServer(contract, opts) {
1100
1259
  if (!handle) throw new SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1101
1260
  return handle;
1102
1261
  },
1262
+ collection(name) {
1263
+ if (!collectionStore) throw new SuperLineError("NOT_FOUND", "No collection backend configured");
1264
+ const def = collectionDefs[name];
1265
+ if (!def) throw new SuperLineError("NOT_FOUND", `Collection not declared: ${name}`);
1266
+ const store = collectionStore;
1267
+ const resolve = async (row) => {
1268
+ const v = await validate(def.schema, row);
1269
+ const key = v[def.key];
1270
+ if (typeof key !== "string")
1271
+ throw new SuperLineError("VALIDATION", `Collection ${name} row is missing string key '${def.key}'`);
1272
+ return { id: key, row: v };
1273
+ };
1274
+ return {
1275
+ async insert(row) {
1276
+ const { id, row: v } = await resolve(row);
1277
+ await commitCollectionBatch([{ op: "insert", n: name, id, row: v }], SERVER_ORIGIN, true);
1278
+ },
1279
+ async update(row) {
1280
+ const { id, row: v } = await resolve(row);
1281
+ await commitCollectionBatch([{ op: "update", n: name, id, row: v }], SERVER_ORIGIN, true);
1282
+ },
1283
+ async delete(id) {
1284
+ await commitCollectionBatch([{ op: "delete", n: name, id }], SERVER_ORIGIN, true);
1285
+ },
1286
+ read(id) {
1287
+ return Promise.resolve(store.read(name, id));
1288
+ },
1289
+ snapshot(query) {
1290
+ return Promise.resolve(store.snapshot(name, query ?? {}));
1291
+ }
1292
+ };
1293
+ },
1103
1294
  async close() {
1104
1295
  if (closing) return;
1105
1296
  closing = true;
@@ -1158,6 +1349,8 @@ function createSuperLineServer(contract, opts) {
1158
1349
  },
1159
1350
  store: (name) => api.store(name),
1160
1351
  storeInfos: () => Object.entries(storeMap).map(([name, store]) => ({ name, model: store.model })),
1352
+ collection: (name) => api.collection(name),
1353
+ collectionInfos: () => Object.entries(collectionDefs).map(([name, def]) => ({ name, key: def.key, references: def.references ?? {} })),
1161
1354
  describe: (conn) => buildDescriptor(conn),
1162
1355
  connectionById: (id) => Promise.resolve(presenceOrThrow().get(id)),
1163
1356
  channel: (name) => pluginChannel(pluginName, name)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-line/server",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Realtime data-bus server for super-line — requests, events, rooms, topics, stores, pluggable transports & adapters.",
6
6
  "license": "MIT",
@@ -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.10.1"
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",
71
+ "@super-line/adapter-libp2p": "0.6.0",
72
72
  "@super-line/adapter-zeromq": "0.5.0",
73
- "@super-line/adapter-rabbitmq": "0.5.0",
74
73
  "@super-line/adapter-redis": "0.5.0",
75
74
  "@super-line/plugin-inspector": "0.1.0",
76
- "@super-line/transport-http": "0.5.0",
77
- "@super-line/transport-websocket": "0.6.0",
75
+ "@super-line/adapter-rabbitmq": "0.5.0",
76
+ "@super-line/client": "0.8.0",
78
77
  "@super-line/transport-loopback": "0.5.0",
79
- "@super-line/adapter-libp2p": "0.6.0"
78
+ "@super-line/transport-websocket": "0.6.0",
79
+ "@super-line/transport-http": "0.5.0"
80
80
  },
81
81
  "optionalDependencies": {
82
82
  "@standard-community/standard-json": "^0.3.5"