@absolutejs/sync 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,9 +32,10 @@ top-N ordering are maintained incrementally through a composable operator graph
32
32
  > Status: early (`0.0.1`). Tier 1 (hub, SSE plugin, browser subscriber,
33
33
  > write-behind cache), Tier 2 (Drizzle + Prisma topic adapters, `createLiveQuery`),
34
34
  > and Tier 3 (sync engine: collections, WebSocket diff transport, optimistic
35
- > mutations + offline queue, CDC for Postgres/MySQL/SQLite, incremental
36
- > aggregations + joins, and a declarative operator graph) are in place.
37
- > Everything ships as subpaths of this one package.
35
+ > mutations + offline queue, a local-first client cache, declarative row-level
36
+ > permissions, live full-text + vector search, CDC for Postgres/MySQL/SQLite,
37
+ > incremental aggregations + joins, and a declarative operator graph) are in
38
+ > place. Everything ships as subpaths of this one package.
38
39
 
39
40
  ## Install
40
41
 
@@ -229,6 +230,55 @@ await orders.mutate({
229
230
  server's changelog still covers it, a fresh snapshot otherwise).
230
231
  - **Access control is mandatory.** Each collection's `authorize` gates subscribe and
231
232
  its filter scopes rows, so a change to a row a caller can't see never reaches them.
233
+ - **Declarative permissions.** Instead of restating a row filter across `authorize`,
234
+ `hydrate`, and `match`, register row-level rules once with `definePermissions` and
235
+ the engine enforces them: `read` rules filter every row emitted (initial snapshot,
236
+ incremental diff, catch-up, one-shot hydrate, and a reactive query's `ctx.db`
237
+ reads); `insert`/`update`/`delete`/`write` rules gate the mutation actions. For
238
+ `update`/`delete` the rule is checked against the _existing_ row (loaded via the
239
+ table's reader), so it can't be spoofed by the client payload.
240
+
241
+ ```ts
242
+ const engine = createSyncEngine({
243
+ permissions: definePermissions<{ userId: number }>({
244
+ tasks: {
245
+ read: (ctx, row) => row.userId === ctx.userId, // see only your rows
246
+ write: (ctx, row) => row.userId === ctx.userId // touch only your rows
247
+ }
248
+ })
249
+ });
250
+ ```
251
+
252
+ - **Live search.** A `defineSearchCollection` is a full-text or vector index kept
253
+ live from a table's change feed. The subscription's `params` are the query (a
254
+ string for keyword search, an embedding for similarity); the ranked top-K stream
255
+ back as an ordinary collection and re-rank as rows change. Read permissions on
256
+ the source table still scope a caller's hits. Standalone, `createTextIndex` and
257
+ `createVectorIndex` are reusable (e.g. RAG retrieval with `@absolutejs/rag`).
258
+
259
+ ```ts
260
+ // server
261
+ engine.registerSearch(
262
+ defineSearchCollection<Doc>({
263
+ name: 'docSearch',
264
+ table: 'docs',
265
+ index: () =>
266
+ createTextIndex({
267
+ key: (d) => d.id,
268
+ fields: ['title', 'body']
269
+ }),
270
+ source: () => db.select().from(docs), // the corpus to index
271
+ key: (d) => d.id
272
+ })
273
+ );
274
+
275
+ // client — params are the query; each result row carries `_score`
276
+ const results = createSyncCollection<Doc>({
277
+ url,
278
+ collection: 'docSearch',
279
+ params: 'quick brown fox' // a vector for createVectorIndex
280
+ });
281
+ ```
232
282
 
233
283
  ## Write-behind cache — keep a remote store off your hot path
234
284
 
@@ -309,20 +359,24 @@ mutate({
309
359
 
310
360
  ### `@absolutejs/sync/engine`
311
361
 
312
- | Export | What it is |
313
- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
- | `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
315
- | `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
316
- | `defineMutation({ name, handler, authorize? })` | Define a server mutation. Its `handler` gets `actions.insert/update/delete` (write through a registered `TableWriter` → persists + emits in one step) plus `actions.change` (escape hatch). Changes commit atomically. |
317
- | `registerWriter(table, { insert, update, delete })` | Teach the engine how to persist a table (any ORM), so writes auto-emit — you can't write without going live. |
318
- | `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
319
- | `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
320
- | `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
321
- | `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
322
- | `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
323
- | `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
324
- | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
325
- | `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader` | Read-set-tracked query: `run(ctx)` reads via `ctx.db` (`all`/`get`/`where`) and re-runs only when the rows/ranges it read change — no `match`, no manual emit. |
362
+ | Export | What it is |
363
+ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
364
+ | `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
365
+ | `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
366
+ | `defineMutation({ name, handler, authorize? })` | Define a server mutation. Its `handler` gets `actions.insert/update/delete` (write through a registered `TableWriter` → persists + emits in one step) plus `actions.change` (escape hatch). Changes commit atomically. |
367
+ | `registerWriter(table, { insert, update, delete })` | Teach the engine how to persist a table (any ORM), so writes auto-emit — you can't write without going live. |
368
+ | `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
369
+ | `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
370
+ | `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
371
+ | `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
372
+ | `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
373
+ | `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
374
+ | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
375
+ | `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader` | Read-set-tracked query: `run(ctx)` reads via `ctx.db` (`all`/`get`/`where`) and re-runs only when the rows/ranges it read change — no `match`, no manual emit. |
376
+ | `definePermissions({ [table]: { read?, insert?, update?, delete?, write? } })` | Declarative row-level access control. Pass as `createSyncEngine({ permissions })` or `registerPermissions(table, rules)`. Read rules filter every row emitted; write rules gate `actions.insert/update/delete`. |
377
+ | `defineSearchCollection({ name, table, index, source, key, limit? })` + `registerSearch` | Live search collection: the subscription's `params` are the query (string/vector), the ranked top-K stream back as a normal collection, re-ranked as rows change. Each row carries its score under `_score`. |
378
+ | `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
379
+ | `createVectorIndex({ key, embedding, metric? })` | Incremental vector index (cosine/dot/euclidean exact k-NN) for semantic search — pairs with `@absolutejs/ai` / `@absolutejs/rag` for RAG retrieval on your own data. |
326
380
 
327
381
  ### `@absolutejs/sync/postgres`
328
382
 
@@ -30,6 +30,14 @@ export { defineReactiveQuery } from './reactive';
30
30
  export type { ReactiveQueryContext, ReactiveQueryDefinition, ReadHandle, TableReader } from './reactive';
31
31
  export { defineGraphCollection, query } from './graph';
32
32
  export type { GraphCollectionDefinition, GraphInstance, GraphSource, GroupByOptions, JoinOptions, OrderByQueryOptions, Query } from './graph';
33
+ export { definePermissions } from './permissions';
34
+ export type { PermissionsDefinition, ReadRule, TablePermissions, WriteRule } from './permissions';
35
+ export { defineSearchCollection, SEARCH_SCORE_FIELD } from './search';
36
+ export type { SearchCollectionDefinition, SearchHit, SearchIndex } from './search';
37
+ export { createTextIndex } from './textIndex';
38
+ export type { TextIndexOptions } from './textIndex';
39
+ export { createVectorIndex } from './vectorIndex';
40
+ export type { VectorIndexOptions, VectorMetric } from './vectorIndex';
33
41
  export { defineMutation } from './mutation';
34
42
  export type { MutationActions, MutationDefinition, MutationHandler, TableWriter, TransactionRunner } from './mutation';
35
43
  export { createSyncEngine, UnauthorizedError } from './syncEngine';
@@ -883,6 +883,158 @@ var makeQuery = (source, steps) => {
883
883
  };
884
884
  var query = (source) => makeQuery(source, []);
885
885
  var defineGraphCollection = (definition) => ({ ...definition, kind: "graph" });
886
+ // src/engine/permissions.ts
887
+ var definePermissions = (permissions) => permissions;
888
+ // src/engine/search.ts
889
+ var SEARCH_SCORE_FIELD = "_score";
890
+ var defineSearchCollection = (definition) => ({
891
+ ...definition,
892
+ kind: "search"
893
+ });
894
+ // src/engine/textIndex.ts
895
+ var defaultTokenize = (text) => text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
896
+ var createTextIndex = (options) => {
897
+ const { key, fields } = options;
898
+ const tokenize = options.tokenize ?? defaultTokenize;
899
+ const stopwords = new Set(options.stopwords ?? []);
900
+ const k1 = options.k1 ?? 1.5;
901
+ const b = options.b ?? 0.75;
902
+ const docs = new Map;
903
+ const postings = new Map;
904
+ let totalLen = 0;
905
+ const termsOf = (row) => {
906
+ const text = fields.map((field) => {
907
+ const value = row[field];
908
+ return value === undefined || value === null ? "" : String(value);
909
+ }).join(" ");
910
+ return tokenize(text).filter((term) => !stopwords.has(term));
911
+ };
912
+ const remove = (rowKey) => {
913
+ const doc = docs.get(rowKey);
914
+ if (doc === undefined) {
915
+ return;
916
+ }
917
+ for (const term of doc.tf.keys()) {
918
+ const set = postings.get(term);
919
+ if (set !== undefined) {
920
+ set.delete(rowKey);
921
+ if (set.size === 0) {
922
+ postings.delete(term);
923
+ }
924
+ }
925
+ }
926
+ totalLen -= doc.len;
927
+ docs.delete(rowKey);
928
+ };
929
+ const add = (row) => {
930
+ const rowKey = key(row);
931
+ remove(rowKey);
932
+ const terms = termsOf(row);
933
+ const tf = new Map;
934
+ for (const term of terms) {
935
+ tf.set(term, (tf.get(term) ?? 0) + 1);
936
+ }
937
+ for (const term of tf.keys()) {
938
+ let set = postings.get(term);
939
+ if (set === undefined) {
940
+ set = new Set;
941
+ postings.set(term, set);
942
+ }
943
+ set.add(rowKey);
944
+ }
945
+ docs.set(rowKey, { row, len: terms.length, tf });
946
+ totalLen += terms.length;
947
+ };
948
+ const search = (query2, limit) => {
949
+ const total = docs.size;
950
+ if (total === 0) {
951
+ return [];
952
+ }
953
+ const avgdl = totalLen / total;
954
+ const queryTerms = new Set(tokenize(query2).filter((term) => !stopwords.has(term)));
955
+ const scores = new Map;
956
+ for (const term of queryTerms) {
957
+ const set = postings.get(term);
958
+ if (set === undefined) {
959
+ continue;
960
+ }
961
+ const df = set.size;
962
+ const idf = Math.log(1 + (total - df + 0.5) / (df + 0.5));
963
+ for (const rowKey of set) {
964
+ const doc = docs.get(rowKey);
965
+ const freq = doc.tf.get(term) ?? 0;
966
+ const norm = freq * (k1 + 1) / (freq + k1 * (1 - b + b * doc.len / avgdl));
967
+ scores.set(rowKey, (scores.get(rowKey) ?? 0) + idf * norm);
968
+ }
969
+ }
970
+ return [...scores.entries()].map(([rowKey, score]) => ({ row: docs.get(rowKey).row, score })).sort((first, second) => second.score - first.score).slice(0, limit);
971
+ };
972
+ return {
973
+ add,
974
+ remove,
975
+ search,
976
+ size: () => docs.size,
977
+ clear: () => {
978
+ docs.clear();
979
+ postings.clear();
980
+ totalLen = 0;
981
+ }
982
+ };
983
+ };
984
+ // src/engine/vectorIndex.ts
985
+ var dot = (first, second) => {
986
+ const length = Math.min(first.length, second.length);
987
+ let sum = 0;
988
+ for (let index = 0;index < length; index += 1) {
989
+ sum += first[index] * second[index];
990
+ }
991
+ return sum;
992
+ };
993
+ var normOf = (vec) => Math.sqrt(dot(vec, vec));
994
+ var euclidean = (first, second) => {
995
+ const length = Math.max(first.length, second.length);
996
+ let sum = 0;
997
+ for (let index = 0;index < length; index += 1) {
998
+ const delta = (first[index] ?? 0) - (second[index] ?? 0);
999
+ sum += delta * delta;
1000
+ }
1001
+ return Math.sqrt(sum);
1002
+ };
1003
+ var createVectorIndex = (options) => {
1004
+ const { key, embedding } = options;
1005
+ const metric = options.metric ?? "cosine";
1006
+ const entries = new Map;
1007
+ const score = (query2, queryNorm, entry) => {
1008
+ if (metric === "dot") {
1009
+ return dot(query2, entry.vec);
1010
+ }
1011
+ if (metric === "euclidean") {
1012
+ return -euclidean(query2, entry.vec);
1013
+ }
1014
+ const denominator = queryNorm * entry.norm;
1015
+ return denominator === 0 ? 0 : dot(query2, entry.vec) / denominator;
1016
+ };
1017
+ return {
1018
+ add: (row) => {
1019
+ const vec = embedding(row);
1020
+ entries.set(key(row), { row, vec, norm: normOf(vec) });
1021
+ },
1022
+ remove: (rowKey) => {
1023
+ entries.delete(rowKey);
1024
+ },
1025
+ search: (query2, limit) => {
1026
+ const queryNorm = normOf(query2);
1027
+ return [...entries.values()].map((entry) => ({
1028
+ row: entry.row,
1029
+ score: score(query2, queryNorm, entry)
1030
+ })).sort((first, second) => second.score - first.score).slice(0, limit);
1031
+ },
1032
+ size: () => entries.size,
1033
+ clear: () => {
1034
+ entries.clear();
1035
+ }
1036
+ };
1037
+ };
886
1038
  // src/engine/mutation.ts
887
1039
  var defineMutation = (definition) => definition;
888
1040
  // src/engine/syncEngine.ts
@@ -904,12 +1056,32 @@ var shallowEqual4 = (a, b) => {
904
1056
  const bKeys = Object.keys(b);
905
1057
  return aKeys.length === bKeys.length && aKeys.every((k) => a[k] === b[k]);
906
1058
  };
1059
+ var equalsIgnoringScore = (a, b) => {
1060
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
1061
+ return a === b;
1062
+ }
1063
+ const strip = (value) => Object.keys(value).filter((k) => k !== SEARCH_SCORE_FIELD);
1064
+ const aKeys = strip(a);
1065
+ const bKeys = strip(b);
1066
+ return aKeys.length === bKeys.length && aKeys.every((k) => a[k] === b[k]);
1067
+ };
907
1068
  var createSyncEngine = (options = {}) => {
908
1069
  const registry = new Map;
909
1070
  const mutations = new Map;
910
1071
  const writers = new Map;
911
1072
  const readers = new Map;
1073
+ const permissions = new Map;
1074
+ for (const [table, rules] of Object.entries(options.permissions ?? {})) {
1075
+ permissions.set(table, rules);
1076
+ }
1077
+ const readRuleFor = (table) => permissions.get(table)?.read;
1078
+ const writeRuleFor = (table, op) => {
1079
+ const rules = permissions.get(table);
1080
+ return rules?.[op] ?? rules?.write;
1081
+ };
912
1082
  const reactiveSubs = new Set;
1083
+ const searchSubs = new Set;
1084
+ const searchIndexes = new Map;
913
1085
  const active = new Map;
914
1086
  const tableIndex = new Map;
915
1087
  const changeLogSize = options.changeLogSize ?? 1024;
@@ -962,6 +1134,9 @@ var createSyncEngine = (options = {}) => {
962
1134
  if (subscription.kind === "reactive") {
963
1135
  return EMPTY_DIFF;
964
1136
  }
1137
+ if (subscription.kind === "search") {
1138
+ return EMPTY_DIFF;
1139
+ }
965
1140
  if (subscription.incremental) {
966
1141
  try {
967
1142
  return subscription.view.apply(change);
@@ -1037,7 +1212,9 @@ var createSyncEngine = (options = {}) => {
1037
1212
  return {
1038
1213
  all: async (table) => {
1039
1214
  readTables.add(table);
1040
- return [...await readerFor(table).all(ctx)];
1215
+ const rows = [...await readerFor(table).all(ctx)];
1216
+ const rule = readRuleFor(table);
1217
+ return rule ? rows.filter((row) => rule(ctx, row)) : rows;
1041
1218
  },
1042
1219
  get: async (table, key) => {
1043
1220
  const reader = readerFor(table);
@@ -1049,16 +1226,20 @@ var createSyncEngine = (options = {}) => {
1049
1226
  } else {
1050
1227
  readTables.add(table);
1051
1228
  }
1052
- return await reader.get(key, ctx);
1229
+ const row = await reader.get(key, ctx);
1230
+ const rule = readRuleFor(table);
1231
+ return rule && row !== undefined && !rule(ctx, row) ? undefined : row;
1053
1232
  },
1054
1233
  where: async (table, predicate) => {
1055
1234
  const reader = readerFor(table);
1056
- const matched = [...await reader.all(ctx)].filter(predicate);
1235
+ const rule = readRuleFor(table);
1236
+ const effective = rule ? (row) => predicate(row) && rule(ctx, row) : predicate;
1237
+ const matched = [...await reader.all(ctx)].filter(effective);
1057
1238
  if (reader.key !== undefined) {
1058
1239
  const key = reader.key;
1059
1240
  rangeDeps.push({
1060
1241
  table,
1061
- predicate,
1242
+ predicate: effective,
1062
1243
  keys: new Set(matched.map(key))
1063
1244
  });
1064
1245
  } else {
@@ -1068,7 +1249,7 @@ var createSyncEngine = (options = {}) => {
1068
1249
  }
1069
1250
  };
1070
1251
  };
1071
- const diffRerun = (sub, rows) => {
1252
+ const diffRerun = (sub, rows, equals = shallowEqual4) => {
1072
1253
  const next = new Map;
1073
1254
  for (const row of rows) {
1074
1255
  next.set(sub.key(row), row);
@@ -1080,7 +1261,7 @@ var createSyncEngine = (options = {}) => {
1080
1261
  const previous = sub.current.get(rowKey);
1081
1262
  if (previous === undefined) {
1082
1263
  added.push(row);
1083
- } else if (!shallowEqual4(previous, row)) {
1264
+ } else if (!equals(previous, row)) {
1084
1265
  changed.push(row);
1085
1266
  }
1086
1267
  }
@@ -1111,6 +1292,47 @@ var createSyncEngine = (options = {}) => {
1111
1292
  }
1112
1293
  return pairs;
1113
1294
  };
1295
+ const ensureSearchIndex = async (definition) => {
1296
+ let entry = searchIndexes.get(definition.name);
1297
+ if (entry === undefined) {
1298
+ entry = { index: definition.index(), definition, hydrated: false };
1299
+ searchIndexes.set(definition.name, entry);
1300
+ }
1301
+ if (!entry.hydrated) {
1302
+ for (const row of await definition.source()) {
1303
+ entry.index.add(row);
1304
+ }
1305
+ entry.hydrated = true;
1306
+ }
1307
+ return entry;
1308
+ };
1309
+ const searchPairs = (changes) => {
1310
+ const touched = new Set;
1311
+ for (const { table, change } of changes) {
1312
+ for (const entry of searchIndexes.values()) {
1313
+ if (!entry.hydrated || entry.definition.table !== table) {
1314
+ continue;
1315
+ }
1316
+ if (change.op === "delete") {
1317
+ entry.index.remove(entry.definition.key(change.row));
1318
+ } else {
1319
+ entry.index.add(change.row);
1320
+ }
1321
+ touched.add(entry.definition.name);
1322
+ }
1323
+ }
1324
+ const pairs = [];
1325
+ for (const sub of searchSubs) {
1326
+ if (!touched.has(sub.collection)) {
1327
+ continue;
1328
+ }
1329
+ const diff = diffRerun(sub, sub.rerun(), equalsIgnoringScore);
1330
+ if (!isEmptyViewDiff(diff)) {
1331
+ pairs.push([sub, diff]);
1332
+ }
1333
+ }
1334
+ return pairs;
1335
+ };
1114
1336
  const logChange = (changeVersion, entry) => {
1115
1337
  changeLog.push(entry);
1116
1338
  if (changeLog.length > changeLogSize) {
@@ -1131,6 +1353,7 @@ var createSyncEngine = (options = {}) => {
1131
1353
  emissions.push(...await reactivePairs([
1132
1354
  { table, key: changedKeyFor(table, change), row: change.row }
1133
1355
  ]));
1356
+ emissions.push(...searchPairs([{ table, change }]));
1134
1357
  for (const [subscription, diff] of emissions) {
1135
1358
  subscription.onDiff(diff, changeVersion);
1136
1359
  }
@@ -1171,6 +1394,7 @@ var createSyncEngine = (options = {}) => {
1171
1394
  }
1172
1395
  }
1173
1396
  emissions.push(...await reactivePairs(reactiveChanges));
1397
+ emissions.push(...searchPairs(changes));
1174
1398
  for (const [subscription, diff] of emissions) {
1175
1399
  subscription.onDiff(diff, batchVersion);
1176
1400
  }
@@ -1313,6 +1537,50 @@ var createSyncEngine = (options = {}) => {
1313
1537
  }
1314
1538
  };
1315
1539
  };
1540
+ const subscribeSearch = async (collection, definition, params, ctx, onDiff, set) => {
1541
+ const query2 = params;
1542
+ if (definition.authorize !== undefined) {
1543
+ const allowed = await definition.authorize(query2, ctx);
1544
+ if (!allowed) {
1545
+ throw new UnauthorizedError(`subscribe to collection "${collection}"`);
1546
+ }
1547
+ }
1548
+ const entry = await ensureSearchIndex(definition);
1549
+ const limit = definition.limit ?? 20;
1550
+ const readRule = readRuleFor(definition.table);
1551
+ const rerun = () => {
1552
+ const candidates = entry.index.search(query2, readRule ? limit * 5 : limit);
1553
+ const visible = readRule ? candidates.filter((hit) => readRule(ctx, hit.row)) : candidates;
1554
+ return visible.slice(0, limit).map((hit) => ({
1555
+ ...hit.row,
1556
+ [SEARCH_SCORE_FIELD]: hit.score
1557
+ }));
1558
+ };
1559
+ const initial = rerun();
1560
+ const current = new Map;
1561
+ for (const row of initial) {
1562
+ current.set(definition.key(row), row);
1563
+ }
1564
+ const atVersion = version;
1565
+ const subscription = {
1566
+ kind: "search",
1567
+ collection,
1568
+ key: definition.key,
1569
+ rerun,
1570
+ current,
1571
+ onDiff
1572
+ };
1573
+ set.add(subscription);
1574
+ searchSubs.add(subscription);
1575
+ return {
1576
+ initial,
1577
+ version: atVersion,
1578
+ unsubscribe: () => {
1579
+ set.delete(subscription);
1580
+ searchSubs.delete(subscription);
1581
+ }
1582
+ };
1583
+ };
1316
1584
  return {
1317
1585
  register: (collection) => {
1318
1586
  registry.set(collection.name, collection);
@@ -1331,6 +1599,9 @@ var createSyncEngine = (options = {}) => {
1331
1599
  addTableIndex(table, collection.name);
1332
1600
  }
1333
1601
  },
1602
+ registerSearch: (collection) => {
1603
+ registry.set(collection.name, collection);
1604
+ },
1334
1605
  subscribe: async ({ collection, params, ctx, onDiff, since }) => {
1335
1606
  const registered = registry.get(collection);
1336
1607
  if (registered === undefined) {
@@ -1351,6 +1622,10 @@ var createSyncEngine = (options = {}) => {
1351
1622
  const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1352
1623
  return reactived;
1353
1624
  }
1625
+ if (registeredKind === "search") {
1626
+ const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1627
+ return searched;
1628
+ }
1354
1629
  const definition = registered;
1355
1630
  if (definition.authorize !== undefined) {
1356
1631
  const allowed = await definition.authorize(params, ctx);
@@ -1359,11 +1634,12 @@ var createSyncEngine = (options = {}) => {
1359
1634
  }
1360
1635
  }
1361
1636
  const key = definition.key ?? defaultKey;
1362
- const rehydrate = async () => definition.hydrate(params, ctx);
1363
1637
  const match = definition.match;
1364
1638
  const tables = definition.tables ?? [collection];
1639
+ const readRule = tables.length === 1 ? readRuleFor(tables[0]) : undefined;
1640
+ const rehydrate = readRule ? async () => [...await definition.hydrate(params, ctx)].filter((row) => readRule(ctx, row)) : async () => definition.hydrate(params, ctx);
1365
1641
  const incremental = match !== undefined && tables.length === 1;
1366
- const boundMatch = incremental ? (row) => match(row, params, ctx) : () => true;
1642
+ const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
1367
1643
  const view = createMaterializedView({
1368
1644
  key,
1369
1645
  match: boundMatch
@@ -1409,7 +1685,10 @@ var createSyncEngine = (options = {}) => {
1409
1685
  throw new UnauthorizedError(`hydrate collection "${collection}"`);
1410
1686
  }
1411
1687
  }
1412
- return [...await definition.hydrate(params, ctx)];
1688
+ const rows = [...await definition.hydrate(params, ctx)];
1689
+ const tables = definition.tables ?? [collection];
1690
+ const readRule = tables.length === 1 ? readRuleFor(tables[0]) : undefined;
1691
+ return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
1413
1692
  },
1414
1693
  applyChange: (table, change) => applyChange(table, change),
1415
1694
  connectSource: async (source) => {
@@ -1453,6 +1732,9 @@ var createSyncEngine = (options = {}) => {
1453
1732
  registerReader: (table, reader) => {
1454
1733
  readers.set(table, reader);
1455
1734
  },
1735
+ registerPermissions: (table, rules) => {
1736
+ permissions.set(table, rules);
1737
+ },
1456
1738
  runMutation: async (name, args, ctx) => {
1457
1739
  const mutation = mutations.get(name);
1458
1740
  if (mutation === undefined) {
@@ -1471,6 +1753,28 @@ var createSyncEngine = (options = {}) => {
1471
1753
  }
1472
1754
  return writer;
1473
1755
  };
1756
+ const authorizeWrite = async (table, op, value) => {
1757
+ const rule = writeRuleFor(table, op);
1758
+ if (rule === undefined) {
1759
+ return;
1760
+ }
1761
+ let subject = value;
1762
+ if (op !== "insert") {
1763
+ const reader = readers.get(table);
1764
+ if (reader?.get !== undefined) {
1765
+ const id = reader.key ? reader.key(value) : value.id;
1766
+ if (id !== undefined) {
1767
+ const existing = await reader.get(id, ctx);
1768
+ if (existing !== undefined) {
1769
+ subject = existing;
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1774
+ if (!rule(ctx, subject)) {
1775
+ throw new UnauthorizedError(`${op} on table "${table}"`);
1776
+ }
1777
+ };
1474
1778
  const runHandler = async (tx) => {
1475
1779
  const buffered2 = [];
1476
1780
  const actions = {
@@ -1482,16 +1786,19 @@ var createSyncEngine = (options = {}) => {
1482
1786
  return Promise.resolve();
1483
1787
  },
1484
1788
  insert: async (table, data) => {
1789
+ await authorizeWrite(table, "insert", data);
1485
1790
  const row = await writerFor(table).insert(data, ctx, tx);
1486
1791
  buffered2.push({ table, change: { op: "insert", row } });
1487
1792
  return row;
1488
1793
  },
1489
1794
  update: async (table, data) => {
1795
+ await authorizeWrite(table, "update", data);
1490
1796
  const row = await writerFor(table).update(data, ctx, tx);
1491
1797
  buffered2.push({ table, change: { op: "update", row } });
1492
1798
  return row;
1493
1799
  },
1494
1800
  delete: async (table, row) => {
1801
+ await authorizeWrite(table, "delete", row);
1495
1802
  await writerFor(table).delete(row, ctx, tx);
1496
1803
  buffered2.push({ table, change: { op: "delete", row } });
1497
1804
  }
@@ -1752,11 +2059,15 @@ export {
1752
2059
  hydrateRoute,
1753
2060
  fromRowChange,
1754
2061
  filterOp,
2062
+ defineSearchCollection,
1755
2063
  defineReactiveQuery,
2064
+ definePermissions,
1756
2065
  defineMutation,
1757
2066
  defineJoinCollection,
1758
2067
  defineGraphCollection,
1759
2068
  defineCollection,
2069
+ createVectorIndex,
2070
+ createTextIndex,
1760
2071
  createSyncEngine,
1761
2072
  createSyncConnection,
1762
2073
  createPresenceHub,
@@ -1767,8 +2078,9 @@ export {
1767
2078
  createAggregate,
1768
2079
  chain,
1769
2080
  aggregateOp,
1770
- UnauthorizedError
2081
+ UnauthorizedError,
2082
+ SEARCH_SCORE_FIELD
1771
2083
  };
1772
2084
 
1773
- //# debugId=4ADB152722C87EA464756E2164756E21
2085
+ //# debugId=28E08370F43FEBAE64756E2164756E21
1774
2086
  //# sourceMappingURL=index.js.map