@absolutejs/sync 0.7.0 → 0.9.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
@@ -33,7 +33,8 @@ 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, live full-text + vector search, scheduled functions, CDC for
36
+ > permissions, schema validation + lazy migrations, live full-text + vector
37
+ > search, scheduled functions, a live devtools dashboard, CDC for
37
38
  > Postgres/MySQL/SQLite, incremental aggregations + joins, and a declarative
38
39
  > operator graph) are in place. Everything ships as subpaths of this one package.
39
40
 
@@ -332,13 +333,14 @@ it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real
332
333
 
333
334
  ### `@absolutejs/sync`
334
335
 
335
- | Export | What it is |
336
- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
337
- | `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
338
- | `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
339
- | `syncSocket({ engine, path?, resolveContext? })` | Elysia WebSocket plugin for the sync engine. |
340
- | `scheduled({ engine, prefix?, onError? })` _(`/scheduled` subpath)_ | Elysia plugin: fires the engine's registered schedules on their cron patterns (via `@elysiajs/cron`). Kept off the main entry so `syncSocket` needs no cron dep. |
341
- | `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
336
+ | Export | What it is |
337
+ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
338
+ | `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
339
+ | `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
340
+ | `syncSocket({ engine, path?, resolveContext? })` | Elysia WebSocket plugin for the sync engine. |
341
+ | `scheduled({ engine, prefix?, onError? })` _(`/scheduled` subpath)_ | Elysia plugin: fires the engine's registered schedules on their cron patterns (via `@elysiajs/cron`). Kept off the main entry so `syncSocket` needs no cron dep. |
342
+ | `syncDevtools({ engine, path?, snapshotMs? })` | Elysia plugin: a live devtools dashboard (collections, subscription counts, mutations, schedules, change feed) over SSE. Backed by `engine.inspect()` + `engine.onActivity()`. |
343
+ | `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
342
344
 
343
345
  ### `@absolutejs/sync/client`
344
346
 
