@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 +71 -17
- package/dist/engine/index.d.ts +8 -0
- package/dist/engine/index.js +323 -11
- package/dist/engine/index.js.map +8 -4
- package/dist/engine/permissions.d.ts +51 -0
- package/dist/engine/search.d.ts +61 -0
- package/dist/engine/syncEngine.d.ts +24 -0
- package/dist/engine/textIndex.d.ts +33 -0
- package/dist/engine/vectorIndex.d.ts +27 -0
- package/package.json +1 -1
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,
|
|
36
|
-
>
|
|
37
|
-
>
|
|
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
|
|
313
|
-
|
|
|
314
|
-
| `createSyncEngine()`
|
|
315
|
-
| `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })`
|
|
316
|
-
| `defineMutation({ name, handler, authorize? })`
|
|
317
|
-
| `registerWriter(table, { insert, update, delete })`
|
|
318
|
-
| `createAggregate({ key, groupBy?, value? })`
|
|
319
|
-
| `createMaterializedView({ key, match, equals? })`
|
|
320
|
-
| `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })`
|
|
321
|
-
| `engine.connectCluster(bus)` + `createInMemoryClusterBus()`
|
|
322
|
-
| `createPresenceHub()` + `syncSocket({ engine, presence })`
|
|
323
|
-
| `query(source).filter().map().join().leftJoin().groupBy().orderBy()`
|
|
324
|
-
| `defineGraphCollection({ name, query, key, authorize? })`
|
|
325
|
-
| `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader`
|
|
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
|
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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=
|
|
2085
|
+
//# debugId=28E08370F43FEBAE64756E2164756E21
|
|
1774
2086
|
//# sourceMappingURL=index.js.map
|