@absolutejs/sync 0.8.0 → 0.10.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 +18 -4
- package/dist/crdt/index.d.ts +105 -0
- package/dist/crdt/index.js +256 -0
- package/dist/crdt/index.js.map +10 -0
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +68 -8
- package/dist/engine/index.js.map +5 -4
- package/dist/engine/schema.d.ts +39 -0
- package/dist/engine/syncEngine.d.ts +28 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -33,10 +33,11 @@ top-N ordering are maintained incrementally through a composable operator graph
|
|
|
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
35
|
> mutations + offline queue, a local-first client cache, declarative row-level
|
|
36
|
-
> permissions,
|
|
37
|
-
>
|
|
38
|
-
>
|
|
39
|
-
>
|
|
36
|
+
> permissions, schema validation + lazy migrations, live full-text + vector
|
|
37
|
+
> search, scheduled functions, a live devtools dashboard, conflict-free
|
|
38
|
+
> collaborative editing (CRDTs), CDC for Postgres/MySQL/SQLite, incremental
|
|
39
|
+
> aggregations + joins, and a declarative operator graph) are in place.
|
|
40
|
+
> Everything ships as subpaths of this one package.
|
|
40
41
|
|
|
41
42
|
## Install
|
|
42
43
|
|
|
@@ -403,11 +404,24 @@ mutate({
|
|
|
403
404
|
| `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
|
|
404
405
|
| `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. |
|
|
405
406
|
| `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`. |
|
|
407
|
+
| `defineSchema({ [table]: { fields, version?, migrate? } })` + `field` kit | Declarative row schema. Pass as `createSyncEngine({ schemas })` or `registerSchema(table, schema)`. Writes are validated (bad write → `SchemaError`); `migrate` lazily upcasts rows on read (no DB migration needed). |
|
|
406
408
|
| `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`. |
|
|
407
409
|
| `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
|
|
408
410
|
| `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. |
|
|
409
411
|
| `defineSchedule({ name, pattern, run })` + `registerSchedule` / `runSchedule` | Scheduled function: `run({ db, actions })` fires on a cron `pattern`; its writes go live through the change feed. Wire triggers with the `scheduled` plugin (or call `runSchedule(name)` on demand). |
|
|
410
412
|
|
|
413
|
+
### `@absolutejs/sync/crdt`
|
|
414
|
+
|
|
415
|
+
Conflict-free replicated data types — pure, **zero-dependency**, and isomorphic (same code client and server). They merge concurrent edits from different tabs/devices without a server round-trip per keystroke and without clobbering: every `merge` is commutative, associative, and idempotent, so replicas converge no matter the order. They ride the existing engine with no engine changes — store the CRDT state as a row field and have a mutation `merge` the incoming state into the stored one.
|
|
416
|
+
|
|
417
|
+
| Export | What it is |
|
|
418
|
+
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
419
|
+
| `counter` | PN-counter: `create/value/increment/decrement/merge`. Concurrent increments and decrements across replicas all survive. |
|
|
420
|
+
| `lww` | Last-write-wins register: `create/set/merge`. The latest timestamp wins (replica id breaks ties) — for "just take the newest value" fields. |
|
|
421
|
+
| `createTextCrdt(replica, initial?)` | Collaborative text (an RGA sequence CRDT): `text/insert/delete/setText/merge/state`. Drive it from an input via `setText`; broadcast `state()`; apply remote state via `merge` — concurrent edits merge and converge. |
|
|
422
|
+
| `textOf(state)` / `mergeTextState(a, b)` | Pure helpers for the text state — use them server-side (e.g. a merge-on-write mutation) with no live instance. |
|
|
423
|
+
| `CrdtText<State>` / `TextCrdtAdapter<State>` | The pluggable collaborative-text contract. `rgaText` is the first-party (zero-dep) backend; swap in an adapter from the `sync-adapters` repo (e.g. `@absolutejs/sync-yjs`, which wraps the Yjs staple) behind the same call sites. |
|
|
424
|
+
|
|
411
425
|
### `@absolutejs/sync/postgres`
|
|
412
426
|
|
|
413
427
|
| Export | What it is |
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict-free replicated data types (CRDTs) for multiplayer/offline editing —
|
|
3
|
+
* pure, dependency-free, and isomorphic (use the same code client and server).
|
|
4
|
+
*
|
|
5
|
+
* These are *state-based* CRDTs (CvRDTs): every `merge` is commutative,
|
|
6
|
+
* associative, and idempotent, so replicas that exchange state in any order
|
|
7
|
+
* converge to the same value. That fits the sync engine without engine changes:
|
|
8
|
+
* store the CRDT state as a row field, have a mutation `merge` the incoming
|
|
9
|
+
* state into the stored one (concurrent writes combine instead of clobbering),
|
|
10
|
+
* and have each client merge the broadcast state into its local edits.
|
|
11
|
+
*/
|
|
12
|
+
/** A counter that survives concurrent increments/decrements across replicas. */
|
|
13
|
+
export type CounterState = {
|
|
14
|
+
increments: Record<string, number>;
|
|
15
|
+
decrements: Record<string, number>;
|
|
16
|
+
};
|
|
17
|
+
export declare const counter: {
|
|
18
|
+
create: () => CounterState;
|
|
19
|
+
/** Current value: total increments minus total decrements. */
|
|
20
|
+
value: (state: CounterState) => number;
|
|
21
|
+
increment: (state: CounterState, replica: string, by?: number) => CounterState;
|
|
22
|
+
decrement: (state: CounterState, replica: string, by?: number) => CounterState;
|
|
23
|
+
/** Merge by taking the max count seen per replica (monotonic). */
|
|
24
|
+
merge: (a: CounterState, b: CounterState) => CounterState;
|
|
25
|
+
};
|
|
26
|
+
/** A single value where the latest write wins (ties broken by replica id). */
|
|
27
|
+
export type LwwState<T> = {
|
|
28
|
+
value: T;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
replica: string;
|
|
31
|
+
};
|
|
32
|
+
export declare const lww: {
|
|
33
|
+
create: <T>(value: T, replica: string, timestamp?: number) => LwwState<T>;
|
|
34
|
+
set: <T>(value: T, replica: string, timestamp?: number) => LwwState<T>;
|
|
35
|
+
/** Keep the entry with the higher timestamp (replica id breaks ties). */
|
|
36
|
+
merge: <T>(a: LwwState<T>, b: LwwState<T>) => LwwState<T>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The contract a collaborative-text CRDT exposes, independent of the algorithm
|
|
40
|
+
* behind it. Implemented first-party by the RGA below ({@link createTextCrdt})
|
|
41
|
+
* and by third-party backends in the `sync-adapters` repo (e.g.
|
|
42
|
+
* `@absolutejs/sync-yjs`). `State` is whatever that backend persists and
|
|
43
|
+
* broadcasts — JSON ({@link TextState}) for the RGA, a base64 update for Yjs.
|
|
44
|
+
*/
|
|
45
|
+
export type CrdtText<State> = {
|
|
46
|
+
/** The current visible text. */
|
|
47
|
+
text: () => string;
|
|
48
|
+
/** Reconcile the local text to `next` (the backend computes the edit). */
|
|
49
|
+
setText: (next: string) => void;
|
|
50
|
+
/** Merge another replica's state in (e.g. a broadcast from the server). */
|
|
51
|
+
merge: (state: State) => void;
|
|
52
|
+
/** The serializable state to persist/broadcast. */
|
|
53
|
+
state: () => State;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A pluggable collaborative-text backend. `create` mints a live doc for a
|
|
57
|
+
* replica; `merge` combines two persisted states server-side (no live instance
|
|
58
|
+
* needed — for the merge-on-write mutation); `empty`/`textOf` are conveniences.
|
|
59
|
+
* Swap the first-party {@link rgaText} for an adapter to get a different engine
|
|
60
|
+
* (e.g. Yjs) behind the exact same call sites.
|
|
61
|
+
*/
|
|
62
|
+
export type TextCrdtAdapter<State> = {
|
|
63
|
+
create: (replica: string, initial?: State) => CrdtText<State>;
|
|
64
|
+
merge: (a: State, b: State) => State;
|
|
65
|
+
empty: () => State;
|
|
66
|
+
textOf: (state: State) => string;
|
|
67
|
+
};
|
|
68
|
+
/** One inserted character in the replicated sequence (kept as a tombstone if deleted). */
|
|
69
|
+
export type TextElement = {
|
|
70
|
+
id: string;
|
|
71
|
+
replica: string;
|
|
72
|
+
clock: number;
|
|
73
|
+
/** Id of the element this was inserted after (`null` = start of document). */
|
|
74
|
+
after: string | null;
|
|
75
|
+
value: string;
|
|
76
|
+
deleted: boolean;
|
|
77
|
+
};
|
|
78
|
+
/** Serializable state of a {@link TextCrdt} — safe to store as a row field. */
|
|
79
|
+
export type TextState = {
|
|
80
|
+
elements: TextElement[];
|
|
81
|
+
};
|
|
82
|
+
/** The visible string of a text-CRDT state. Pure — use it server-side too. */
|
|
83
|
+
export declare const textOf: (state: TextState) => string;
|
|
84
|
+
/** Merge two text-CRDT states (commutative/idempotent). Pure — for server mutations. */
|
|
85
|
+
export declare const mergeTextState: (a: TextState, b: TextState) => TextState;
|
|
86
|
+
/** The RGA text CRDT — {@link CrdtText} plus direct positional edits. */
|
|
87
|
+
export type TextCrdt = CrdtText<TextState> & {
|
|
88
|
+
/** Insert `value` at visible `index`. */
|
|
89
|
+
insert: (index: number, value: string) => void;
|
|
90
|
+
/** Tombstone `count` visible characters from `index`. */
|
|
91
|
+
delete: (index: number, count: number) => void;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* A collaborative text buffer backed by an RGA sequence CRDT. Concurrent inserts
|
|
95
|
+
* and deletes from different replicas merge without conflict and converge. Drive
|
|
96
|
+
* it from an input via {@link TextCrdt.setText}; persist/broadcast
|
|
97
|
+
* {@link TextCrdt.state}; apply remote state via {@link TextCrdt.merge}.
|
|
98
|
+
*/
|
|
99
|
+
export declare const createTextCrdt: (replica: string, initial?: TextState) => TextCrdt;
|
|
100
|
+
/**
|
|
101
|
+
* The first-party collaborative-text backend (the RGA above) packaged as a
|
|
102
|
+
* {@link TextCrdtAdapter}. Zero dependencies. Use it directly, or swap in an
|
|
103
|
+
* adapter from `sync-adapters` (e.g. `@absolutejs/sync-yjs`) for the same shape.
|
|
104
|
+
*/
|
|
105
|
+
export declare const rgaText: TextCrdtAdapter<TextState>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __name = (target, name) => {
|
|
5
|
+
Object.defineProperty(target, "name", {
|
|
6
|
+
value: name,
|
|
7
|
+
enumerable: false,
|
|
8
|
+
configurable: true
|
|
9
|
+
});
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name);
|
|
13
|
+
var __typeError = (msg) => {
|
|
14
|
+
throw TypeError(msg);
|
|
15
|
+
};
|
|
16
|
+
var __defNormalProp = (obj, key, value) => (key in obj) ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
17
|
+
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
18
|
+
var __privateIn = (member, obj) => Object(obj) !== obj ? __typeError('Cannot use the "in" operator on this value') : member.has(obj);
|
|
19
|
+
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
20
|
+
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
21
|
+
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
22
|
+
var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
|
|
23
|
+
var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"];
|
|
24
|
+
var __expectFn = (fn) => fn !== undefined && typeof fn !== "function" ? __typeError("Function expected") : fn;
|
|
25
|
+
var __decoratorContext = (kind, name, done, metadata, fns) => ({
|
|
26
|
+
kind: __decoratorStrings[kind],
|
|
27
|
+
name,
|
|
28
|
+
metadata,
|
|
29
|
+
addInitializer: (fn) => done._ ? __typeError("Already initialized") : fns.push(__expectFn(fn || null))
|
|
30
|
+
});
|
|
31
|
+
var __decoratorMetadata = (array, target) => __defNormalProp(target, __knownSymbol("metadata"), array[3]);
|
|
32
|
+
var __runInitializers = (array, flags, self, value) => {
|
|
33
|
+
for (var i = 0, fns = array[flags >> 1], n = fns && fns.length;i < n; i++)
|
|
34
|
+
flags & 1 ? fns[i].call(self) : value = fns[i].call(self, value);
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
var __decorateElement = (array, flags, name, decorators, target, extra) => {
|
|
38
|
+
var fn, it, done, ctx, access, k = flags & 7, s = !!(flags & 8), p = !!(flags & 16);
|
|
39
|
+
var j = k > 3 ? array.length + 1 : k ? s ? 1 : 2 : 0, key = __decoratorStrings[k + 5];
|
|
40
|
+
var initializers = k > 3 && (array[j - 1] = []), extraInitializers = array[j] || (array[j] = []);
|
|
41
|
+
var desc = k && (!p && !s && (target = target.prototype), k < 5 && (k > 3 || !p) && __getOwnPropDesc(k < 4 ? target : {
|
|
42
|
+
get [name]() {
|
|
43
|
+
return __privateGet(this, extra);
|
|
44
|
+
},
|
|
45
|
+
set [name](x) {
|
|
46
|
+
__privateSet(this, extra, x);
|
|
47
|
+
}
|
|
48
|
+
}, name));
|
|
49
|
+
k ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name) : __name(target, name);
|
|
50
|
+
for (var i = decorators.length - 1;i >= 0; i--) {
|
|
51
|
+
ctx = __decoratorContext(k, name, done = {}, array[3], extraInitializers);
|
|
52
|
+
if (k) {
|
|
53
|
+
ctx.static = s, ctx.private = p, access = ctx.access = { has: p ? (x) => __privateIn(target, x) : (x) => (name in x) };
|
|
54
|
+
if (k ^ 3)
|
|
55
|
+
access.get = p ? (x) => (k ^ 1 ? __privateGet : __privateMethod)(x, target, k ^ 4 ? extra : desc.get) : (x) => x[name];
|
|
56
|
+
if (k > 2)
|
|
57
|
+
access.set = p ? (x, y) => __privateSet(x, target, y, k ^ 4 ? extra : desc.set) : (x, y) => x[name] = y;
|
|
58
|
+
}
|
|
59
|
+
it = (0, decorators[i])(k ? k < 4 ? p ? extra : desc[key] : k > 4 ? undefined : { get: desc.get, set: desc.set } : target, ctx);
|
|
60
|
+
done._ = 1;
|
|
61
|
+
if (k ^ 4 || it === undefined)
|
|
62
|
+
__expectFn(it) && (k > 4 ? initializers.unshift(it) : k ? p ? extra = it : desc[key] = it : target = it);
|
|
63
|
+
else if (typeof it !== "object" || it === null)
|
|
64
|
+
__typeError("Object expected");
|
|
65
|
+
else
|
|
66
|
+
__expectFn(fn = it.get) && (desc.get = fn), __expectFn(fn = it.set) && (desc.set = fn), __expectFn(fn = it.init) && initializers.unshift(fn);
|
|
67
|
+
}
|
|
68
|
+
return k || __decoratorMetadata(array, target), desc && __defProp(target, name, desc), p ? k ^ 4 ? extra : desc : target;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// src/crdt/index.ts
|
|
72
|
+
var sumValues = (counts) => Object.values(counts).reduce((total, value) => total + value, 0);
|
|
73
|
+
var mergeMax = (a, b) => {
|
|
74
|
+
const merged = { ...a };
|
|
75
|
+
for (const [replica, value] of Object.entries(b)) {
|
|
76
|
+
merged[replica] = Math.max(merged[replica] ?? 0, value);
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
};
|
|
80
|
+
var counter = {
|
|
81
|
+
create: () => ({ increments: {}, decrements: {} }),
|
|
82
|
+
value: (state) => sumValues(state.increments) - sumValues(state.decrements),
|
|
83
|
+
increment: (state, replica, by = 1) => ({
|
|
84
|
+
increments: {
|
|
85
|
+
...state.increments,
|
|
86
|
+
[replica]: (state.increments[replica] ?? 0) + by
|
|
87
|
+
},
|
|
88
|
+
decrements: state.decrements
|
|
89
|
+
}),
|
|
90
|
+
decrement: (state, replica, by = 1) => ({
|
|
91
|
+
increments: state.increments,
|
|
92
|
+
decrements: {
|
|
93
|
+
...state.decrements,
|
|
94
|
+
[replica]: (state.decrements[replica] ?? 0) + by
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
merge: (a, b) => ({
|
|
98
|
+
increments: mergeMax(a.increments, b.increments),
|
|
99
|
+
decrements: mergeMax(a.decrements, b.decrements)
|
|
100
|
+
})
|
|
101
|
+
};
|
|
102
|
+
var lww = {
|
|
103
|
+
create: (value, replica, timestamp = Date.now()) => ({ value, timestamp, replica }),
|
|
104
|
+
set: (value, replica, timestamp = Date.now()) => ({
|
|
105
|
+
value,
|
|
106
|
+
timestamp,
|
|
107
|
+
replica
|
|
108
|
+
}),
|
|
109
|
+
merge: (a, b) => {
|
|
110
|
+
if (b.timestamp > a.timestamp) {
|
|
111
|
+
return b;
|
|
112
|
+
}
|
|
113
|
+
if (b.timestamp < a.timestamp) {
|
|
114
|
+
return a;
|
|
115
|
+
}
|
|
116
|
+
return b.replica > a.replica ? b : a;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
var compare = (a, b) => {
|
|
120
|
+
if (a.clock !== b.clock) {
|
|
121
|
+
return b.clock - a.clock;
|
|
122
|
+
}
|
|
123
|
+
if (a.replica === b.replica) {
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
return a.replica > b.replica ? -1 : 1;
|
|
127
|
+
};
|
|
128
|
+
var linearize = (elements) => {
|
|
129
|
+
const children = new Map;
|
|
130
|
+
for (const element of elements) {
|
|
131
|
+
const list = children.get(element.after);
|
|
132
|
+
if (list === undefined) {
|
|
133
|
+
children.set(element.after, [element]);
|
|
134
|
+
} else {
|
|
135
|
+
list.push(element);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const list of children.values()) {
|
|
139
|
+
list.sort(compare);
|
|
140
|
+
}
|
|
141
|
+
const ordered = [];
|
|
142
|
+
const stack = [...children.get(null) ?? []].reverse();
|
|
143
|
+
while (stack.length > 0) {
|
|
144
|
+
const element = stack.pop();
|
|
145
|
+
ordered.push(element);
|
|
146
|
+
const kids = children.get(element.id);
|
|
147
|
+
if (kids !== undefined) {
|
|
148
|
+
for (let index = kids.length - 1;index >= 0; index -= 1) {
|
|
149
|
+
stack.push(kids[index]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return ordered;
|
|
154
|
+
};
|
|
155
|
+
var textOf = (state) => linearize(state.elements).filter((element) => !element.deleted).map((element) => element.value).join("");
|
|
156
|
+
var mergeTextState = (a, b) => {
|
|
157
|
+
const byId = new Map;
|
|
158
|
+
for (const element of [...a.elements, ...b.elements]) {
|
|
159
|
+
const existing = byId.get(element.id);
|
|
160
|
+
byId.set(element.id, existing === undefined ? element : { ...existing, deleted: existing.deleted || element.deleted });
|
|
161
|
+
}
|
|
162
|
+
return { elements: [...byId.values()] };
|
|
163
|
+
};
|
|
164
|
+
var createTextCrdt = (replica, initial) => {
|
|
165
|
+
const elements = new Map;
|
|
166
|
+
let clock = 0;
|
|
167
|
+
if (initial !== undefined) {
|
|
168
|
+
for (const element of initial.elements) {
|
|
169
|
+
elements.set(element.id, element);
|
|
170
|
+
clock = Math.max(clock, element.clock);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const visible = () => linearize([...elements.values()]).filter((element) => !element.deleted);
|
|
174
|
+
const insert = (index, value) => {
|
|
175
|
+
const seen = visible();
|
|
176
|
+
let after = index <= 0 ? null : seen[index - 1]?.id ?? null;
|
|
177
|
+
for (const char of [...value]) {
|
|
178
|
+
clock += 1;
|
|
179
|
+
const element = {
|
|
180
|
+
id: `${replica}:${clock}`,
|
|
181
|
+
replica,
|
|
182
|
+
clock,
|
|
183
|
+
after,
|
|
184
|
+
value: char,
|
|
185
|
+
deleted: false
|
|
186
|
+
};
|
|
187
|
+
elements.set(element.id, element);
|
|
188
|
+
after = element.id;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const remove = (index, count) => {
|
|
192
|
+
const seen = visible();
|
|
193
|
+
for (let offset = 0;offset < count; offset += 1) {
|
|
194
|
+
const target = seen[index + offset];
|
|
195
|
+
if (target !== undefined) {
|
|
196
|
+
elements.set(target.id, { ...target, deleted: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
text: () => textOf({ elements: [...elements.values()] }),
|
|
202
|
+
insert,
|
|
203
|
+
delete: remove,
|
|
204
|
+
merge: (state) => {
|
|
205
|
+
for (const element of state.elements) {
|
|
206
|
+
const existing = elements.get(element.id);
|
|
207
|
+
elements.set(element.id, existing === undefined ? element : {
|
|
208
|
+
...existing,
|
|
209
|
+
deleted: existing.deleted || element.deleted
|
|
210
|
+
});
|
|
211
|
+
clock = Math.max(clock, element.clock);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
setText: (next) => {
|
|
215
|
+
const current = textOf({ elements: [...elements.values()] });
|
|
216
|
+
if (current === next) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
let prefix = 0;
|
|
220
|
+
const maxPrefix = Math.min(current.length, next.length);
|
|
221
|
+
while (prefix < maxPrefix && current[prefix] === next[prefix]) {
|
|
222
|
+
prefix += 1;
|
|
223
|
+
}
|
|
224
|
+
let suffix = 0;
|
|
225
|
+
while (suffix < maxPrefix - prefix && current[current.length - 1 - suffix] === next[next.length - 1 - suffix]) {
|
|
226
|
+
suffix += 1;
|
|
227
|
+
}
|
|
228
|
+
const removed = current.length - prefix - suffix;
|
|
229
|
+
if (removed > 0) {
|
|
230
|
+
remove(prefix, removed);
|
|
231
|
+
}
|
|
232
|
+
const inserted = next.slice(prefix, next.length - suffix);
|
|
233
|
+
if (inserted.length > 0) {
|
|
234
|
+
insert(prefix, inserted);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
state: () => ({ elements: [...elements.values()] })
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
var rgaText = {
|
|
241
|
+
create: createTextCrdt,
|
|
242
|
+
empty: () => ({ elements: [] }),
|
|
243
|
+
merge: mergeTextState,
|
|
244
|
+
textOf
|
|
245
|
+
};
|
|
246
|
+
export {
|
|
247
|
+
textOf,
|
|
248
|
+
rgaText,
|
|
249
|
+
mergeTextState,
|
|
250
|
+
lww,
|
|
251
|
+
createTextCrdt,
|
|
252
|
+
counter
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
//# debugId=21FF50AD5A6B235464756E2164756E21
|
|
256
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/crdt/index.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Conflict-free replicated data types (CRDTs) for multiplayer/offline editing —\n * pure, dependency-free, and isomorphic (use the same code client and server).\n *\n * These are *state-based* CRDTs (CvRDTs): every `merge` is commutative,\n * associative, and idempotent, so replicas that exchange state in any order\n * converge to the same value. That fits the sync engine without engine changes:\n * store the CRDT state as a row field, have a mutation `merge` the incoming\n * state into the stored one (concurrent writes combine instead of clobbering),\n * and have each client merge the broadcast state into its local edits.\n */\n\nconst sumValues = (counts: Record<string, number>) =>\n\tObject.values(counts).reduce((total, value) => total + value, 0);\n\nconst mergeMax = (\n\ta: Record<string, number>,\n\tb: Record<string, number>\n): Record<string, number> => {\n\tconst merged: Record<string, number> = { ...a };\n\tfor (const [replica, value] of Object.entries(b)) {\n\t\tmerged[replica] = Math.max(merged[replica] ?? 0, value);\n\t}\n\treturn merged;\n};\n\n/* ─── PN-counter ─── */\n\n/** A counter that survives concurrent increments/decrements across replicas. */\nexport type CounterState = {\n\tincrements: Record<string, number>;\n\tdecrements: Record<string, number>;\n};\n\nexport const counter = {\n\tcreate: (): CounterState => ({ increments: {}, decrements: {} }),\n\t/** Current value: total increments minus total decrements. */\n\tvalue: (state: CounterState) =>\n\t\tsumValues(state.increments) - sumValues(state.decrements),\n\tincrement: (\n\t\tstate: CounterState,\n\t\treplica: string,\n\t\tby = 1\n\t): CounterState => ({\n\t\tincrements: {\n\t\t\t...state.increments,\n\t\t\t[replica]: (state.increments[replica] ?? 0) + by\n\t\t},\n\t\tdecrements: state.decrements\n\t}),\n\tdecrement: (\n\t\tstate: CounterState,\n\t\treplica: string,\n\t\tby = 1\n\t): CounterState => ({\n\t\tincrements: state.increments,\n\t\tdecrements: {\n\t\t\t...state.decrements,\n\t\t\t[replica]: (state.decrements[replica] ?? 0) + by\n\t\t}\n\t}),\n\t/** Merge by taking the max count seen per replica (monotonic). */\n\tmerge: (a: CounterState, b: CounterState): CounterState => ({\n\t\tincrements: mergeMax(a.increments, b.increments),\n\t\tdecrements: mergeMax(a.decrements, b.decrements)\n\t})\n};\n\n/* ─── LWW register ─── */\n\n/** A single value where the latest write wins (ties broken by replica id). */\nexport type LwwState<T> = { value: T; timestamp: number; replica: string };\n\nexport const lww = {\n\tcreate: <T>(\n\t\tvalue: T,\n\t\treplica: string,\n\t\ttimestamp = Date.now()\n\t): LwwState<T> => ({ value, timestamp, replica }),\n\tset: <T>(\n\t\tvalue: T,\n\t\treplica: string,\n\t\ttimestamp = Date.now()\n\t): LwwState<T> => ({\n\t\tvalue,\n\t\ttimestamp,\n\t\treplica\n\t}),\n\t/** Keep the entry with the higher timestamp (replica id breaks ties). */\n\tmerge: <T>(a: LwwState<T>, b: LwwState<T>): LwwState<T> => {\n\t\tif (b.timestamp > a.timestamp) {\n\t\t\treturn b;\n\t\t}\n\t\tif (b.timestamp < a.timestamp) {\n\t\t\treturn a;\n\t\t}\n\t\treturn b.replica > a.replica ? b : a;\n\t}\n};\n\n/* ─── Collaborative text ─── */\n\n/**\n * The contract a collaborative-text CRDT exposes, independent of the algorithm\n * behind it. Implemented first-party by the RGA below ({@link createTextCrdt})\n * and by third-party backends in the `sync-adapters` repo (e.g.\n * `@absolutejs/sync-yjs`). `State` is whatever that backend persists and\n * broadcasts — JSON ({@link TextState}) for the RGA, a base64 update for Yjs.\n */\nexport type CrdtText<State> = {\n\t/** The current visible text. */\n\ttext: () => string;\n\t/** Reconcile the local text to `next` (the backend computes the edit). */\n\tsetText: (next: string) => void;\n\t/** Merge another replica's state in (e.g. a broadcast from the server). */\n\tmerge: (state: State) => void;\n\t/** The serializable state to persist/broadcast. */\n\tstate: () => State;\n};\n\n/**\n * A pluggable collaborative-text backend. `create` mints a live doc for a\n * replica; `merge` combines two persisted states server-side (no live instance\n * needed — for the merge-on-write mutation); `empty`/`textOf` are conveniences.\n * Swap the first-party {@link rgaText} for an adapter to get a different engine\n * (e.g. Yjs) behind the exact same call sites.\n */\nexport type TextCrdtAdapter<State> = {\n\tcreate: (replica: string, initial?: State) => CrdtText<State>;\n\tmerge: (a: State, b: State) => State;\n\tempty: () => State;\n\ttextOf: (state: State) => string;\n};\n\n/* ─── Collaborative text (RGA) — the first-party backend ─── */\n\n/** One inserted character in the replicated sequence (kept as a tombstone if deleted). */\nexport type TextElement = {\n\tid: string;\n\treplica: string;\n\tclock: number;\n\t/** Id of the element this was inserted after (`null` = start of document). */\n\tafter: string | null;\n\tvalue: string;\n\tdeleted: boolean;\n};\n\n/** Serializable state of a {@link TextCrdt} — safe to store as a row field. */\nexport type TextState = { elements: TextElement[] };\n\n// Sibling order (same `after`): higher clock first, then higher replica id.\nconst compare = (a: TextElement, b: TextElement) => {\n\tif (a.clock !== b.clock) {\n\t\treturn b.clock - a.clock;\n\t}\n\tif (a.replica === b.replica) {\n\t\treturn 0;\n\t}\n\treturn a.replica > b.replica ? -1 : 1;\n};\n\n/** Flatten the sequence into document order (an iterative RGA pre-order walk). */\nconst linearize = (elements: TextElement[]): TextElement[] => {\n\tconst children = new Map<string | null, TextElement[]>();\n\tfor (const element of elements) {\n\t\tconst list = children.get(element.after);\n\t\tif (list === undefined) {\n\t\t\tchildren.set(element.after, [element]);\n\t\t} else {\n\t\t\tlist.push(element);\n\t\t}\n\t}\n\tfor (const list of children.values()) {\n\t\tlist.sort(compare);\n\t}\n\tconst ordered: TextElement[] = [];\n\tconst stack = [...(children.get(null) ?? [])].reverse();\n\twhile (stack.length > 0) {\n\t\tconst element = stack.pop()!;\n\t\tordered.push(element);\n\t\tconst kids = children.get(element.id);\n\t\tif (kids !== undefined) {\n\t\t\tfor (let index = kids.length - 1; index >= 0; index -= 1) {\n\t\t\t\tstack.push(kids[index]!);\n\t\t\t}\n\t\t}\n\t}\n\treturn ordered;\n};\n\n/** The visible string of a text-CRDT state. Pure — use it server-side too. */\nexport const textOf = (state: TextState): string =>\n\tlinearize(state.elements)\n\t\t.filter((element) => !element.deleted)\n\t\t.map((element) => element.value)\n\t\t.join('');\n\n/** Merge two text-CRDT states (commutative/idempotent). Pure — for server mutations. */\nexport const mergeTextState = (a: TextState, b: TextState): TextState => {\n\tconst byId = new Map<string, TextElement>();\n\tfor (const element of [...a.elements, ...b.elements]) {\n\t\tconst existing = byId.get(element.id);\n\t\tbyId.set(\n\t\t\telement.id,\n\t\t\texisting === undefined\n\t\t\t\t? element\n\t\t\t\t: { ...existing, deleted: existing.deleted || element.deleted }\n\t\t);\n\t}\n\treturn { elements: [...byId.values()] };\n};\n\n/** The RGA text CRDT — {@link CrdtText} plus direct positional edits. */\nexport type TextCrdt = CrdtText<TextState> & {\n\t/** Insert `value` at visible `index`. */\n\tinsert: (index: number, value: string) => void;\n\t/** Tombstone `count` visible characters from `index`. */\n\tdelete: (index: number, count: number) => void;\n};\n\n/**\n * A collaborative text buffer backed by an RGA sequence CRDT. Concurrent inserts\n * and deletes from different replicas merge without conflict and converge. Drive\n * it from an input via {@link TextCrdt.setText}; persist/broadcast\n * {@link TextCrdt.state}; apply remote state via {@link TextCrdt.merge}.\n */\nexport const createTextCrdt = (\n\treplica: string,\n\tinitial?: TextState\n): TextCrdt => {\n\tconst elements = new Map<string, TextElement>();\n\tlet clock = 0;\n\tif (initial !== undefined) {\n\t\tfor (const element of initial.elements) {\n\t\t\telements.set(element.id, element);\n\t\t\tclock = Math.max(clock, element.clock);\n\t\t}\n\t}\n\n\tconst visible = () =>\n\t\tlinearize([...elements.values()]).filter((element) => !element.deleted);\n\n\tconst insert = (index: number, value: string) => {\n\t\tconst seen = visible();\n\t\tlet after = index <= 0 ? null : (seen[index - 1]?.id ?? null);\n\t\tfor (const char of [...value]) {\n\t\t\tclock += 1;\n\t\t\tconst element: TextElement = {\n\t\t\t\tid: `${replica}:${clock}`,\n\t\t\t\treplica,\n\t\t\t\tclock,\n\t\t\t\tafter,\n\t\t\t\tvalue: char,\n\t\t\t\tdeleted: false\n\t\t\t};\n\t\t\telements.set(element.id, element);\n\t\t\tafter = element.id;\n\t\t}\n\t};\n\n\tconst remove = (index: number, count: number) => {\n\t\tconst seen = visible();\n\t\tfor (let offset = 0; offset < count; offset += 1) {\n\t\t\tconst target = seen[index + offset];\n\t\t\tif (target !== undefined) {\n\t\t\t\telements.set(target.id, { ...target, deleted: true });\n\t\t\t}\n\t\t}\n\t};\n\n\treturn {\n\t\ttext: () => textOf({ elements: [...elements.values()] }),\n\t\tinsert,\n\t\tdelete: remove,\n\t\tmerge: (state) => {\n\t\t\tfor (const element of state.elements) {\n\t\t\t\tconst existing = elements.get(element.id);\n\t\t\t\telements.set(\n\t\t\t\t\telement.id,\n\t\t\t\t\texisting === undefined\n\t\t\t\t\t\t? element\n\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t...existing,\n\t\t\t\t\t\t\t\tdeleted: existing.deleted || element.deleted\n\t\t\t\t\t\t\t}\n\t\t\t\t);\n\t\t\t\tclock = Math.max(clock, element.clock);\n\t\t\t}\n\t\t},\n\t\t// Reconcile to `next` by editing only the changed middle: keep the common\n\t\t// prefix/suffix, delete the old middle, insert the new — so two clients\n\t\t// typing in different places merge instead of overwriting.\n\t\tsetText: (next) => {\n\t\t\tconst current = textOf({ elements: [...elements.values()] });\n\t\t\tif (current === next) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet prefix = 0;\n\t\t\tconst maxPrefix = Math.min(current.length, next.length);\n\t\t\twhile (prefix < maxPrefix && current[prefix] === next[prefix]) {\n\t\t\t\tprefix += 1;\n\t\t\t}\n\t\t\tlet suffix = 0;\n\t\t\twhile (\n\t\t\t\tsuffix < maxPrefix - prefix &&\n\t\t\t\tcurrent[current.length - 1 - suffix] ===\n\t\t\t\t\tnext[next.length - 1 - suffix]\n\t\t\t) {\n\t\t\t\tsuffix += 1;\n\t\t\t}\n\t\t\tconst removed = current.length - prefix - suffix;\n\t\t\tif (removed > 0) {\n\t\t\t\tremove(prefix, removed);\n\t\t\t}\n\t\t\tconst inserted = next.slice(prefix, next.length - suffix);\n\t\t\tif (inserted.length > 0) {\n\t\t\t\tinsert(prefix, inserted);\n\t\t\t}\n\t\t},\n\t\tstate: () => ({ elements: [...elements.values()] })\n\t};\n};\n\n/**\n * The first-party collaborative-text backend (the RGA above) packaged as a\n * {@link TextCrdtAdapter}. Zero dependencies. Use it directly, or swap in an\n * adapter from `sync-adapters` (e.g. `@absolutejs/sync-yjs`) for the same shape.\n */\nexport const rgaText: TextCrdtAdapter<TextState> = {\n\tcreate: createTextCrdt,\n\tempty: () => ({ elements: [] }),\n\tmerge: mergeTextState,\n\ttextOf\n};\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "21FF50AD5A6B235464756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -42,8 +42,10 @@ export { defineSchedule } from './schedule';
|
|
|
42
42
|
export type { ScheduleContext, ScheduleDefinition } from './schedule';
|
|
43
43
|
export { defineMutation } from './mutation';
|
|
44
44
|
export type { MutationActions, MutationDefinition, MutationHandler, TableWriter, TransactionRunner } from './mutation';
|
|
45
|
-
export { createSyncEngine, UnauthorizedError } from './syncEngine';
|
|
45
|
+
export { createSyncEngine, SchemaError, UnauthorizedError } from './syncEngine';
|
|
46
46
|
export type { SubscribeArgs, Subscription, SyncEngine } from './syncEngine';
|
|
47
|
+
export { defineSchema, field } from './schema';
|
|
48
|
+
export type { FieldValidator, SchemaDefinition, TableSchema } from './schema';
|
|
47
49
|
export type { CollectionInspection, CollectionKind, EngineActivity, EngineInspection } from './devtools';
|
|
48
50
|
export { hydrateRoute, mutateRoute } from './routes';
|
|
49
51
|
export type { SyncRouteContext } from './routes';
|
package/dist/engine/index.js
CHANGED
|
@@ -1046,6 +1046,13 @@ class UnauthorizedError extends Error {
|
|
|
1046
1046
|
this.name = "UnauthorizedError";
|
|
1047
1047
|
}
|
|
1048
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
class SchemaError extends Error {
|
|
1051
|
+
constructor(table, fieldName) {
|
|
1052
|
+
super(`Schema violation on "${table}": invalid field "${fieldName}"`);
|
|
1053
|
+
this.name = "SchemaError";
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1049
1056
|
var defaultKey = (row) => row.id;
|
|
1050
1057
|
var shallowEqual4 = (a, b) => {
|
|
1051
1058
|
if (a === b) {
|
|
@@ -1082,6 +1089,30 @@ var createSyncEngine = (options = {}) => {
|
|
|
1082
1089
|
const rules = permissions.get(table);
|
|
1083
1090
|
return rules?.[op] ?? rules?.write;
|
|
1084
1091
|
};
|
|
1092
|
+
const schemas = new Map;
|
|
1093
|
+
for (const [table, schema] of Object.entries(options.schemas ?? {})) {
|
|
1094
|
+
schemas.set(table, schema);
|
|
1095
|
+
}
|
|
1096
|
+
const validateWrite = (table, op, row) => {
|
|
1097
|
+
const schema = schemas.get(table);
|
|
1098
|
+
if (schema === undefined || typeof row !== "object" || row === null) {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const record = row;
|
|
1102
|
+
for (const [fieldName, validate] of Object.entries(schema.fields)) {
|
|
1103
|
+
const present = fieldName in record;
|
|
1104
|
+
if (op === "update" && !present) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
if (!validate(record[fieldName])) {
|
|
1108
|
+
throw new SchemaError(table, fieldName);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
const migrateRow = (table, row) => {
|
|
1113
|
+
const migrate = schemas.get(table)?.migrate;
|
|
1114
|
+
return migrate ? migrate(row) : row;
|
|
1115
|
+
};
|
|
1085
1116
|
const reactiveSubs = new Set;
|
|
1086
1117
|
const searchSubs = new Set;
|
|
1087
1118
|
const searchIndexes = new Map;
|
|
@@ -1222,7 +1253,7 @@ var createSyncEngine = (options = {}) => {
|
|
|
1222
1253
|
return {
|
|
1223
1254
|
all: async (table) => {
|
|
1224
1255
|
readTables.add(table);
|
|
1225
|
-
const rows = [...await readerFor(table).all(ctx)];
|
|
1256
|
+
const rows = [...await readerFor(table).all(ctx)].map((row) => migrateRow(table, row));
|
|
1226
1257
|
const rule = ruleFor(table);
|
|
1227
1258
|
return rule ? rows.filter((row) => rule(ctx, row)) : rows;
|
|
1228
1259
|
},
|
|
@@ -1236,7 +1267,8 @@ var createSyncEngine = (options = {}) => {
|
|
|
1236
1267
|
} else {
|
|
1237
1268
|
readTables.add(table);
|
|
1238
1269
|
}
|
|
1239
|
-
const
|
|
1270
|
+
const raw = await reader.get(key, ctx);
|
|
1271
|
+
const row = raw === undefined ? undefined : migrateRow(table, raw);
|
|
1240
1272
|
const rule = ruleFor(table);
|
|
1241
1273
|
return rule && row !== undefined && !rule(ctx, row) ? undefined : row;
|
|
1242
1274
|
},
|
|
@@ -1244,7 +1276,7 @@ var createSyncEngine = (options = {}) => {
|
|
|
1244
1276
|
const reader = readerFor(table);
|
|
1245
1277
|
const rule = ruleFor(table);
|
|
1246
1278
|
const effective = rule ? (row) => predicate(row) && rule(ctx, row) : predicate;
|
|
1247
|
-
const matched = [...await reader.all(ctx)].filter(effective);
|
|
1279
|
+
const matched = [...await reader.all(ctx)].map((row) => migrateRow(table, row)).filter(effective);
|
|
1248
1280
|
if (reader.key !== undefined) {
|
|
1249
1281
|
const key = reader.key;
|
|
1250
1282
|
rangeDeps.push({
|
|
@@ -1299,6 +1331,7 @@ var createSyncEngine = (options = {}) => {
|
|
|
1299
1331
|
return Promise.resolve();
|
|
1300
1332
|
},
|
|
1301
1333
|
insert: async (table, data) => {
|
|
1334
|
+
validateWrite(table, "insert", data);
|
|
1302
1335
|
if (enforce) {
|
|
1303
1336
|
await authorizeWrite(table, "insert", data, ctx);
|
|
1304
1337
|
}
|
|
@@ -1307,6 +1340,7 @@ var createSyncEngine = (options = {}) => {
|
|
|
1307
1340
|
return row;
|
|
1308
1341
|
},
|
|
1309
1342
|
update: async (table, data) => {
|
|
1343
|
+
validateWrite(table, "update", data);
|
|
1310
1344
|
if (enforce) {
|
|
1311
1345
|
await authorizeWrite(table, "update", data, ctx);
|
|
1312
1346
|
}
|
|
@@ -1725,8 +1759,13 @@ var createSyncEngine = (options = {}) => {
|
|
|
1725
1759
|
const key = definition.key ?? defaultKey;
|
|
1726
1760
|
const match = definition.match;
|
|
1727
1761
|
const tables = definition.tables ?? [collection];
|
|
1728
|
-
const
|
|
1729
|
-
const
|
|
1762
|
+
const scopedTable = tables.length === 1 ? tables[0] : undefined;
|
|
1763
|
+
const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
|
|
1764
|
+
const rehydrate = async () => {
|
|
1765
|
+
const raw = [...await definition.hydrate(params, ctx)];
|
|
1766
|
+
const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
|
|
1767
|
+
return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
|
|
1768
|
+
};
|
|
1730
1769
|
const incremental = match !== undefined && tables.length === 1;
|
|
1731
1770
|
const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
|
|
1732
1771
|
const view = createMaterializedView({
|
|
@@ -1774,9 +1813,11 @@ var createSyncEngine = (options = {}) => {
|
|
|
1774
1813
|
throw new UnauthorizedError(`hydrate collection "${collection}"`);
|
|
1775
1814
|
}
|
|
1776
1815
|
}
|
|
1777
|
-
const
|
|
1816
|
+
const raw = [...await definition.hydrate(params, ctx)];
|
|
1778
1817
|
const tables = definition.tables ?? [collection];
|
|
1779
|
-
const
|
|
1818
|
+
const scopedTable = tables.length === 1 ? tables[0] : undefined;
|
|
1819
|
+
const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
|
|
1820
|
+
const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
|
|
1780
1821
|
return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
|
|
1781
1822
|
},
|
|
1782
1823
|
applyChange: (table, change) => applyChange(table, change),
|
|
@@ -1824,6 +1865,10 @@ var createSyncEngine = (options = {}) => {
|
|
|
1824
1865
|
registerPermissions: (table, rules) => {
|
|
1825
1866
|
permissions.set(table, rules);
|
|
1826
1867
|
},
|
|
1868
|
+
registerSchema: (table, schema) => {
|
|
1869
|
+
schemas.set(table, schema);
|
|
1870
|
+
},
|
|
1871
|
+
migrate: (table, row) => migrateRow(table, row),
|
|
1827
1872
|
runMutation: async (name, args, ctx) => {
|
|
1828
1873
|
const mutation = mutations.get(name);
|
|
1829
1874
|
if (mutation === undefined) {
|
|
@@ -1927,6 +1972,18 @@ var createSyncEngine = (options = {}) => {
|
|
|
1927
1972
|
}
|
|
1928
1973
|
};
|
|
1929
1974
|
};
|
|
1975
|
+
// src/engine/schema.ts
|
|
1976
|
+
var defineSchema = (schemas) => schemas;
|
|
1977
|
+
var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
1978
|
+
var field = {
|
|
1979
|
+
string: (value) => typeof value === "string",
|
|
1980
|
+
number: isFiniteNumber,
|
|
1981
|
+
boolean: (value) => typeof value === "boolean",
|
|
1982
|
+
any: () => true,
|
|
1983
|
+
optional: (inner) => (value) => value === undefined || inner(value),
|
|
1984
|
+
array: (inner) => (value) => Array.isArray(value) && value.every(inner),
|
|
1985
|
+
enum: (...values) => (value) => values.includes(value)
|
|
1986
|
+
};
|
|
1930
1987
|
// src/engine/routes.ts
|
|
1931
1988
|
var emptyContext = () => ({});
|
|
1932
1989
|
var hydrateRoute = (engine, collection, resolveContext = emptyContext) => {
|
|
@@ -2174,7 +2231,9 @@ export {
|
|
|
2174
2231
|
hydrateRoute,
|
|
2175
2232
|
fromRowChange,
|
|
2176
2233
|
filterOp,
|
|
2234
|
+
field,
|
|
2177
2235
|
defineSearchCollection,
|
|
2236
|
+
defineSchema,
|
|
2178
2237
|
defineSchedule,
|
|
2179
2238
|
defineReactiveQuery,
|
|
2180
2239
|
definePermissions,
|
|
@@ -2195,8 +2254,9 @@ export {
|
|
|
2195
2254
|
chain,
|
|
2196
2255
|
aggregateOp,
|
|
2197
2256
|
UnauthorizedError,
|
|
2257
|
+
SchemaError,
|
|
2198
2258
|
SEARCH_SCORE_FIELD
|
|
2199
2259
|
};
|
|
2200
2260
|
|
|
2201
|
-
//# debugId=
|
|
2261
|
+
//# debugId=B040890280D3C67864756E2164756E21
|
|
2202
2262
|
//# sourceMappingURL=index.js.map
|