@@ -401,6 +403,7 @@ mutate({
401
403
  | `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
402
404
  | `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. |
403
405
  | `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`. |
406
+ | `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). |
404
407
  | `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`. |
405
408
  | `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
406
409
  | `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. |
@@ -0,0 +1,69 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { SyncEngine } from './engine/syncEngine';
3
+ export type SyncDevtoolsOptions = {
4
+ /** The engine to inspect. */
5
+ engine: SyncEngine;
6
+ /** Route the dashboard is served from (its SSE feed is `<path>/stream`). Default `/sync/devtools`. */
7
+ path?: string;
8
+ /** Snapshot refresh interval (ms) — keeps subscription counts/version current. Default 2000. */
9
+ snapshotMs?: number;
10
+ };
11
+ /**
12
+ * Elysia plugin: a live devtools dashboard for a {@link SyncEngine}. Mount it and
13
+ * open `path` in a browser to watch registered collections (kind, source tables,
14
+ * live subscription counts), mutations, schedules, readers/writers, the
15
+ * change-feed version, and a streaming log of changes + mutation outcomes — over
16
+ * Server-Sent Events. Read-only; safe to leave mounted in dev.
17
+ */
18
+ export declare const syncDevtools: ({ engine, path, snapshotMs }: SyncDevtoolsOptions) => Elysia<"", {
19
+ decorator: {};
20
+ store: {};
21
+ derive: {};
22
+ resolve: {};
23
+ }, {
24
+ typebox: {};
25
+ error: {};
26
+ }, {
27
+ schema: {};
28
+ standaloneSchema: {};
29
+ macro: {};
30
+ macroFn: {};
31
+ parser: {};
32
+ response: {};
33
+ }, {
34
+ [x: string]: {
35
+ get: {
36
+ body: unknown;
37
+ params: {};
38
+ query: unknown;
39
+ headers: unknown;
40
+ response: {
41
+ 200: Response;
42
+ };
43
+ };
44
+ };
45
+ } & {
46
+ [x: string]: {
47
+ get: {
48
+ body: unknown;
49
+ params: {};
50
+ query: unknown;
51
+ headers: unknown;
52
+ response: {
53
+ 200: Response;
54
+ };
55
+ };
56
+ };
57
+ }, {
58
+ derive: {};
59
+ resolve: {};
60
+ schema: {};
61
+ standaloneSchema: {};
62
+ response: {};
63
+ }, {
64
+ derive: {};
65
+ resolve: {};
66
+ schema: {};
67
+ standaloneSchema: {};
68
+ response: {};
69
+ }>;
@@ -0,0 +1,55 @@
1
+ import type { RowOp } from './types';
2
+ /**
3
+ * Devtools introspection — a live window into a running {@link SyncEngine}: what
4
+ * collections are registered, how many clients subscribe to each, which
5
+ * mutations/schedules/readers/writers exist, the change-feed version, and a tail
6
+ * of recent changes. Paired with the activity stream ({@link EngineActivity}) it
7
+ * powers the `syncDevtools` dashboard. Read-only — purely observational.
8
+ */
9
+ export type CollectionKind = 'view' | 'join' | 'graph' | 'reactive' | 'search';
10
+ /** One registered collection's current state. */
11
+ export type CollectionInspection = {
12
+ name: string;
13
+ kind: CollectionKind;
14
+ /** Source tables it reads (empty for a reactive query — its deps are dynamic). */
15
+ tables: string[];
16
+ /** Active client subscriptions to it right now. */
17
+ subscriptions: number;
18
+ };
19
+ /** A point-in-time snapshot of the engine (see {@link SyncEngine.inspect}). */
20
+ export type EngineInspection = {
21
+ /** Current change-feed version (monotonic). */
22
+ version: number;
23
+ collections: CollectionInspection[];
24
+ mutations: string[];
25
+ schedules: {
26
+ name: string;
27
+ pattern: string;
28
+ }[];
29
+ /** Tables with a registered reader / writer. */
30
+ readers: string[];
31
+ writers: string[];
32
+ /** Most recent changes from the change log (oldest first). */
33
+ recentChanges: {
34
+ version: number;
35
+ table: string;
36
+ op: RowOp;
37
+ }[];
38
+ };
39
+ /**
40
+ * A live engine event (see {@link SyncEngine.onActivity}): a committed change or
41
+ * a mutation outcome. `at` is `Date.now()`. (Live subscription counts come from
42
+ * the {@link EngineInspection} snapshot.)
43
+ */
44
+ export type EngineActivity = {
45
+ type: 'change';
46
+ at: number;
47
+ table: string;
48
+ op: RowOp;
49
+ version: number;
50
+ } | {
51
+ type: 'mutation';
52
+ at: number;
53
+ name: string;
54
+ status: 'ok' | 'error';
55
+ };
@@ -42,8 +42,11 @@ 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';
49
+ export type { CollectionInspection, CollectionKind, EngineActivity, EngineInspection } from './devtools';
47
50
  export { hydrateRoute, mutateRoute } from './routes';
48
51
  export type { SyncRouteContext } from './routes';
49
52
  export { createSyncConnection } from './connection';
@@ -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;
@@ -1090,6 +1121,12 @@ var createSyncEngine = (options = {}) => {
1090
1121
  const changeLogSize = options.changeLogSize ?? 1024;
1091
1122
  const changeLog = [];
1092
1123
  let version = 0;
1124
+ const activityListeners = new Set;
1125
+ const emitActivity = (event) => {
1126
+ for (const listener of activityListeners) {
1127
+ listener(event);
1128
+ }
1129
+ };
1093
1130
  const runInTransaction = options.transaction;
1094
1131
  const instanceId = globalThis.crypto?.randomUUID?.() ?? `i${Math.random()}`;
1095
1132
  let clusterBus;
@@ -1216,7 +1253,7 @@ var createSyncEngine = (options = {}) => {
1216
1253
  return {
1217
1254
  all: async (table) => {
1218
1255
  readTables.add(table);
1219
- const rows = [...await readerFor(table).all(ctx)];
1256
+ const rows = [...await readerFor(table).all(ctx)].map((row) => migrateRow(table, row));
1220
1257
  const rule = ruleFor(table);
1221
1258
  return rule ? rows.filter((row) => rule(ctx, row)) : rows;
1222
1259
  },
@@ -1230,7 +1267,8 @@ var createSyncEngine = (options = {}) => {
1230
1267
  } else {
1231
1268
  readTables.add(table);
1232
1269
  }
1233
- const row = await reader.get(key, ctx);
1270
+ const raw = await reader.get(key, ctx);
1271
+ const row = raw === undefined ? undefined : migrateRow(table, raw);
1234
1272
  const rule = ruleFor(table);
1235
1273
  return rule && row !== undefined && !rule(ctx, row) ? undefined : row;
1236
1274
  },
@@ -1238,7 +1276,7 @@ var createSyncEngine = (options = {}) => {
1238
1276
  const reader = readerFor(table);
1239
1277
  const rule = ruleFor(table);
1240
1278
  const effective = rule ? (row) => predicate(row) && rule(ctx, row) : predicate;
1241
- const matched = [...await reader.all(ctx)].filter(effective);
1279
+ const matched = [...await reader.all(ctx)].map((row) => migrateRow(table, row)).filter(effective);
1242
1280
  if (reader.key !== undefined) {
1243
1281
  const key = reader.key;
1244
1282
  rangeDeps.push({
@@ -1293,6 +1331,7 @@ var createSyncEngine = (options = {}) => {
1293
1331
  return Promise.resolve();
1294
1332
  },
1295
1333
  insert: async (table, data) => {
1334
+ validateWrite(table, "insert", data);
1296
1335
  if (enforce) {
1297
1336
  await authorizeWrite(table, "insert", data, ctx);
1298
1337
  }
@@ -1301,6 +1340,7 @@ var createSyncEngine = (options = {}) => {
1301
1340
  return row;
1302
1341
  },
1303
1342
  update: async (table, data) => {
1343
+ validateWrite(table, "update", data);
1304
1344
  if (enforce) {
1305
1345
  await authorizeWrite(table, "update", data, ctx);
1306
1346
  }
@@ -1412,6 +1452,13 @@ var createSyncEngine = (options = {}) => {
1412
1452
  version += 1;
1413
1453
  const changeVersion = version;
1414
1454
  logChange(changeVersion, { version: changeVersion, table, change });
1455
+ emitActivity({
1456
+ type: "change",
1457
+ at: Date.now(),
1458
+ table,
1459
+ op: change.op,
1460
+ version: changeVersion
1461
+ });
1415
1462
  const emissions = [];
1416
1463
  for (const subscription of subscriptionsForTable(table)) {
1417
1464
  const diff = await subscriptionDiff(subscription, table, change);
@@ -1440,6 +1487,13 @@ var createSyncEngine = (options = {}) => {
1440
1487
  const reactiveChanges = [];
1441
1488
  for (const { table, change } of changes) {
1442
1489
  logChange(batchVersion, { version: batchVersion, table, change });
1490
+ emitActivity({
1491
+ type: "change",
1492
+ at: Date.now(),
1493
+ table,
1494
+ op: change.op,
1495
+ version: batchVersion
1496
+ });
1443
1497
  reactiveChanges.push({
1444
1498
  table,
1445
1499
  key: changedKeyFor(table, change),
@@ -1705,8 +1759,13 @@ var createSyncEngine = (options = {}) => {
1705
1759
  const key = definition.key ?? defaultKey;
1706
1760
  const match = definition.match;
1707
1761
  const tables = definition.tables ?? [collection];
1708
- const readRule = tables.length === 1 ? readRuleFor(tables[0]) : undefined;
1709
- const rehydrate = readRule ? async () => [...await definition.hydrate(params, ctx)].filter((row) => readRule(ctx, row)) : async () => definition.hydrate(params, ctx);
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
+ };
1710
1769
  const incremental = match !== undefined && tables.length === 1;
1711
1770
  const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
1712
1771
  const view = createMaterializedView({
@@ -1754,9 +1813,11 @@ var createSyncEngine = (options = {}) => {
1754
1813
  throw new UnauthorizedError(`hydrate collection "${collection}"`);
1755
1814
  }
1756
1815
  }
1757
- const rows = [...await definition.hydrate(params, ctx)];
1816
+ const raw = [...await definition.hydrate(params, ctx)];
1758
1817
  const tables = definition.tables ?? [collection];
1759
- const readRule = tables.length === 1 ? readRuleFor(tables[0]) : undefined;
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;
1760
1821
  return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
1761
1822
  },
1762
1823
  applyChange: (table, change) => applyChange(table, change),
@@ -1804,6 +1865,10 @@ var createSyncEngine = (options = {}) => {
1804
1865
  registerPermissions: (table, rules) => {
1805
1866
  permissions.set(table, rules);
1806
1867
  },
1868
+ registerSchema: (table, schema) => {
1869
+ schemas.set(table, schema);
1870
+ },
1871
+ migrate: (table, row) => migrateRow(table, row),
1807
1872
  runMutation: async (name, args, ctx) => {
1808
1873
  const mutation = mutations.get(name);
1809
1874
  if (mutation === undefined) {
@@ -1816,13 +1881,29 @@ var createSyncEngine = (options = {}) => {
1816
1881
  }
1817
1882
  }
1818
1883
  const runHandler = async (tx) => {
1819
- const { actions, buffered: buffered2 } = makeActions(tx, ctx, true);
1820
- const result2 = await mutation.handler(args, ctx, actions);
1821
- return { buffered: buffered2, result: result2 };
1884
+ const { actions, buffered } = makeActions(tx, ctx, true);
1885
+ const result = await mutation.handler(args, ctx, actions);
1886
+ return { buffered, result };
1822
1887
  };
1823
- const { buffered, result } = runInTransaction !== undefined ? await runInTransaction((tx) => runHandler(tx)) : await runHandler(undefined);
1824
- await applyChangeBatch(buffered);
1825
- return result;
1888
+ try {
1889
+ const { buffered, result } = runInTransaction !== undefined ? await runInTransaction((tx) => runHandler(tx)) : await runHandler(undefined);
1890
+ await applyChangeBatch(buffered);
1891
+ emitActivity({
1892
+ type: "mutation",
1893
+ at: Date.now(),
1894
+ name,
1895
+ status: "ok"
1896
+ });
1897
+ return result;
1898
+ } catch (error) {
1899
+ emitActivity({
1900
+ type: "mutation",
1901
+ at: Date.now(),
1902
+ name,
1903
+ status: "error"
1904
+ });
1905
+ throw error;
1906
+ }
1826
1907
  },
1827
1908
  registerSchedule: (schedule) => {
1828
1909
  schedules.set(schedule.name, schedule);
@@ -1841,9 +1922,68 @@ var createSyncEngine = (options = {}) => {
1841
1922
  };
1842
1923
  const buffered = runInTransaction !== undefined ? await runInTransaction((tx) => runHandler(tx)) : await runHandler(undefined);
1843
1924
  await applyChangeBatch(buffered);
1925
+ },
1926
+ inspect: () => {
1927
+ const collections = [...registry.entries()].map(([name, def]) => {
1928
+ const kind = def.kind ?? "view";
1929
+ let tables = [];
1930
+ if (kind === "join") {
1931
+ const join = def;
1932
+ tables = [join.left.table, join.right.table];
1933
+ } else if (kind === "graph") {
1934
+ tables = def.query.tables();
1935
+ } else if (kind === "search") {
1936
+ tables = [
1937
+ def.table
1938
+ ];
1939
+ } else if (kind === "view") {
1940
+ tables = def.tables ?? [name];
1941
+ }
1942
+ return {
1943
+ name,
1944
+ kind,
1945
+ tables,
1946
+ subscriptions: active.get(name)?.size ?? 0
1947
+ };
1948
+ });
1949
+ const DEVTOOLS_RECENT = 50;
1950
+ return {
1951
+ version,
1952
+ collections,
1953
+ mutations: [...mutations.keys()],
1954
+ schedules: [...schedules.values()].map((schedule) => ({
1955
+ name: schedule.name,
1956
+ pattern: schedule.pattern
1957
+ })),
1958
+ readers: [...readers.keys()],
1959
+ writers: [...writers.keys()],
1960
+ recentChanges: changeLog.slice(-DEVTOOLS_RECENT).map((entry) => ({
1961
+ version: entry.version,
1962
+ table: entry.table,
1963
+ op: entry.change.op
1964
+ }))
1965
+ };
1966
+ },
1967
+ onActivity: (listener) => {
1968
+ activityListeners.add(listener);
1969
+ return () => {
1970
+ activityListeners.delete(listener);
1971
+ };
1844
1972
  }
1845
1973
  };
1846
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
+ };
1847
1987
  // src/engine/routes.ts
1848
1988
  var emptyContext = () => ({});
1849
1989
  var hydrateRoute = (engine, collection, resolveContext = emptyContext) => {
@@ -2091,7 +2231,9 @@ export {
2091
2231
  hydrateRoute,
2092
2232
  fromRowChange,
2093
2233
  filterOp,
2234
+ field,
2094
2235
  defineSearchCollection,
2236
+ defineSchema,
2095
2237
  defineSchedule,
2096
2238
  defineReactiveQuery,
2097
2239
  definePermissions,
@@ -2112,8 +2254,9 @@ export {
2112
2254
  chain,
2113
2255
  aggregateOp,
2114
2256
  UnauthorizedError,
2257
+ SchemaError,
2115
2258
  SEARCH_SCORE_FIELD
2116
2259
  };
2117
2260
 
2118
- //# debugId=54AD7964887E323764756E2164756E21
2261
+ //# debugId=B040890280D3C67864756E2164756E21
2119
2262
  //# sourceMappingURL=index.js.map