@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 +190 -0
- package/dist/index.d.cts +88 -4
- package/dist/index.d.ts +88 -4
- package/dist/index.js +194 -1
- package/package.json +7 -7
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
77
|
-
"@super-line/
|
|
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/
|
|
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"
|