@absolutejs/sync 1.7.4 → 1.7.6

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.
@@ -37,6 +37,60 @@
37
37
  * needed — the OS reaps the workers when the engine's host process exits.
38
38
  */
39
39
  import type { MutationActions } from './mutation';
40
+ /**
41
+ * Per-call metrics record emitted by `sandboxedHandler` when the engine
42
+ * is configured with {@link SyncEngineOptions.handlerMetrics}. One record
43
+ * per invocation, fired AFTER the call completes (success or failure).
44
+ *
45
+ * Use this for per-tenant dashboards ("which tenant is burning the most
46
+ * CPU?"), runtime alerting ("this handler is timing out repeatedly"),
47
+ * cost attribution, and post-mortem replay of slow / failed mutations.
48
+ *
49
+ * Sample wiring pattern — publish to a sync collection users can
50
+ * subscribe to like any other:
51
+ *
52
+ * ```ts
53
+ * const engine = createSyncEngine({
54
+ * handlerMetrics: (record) => {
55
+ * metricsCollection.insert(record); // your own collection / sink
56
+ * },
57
+ * });
58
+ * ```
59
+ */
60
+ export type HandlerMetricsRecord = {
61
+ /** Globally-unique id for this call (random). Useful as a join key. */
62
+ id: string;
63
+ /** Name passed to `defineMutation`. */
64
+ mutationName: string;
65
+ /** Wall-clock duration from call entry to result resolution (ms). */
66
+ durationMs: number;
67
+ /**
68
+ * CPU time spent inside the JSC sandbox (ms). Comes from
69
+ * `Script.runWithMetrics` — does NOT include host-side message-passing
70
+ * overhead on the Worker backend. Sub-millisecond runs round to 0.
71
+ */
72
+ cpuMs: number;
73
+ /**
74
+ * Heap size (bytes) measured immediately after the script returned.
75
+ * Not the run's peak — a true peak needs continuous polling.
76
+ */
77
+ heapBytes: number;
78
+ /** `true` if the handler returned normally; `false` if it threw. */
79
+ ok: boolean;
80
+ /** Error name (`TimeoutError`, `MemoryLimitError`, `Error`, …) on failure. */
81
+ errorName?: string;
82
+ /** Error message on failure. */
83
+ errorMessage?: string;
84
+ /** `Date.now()` at the moment the call ended. */
85
+ timestamp: number;
86
+ };
87
+ /**
88
+ * Per-call hook invoked once each `sandboxedHandler` invocation finishes
89
+ * (success or failure). Synchronous return is the common case; an async
90
+ * return is awaited but its rejection is swallowed (a metrics hook that
91
+ * crashes must NOT also crash the caller's mutation path).
92
+ */
93
+ export type HandlerMetricsHook = (record: HandlerMetricsRecord) => void | Promise<void>;
40
94
  /** Per-mutation sandbox configuration. */
41
95
  export type SandboxConfig = {
42
96
  /** Heap memory cap (MB). Default 32. */
@@ -60,15 +114,30 @@ export type SandboxConfig = {
60
114
  };
61
115
  /**
62
116
  * Build a lazy runner for one mutation's sandboxed source. The first call
63
- * compiles the isolate + context + callable; subsequent calls reuse all
64
- * three and only pack the per-call args (`args`, `ctx`, and a fresh
65
- * dispatch Reference closed over this call's `actions`). If the isolate
66
- * has been disposed (timeout, memory cap), the next call re-spawns
67
- * transparently.
117
+ * compiles the isolate + context + dispatch Reference + callable;
118
+ * subsequent calls only generate a fresh callId, register the per-call
119
+ * `actions` in the callMap, and invoke `callable.call([callId, args,
120
+ * ctx])`. Per-call cost on FFI: one JSObjectCallAsFunction + three
121
+ * cheap primitive packings. No per-call Reference allocation, no
122
+ * setGlobal, no eval.
123
+ *
124
+ * Concurrency-safe by construction: each call has its own callId →
125
+ * its own actions slot in the callMap.
126
+ *
127
+ * If the isolate has been disposed (timeout, memory cap), the next
128
+ * call re-spawns transparently.
129
+ */
130
+ export declare const makeSandboxedHandler: (source: string, config?: SandboxConfig,
131
+ /**
132
+ * Optional hook + tagging. When `onMetrics` is supplied, every call
133
+ * uses `callable.callWithMetrics` (slightly costlier) and fires the
134
+ * hook on completion. Without it, the cheap `callable.call` path is
135
+ * used and nothing changes vs the pre-1.7.6 contract.
68
136
  *
69
- * Concurrency-safe by construction: every call gets its own fresh
70
- * dispatch Reference closed over its own `actions`. No shared slot, no
71
- * promise queue needed. (Reference allocation cost is ~0.003 ms per
72
- * call — negligible.)
137
+ * `mutationName` only matters when `onMetrics` is set it's the
138
+ * `mutationName` field of the emitted record.
73
139
  */
74
- export declare const makeSandboxedHandler: (source: string, config?: SandboxConfig) => ((args: unknown, ctx: unknown, actions: MutationActions) => Promise<unknown>);
140
+ metricsHook?: {
141
+ mutationName: string;
142
+ onMetrics: HandlerMetricsHook;
143
+ }) => ((args: unknown, ctx: unknown, actions: MutationActions) => Promise<unknown>);
@@ -2,6 +2,7 @@ import type { CollectionContext, CollectionDefinition, JoinCollectionDefinition
2
2
  import type { GraphCollectionDefinition } from './graph';
3
3
  import type { MutationDefinition, TableWriter, TransactionRunner } from './mutation';
4
4
  import type { ReactiveQueryDefinition, TableReader } from './reactive';
5
+ import { type HandlerMetricsHook } from './sandbox';
5
6
  import type { PermissionsDefinition, TablePermissions } from './permissions';
6
7
  import type { SearchCollectionDefinition } from './search';
7
8
  import type { ScheduleDefinition } from './schedule';
@@ -308,6 +309,23 @@ export type SyncEngineOptions = {
308
309
  max?: number;
309
310
  ttlMs?: number;
310
311
  };
312
+ /**
313
+ * Per-call telemetry for `sandboxedHandler` mutations. When set, every
314
+ * sandboxed call fires `onMetrics(record)` after completion with
315
+ * `{ id, mutationName, durationMs, cpuMs, heapBytes, ok, errorName,
316
+ * errorMessage, timestamp }`. Wire to a sync collection, your
317
+ * observability backend, a Drizzle table, anything you want.
318
+ *
319
+ * Hook failures are swallowed (a misbehaving metrics sink must NOT
320
+ * crash the caller's mutation). Adding the hook switches the runner
321
+ * to `callable.callWithMetrics`, which is a small per-call cost
322
+ * (~0.05 ms) — disable for hot-path mutations that don't need it.
323
+ *
324
+ * Off by default.
325
+ *
326
+ * @see {@link HandlerMetricsRecord}
327
+ */
328
+ handlerMetrics?: HandlerMetricsHook;
311
329
  };
312
330
  /**
313
331
  * The Tier 3 sync engine: a registry of collections plus the view syncer. It is
package/dist/index.js CHANGED
@@ -725,7 +725,7 @@ var loadIsolatedJsc = async () => {
725
725
  }
726
726
  };
727
727
  var wrap = (source) => `
728
- function (args, ctx, __dispatch) {
728
+ function (__callId, args, ctx) {
729
729
  const userFn = (${source});
730
730
  if (typeof userFn !== 'function') {
731
731
  throw new Error(
@@ -734,30 +734,52 @@ var wrap = (source) => `
734
734
  );
735
735
  }
736
736
  const actions = {
737
- insert: (table, data) => __dispatch('insert', table, data),
738
- update: (table, data) => __dispatch('update', table, data),
739
- delete: (table, row) => __dispatch('delete', table, row),
740
- change: (collection, change) => __dispatch('change', collection, change)
737
+ insert: (table, data) => __dispatch(__callId, 'insert', table, data),
738
+ update: (table, data) => __dispatch(__callId, 'update', table, data),
739
+ delete: (table, row) => __dispatch(__callId, 'delete', table, row),
740
+ change: (collection, change) => __dispatch(__callId, 'change', collection, change)
741
741
  };
742
742
  return userFn(args, ctx, actions);
743
743
  }
744
744
  `;
745
745
  var compile = async (source, config) => {
746
- const { createIsolate } = await loadIsolatedJsc();
746
+ const { createIsolate, Reference } = await loadIsolatedJsc();
747
747
  const isolate = await createIsolate({
748
748
  backend: config.backend ?? "auto",
749
749
  memoryLimit: config.memoryLimit ?? 32
750
750
  });
751
751
  const context = await isolate.createContext();
752
+ const callMap = new Map;
753
+ const dispatch = new Reference((callId, op, ...rest) => {
754
+ const a = callMap.get(callId);
755
+ if (a === undefined) {
756
+ throw new Error(`__dispatch invoked for orphan callId ${String(callId)}`);
757
+ }
758
+ switch (op) {
759
+ case "insert":
760
+ return a.insert(rest[0], rest[1]);
761
+ case "update":
762
+ return a.update(rest[0], rest[1]);
763
+ case "delete":
764
+ return a.delete(rest[0], rest[1]);
765
+ case "change":
766
+ return a.change(rest[0], rest[1]);
767
+ default:
768
+ throw new Error(`unknown sandbox action op: ${String(op)}`);
769
+ }
770
+ });
771
+ await context.setGlobal("__dispatch", dispatch);
752
772
  const callable = await context.compileCallable(wrap(source));
753
773
  return {
754
774
  callable,
775
+ callMap,
755
776
  context,
756
777
  isolate,
778
+ nextCallId: 1,
757
779
  timeoutMs: config.timeout ?? 5000
758
780
  };
759
781
  };
760
- var makeSandboxedHandler = (source, config = {}) => {
782
+ var makeSandboxedHandler = (source, config = {}, metricsHook) => {
761
783
  let pending;
762
784
  const getCompiled = async () => {
763
785
  if (pending !== undefined) {
@@ -770,27 +792,62 @@ var makeSandboxedHandler = (source, config = {}) => {
770
792
  return pending;
771
793
  };
772
794
  return async (args, ctx, actions) => {
773
- const { Reference } = await loadIsolatedJsc();
774
795
  const compiled = await getCompiled();
775
- const dispatch = new Reference((op, ...rest) => {
776
- switch (op) {
777
- case "insert":
778
- return actions.insert(rest[0], rest[1]);
779
- case "update":
780
- return actions.update(rest[0], rest[1]);
781
- case "delete":
782
- return actions.delete(rest[0], rest[1]);
783
- case "change":
784
- return actions.change(rest[0], rest[1]);
785
- default:
786
- throw new Error(`unknown sandbox action op: ${String(op)}`);
796
+ const callId = compiled.nextCallId++;
797
+ compiled.callMap.set(callId, actions);
798
+ if (metricsHook === undefined) {
799
+ try {
800
+ return await compiled.callable.call([callId, args, ctx], {
801
+ timeout: compiled.timeoutMs
802
+ });
803
+ } finally {
804
+ compiled.callMap.delete(callId);
787
805
  }
788
- });
789
- return compiled.callable.call([args, ctx, dispatch], {
790
- timeout: compiled.timeoutMs
791
- });
806
+ }
807
+ const startedAt = performance.now();
808
+ const id = makeRandomId();
809
+ try {
810
+ const { result, metrics } = await compiled.callable.callWithMetrics([callId, args, ctx], { timeout: compiled.timeoutMs });
811
+ fireMetrics(metricsHook.onMetrics, {
812
+ cpuMs: metrics.cpuMs,
813
+ durationMs: performance.now() - startedAt,
814
+ heapBytes: metrics.heapBytes,
815
+ id,
816
+ mutationName: metricsHook.mutationName,
817
+ ok: true,
818
+ timestamp: Date.now()
819
+ });
820
+ return result;
821
+ } catch (error) {
822
+ fireMetrics(metricsHook.onMetrics, {
823
+ cpuMs: 0,
824
+ durationMs: performance.now() - startedAt,
825
+ errorMessage: error instanceof Error ? error.message : String(error),
826
+ errorName: error instanceof Error ? error.name : "Error",
827
+ heapBytes: 0,
828
+ id,
829
+ mutationName: metricsHook.mutationName,
830
+ ok: false,
831
+ timestamp: Date.now()
832
+ });
833
+ throw error;
834
+ } finally {
835
+ compiled.callMap.delete(callId);
836
+ }
792
837
  };
793
838
  };
839
+ var makeRandomId = () => `hm_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
840
+ var fireMetrics = (hook, record) => {
841
+ let outcome;
842
+ try {
843
+ outcome = hook(record);
844
+ } catch {
845
+ return;
846
+ }
847
+ if (outcome instanceof Promise) {
848
+ outcome.catch(() => {});
849
+ }
850
+ };
794
851
 
795
852
  // src/engine/search.ts
796
853
  var SEARCH_SCORE_FIELD = "_score";
@@ -1786,7 +1843,10 @@ var createSyncEngine = (options = {}) => {
1786
1843
  }
1787
1844
  mutations.set(mutation.name, mutation);
1788
1845
  if (mutation.sandboxedHandler !== undefined) {
1789
- sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox));
1846
+ sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox, options.handlerMetrics === undefined ? undefined : {
1847
+ mutationName: mutation.name,
1848
+ onMetrics: options.handlerMetrics
1849
+ }));
1790
1850
  }
1791
1851
  },
1792
1852
  registerWriter: (table, writer) => {
@@ -2310,5 +2370,5 @@ export {
2310
2370
  createPresenceHub
2311
2371
  };
2312
2372
 
2313
- //# debugId=E4BD5E29981CFB6664756E2164756E21
2373
+ //# debugId=9570274420A04D4164756E2164756E21
2314
2374
  //# sourceMappingURL=index.js.map