@absolutejs/sync 1.7.5 → 1.7.7

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.
@@ -45,6 +45,28 @@ export type MutationActions = {
45
45
  delete: (table: string, row: unknown) => Promise<void>;
46
46
  /** Escape hatch: emit a change you persisted yourself (no writer call). */
47
47
  change: <T>(collection: string, change: RowChange<T>) => Promise<void>;
48
+ /**
49
+ * Wall-clock timestamp the handler should use instead of `Date.now()`.
50
+ * Returns a `number` (ms since epoch).
51
+ *
52
+ * Why an injected clock? Two forward-looking reasons:
53
+ *
54
+ * 1. **Replay / rebase determinism.** When the engine re-runs a
55
+ * mutation against an updated state (Replicache-style mutator
56
+ * replay), `actions.now()` returns the ORIGINAL call's timestamp
57
+ * instead of the current wall clock. `Date.now()` would silently
58
+ * diverge between client-optimistic and server-canonical runs;
59
+ * `actions.now()` doesn't.
60
+ *
61
+ * 2. **Test determinism.** Test harnesses can pin time by passing a
62
+ * custom `now()` through {@link MutationActions} — the handler
63
+ * observes whatever the test wants.
64
+ *
65
+ * If the engine doesn't override it (the common case today), it just
66
+ * returns `Date.now()`. Use it everywhere you'd reach for
67
+ * `Date.now()` inside a mutation handler.
68
+ */
69
+ now: () => number;
48
70
  };
49
71
  export type MutationHandler<Args, Ctx, Result> = (args: Args, ctx: Ctx, actions: MutationActions) => Promise<Result> | Result;
50
72
  export type MutationDefinition<Args = unknown, Ctx = CollectionContext, Result = unknown> = {
@@ -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. */
@@ -73,4 +127,17 @@ export type SandboxConfig = {
73
127
  * If the isolate has been disposed (timeout, memory cap), the next
74
128
  * call re-spawns transparently.
75
129
  */
76
- export declare const makeSandboxedHandler: (source: string, config?: SandboxConfig) => ((args: unknown, ctx: unknown, actions: MutationActions) => Promise<unknown>);
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.
136
+ *
137
+ * `mutationName` only matters when `onMetrics` is set — it's the
138
+ * `mutationName` field of the emitted record.
139
+ */
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
@@ -737,7 +737,8 @@ var wrap = (source) => `
737
737
  insert: (table, data) => __dispatch(__callId, 'insert', table, data),
738
738
  update: (table, data) => __dispatch(__callId, 'update', table, data),
739
739
  delete: (table, row) => __dispatch(__callId, 'delete', table, row),
740
- change: (collection, change) => __dispatch(__callId, 'change', collection, change)
740
+ change: (collection, change) => __dispatch(__callId, 'change', collection, change),
741
+ now: () => __dispatch(__callId, 'now')
741
742
  };
742
743
  return userFn(args, ctx, actions);
743
744
  }
@@ -764,6 +765,8 @@ var compile = async (source, config) => {
764
765
  return a.delete(rest[0], rest[1]);
765
766
  case "change":
766
767
  return a.change(rest[0], rest[1]);
768
+ case "now":
769
+ return a.now();
767
770
  default:
768
771
  throw new Error(`unknown sandbox action op: ${String(op)}`);
769
772
  }
@@ -779,7 +782,7 @@ var compile = async (source, config) => {
779
782
  timeoutMs: config.timeout ?? 5000
780
783
  };
781
784
  };
782
- var makeSandboxedHandler = (source, config = {}) => {
785
+ var makeSandboxedHandler = (source, config = {}, metricsHook) => {
783
786
  let pending;
784
787
  const getCompiled = async () => {
785
788
  if (pending !== undefined) {
@@ -795,15 +798,59 @@ var makeSandboxedHandler = (source, config = {}) => {
795
798
  const compiled = await getCompiled();
796
799
  const callId = compiled.nextCallId++;
797
800
  compiled.callMap.set(callId, actions);
801
+ if (metricsHook === undefined) {
802
+ try {
803
+ return await compiled.callable.call([callId, args, ctx], {
804
+ timeout: compiled.timeoutMs
805
+ });
806
+ } finally {
807
+ compiled.callMap.delete(callId);
808
+ }
809
+ }
810
+ const startedAt = performance.now();
811
+ const id = makeRandomId();
798
812
  try {
799
- return await compiled.callable.call([callId, args, ctx], {
800
- timeout: compiled.timeoutMs
813
+ const { result, metrics } = await compiled.callable.callWithMetrics([callId, args, ctx], { timeout: compiled.timeoutMs });
814
+ fireMetrics(metricsHook.onMetrics, {
815
+ cpuMs: metrics.cpuMs,
816
+ durationMs: performance.now() - startedAt,
817
+ heapBytes: metrics.heapBytes,
818
+ id,
819
+ mutationName: metricsHook.mutationName,
820
+ ok: true,
821
+ timestamp: Date.now()
822
+ });
823
+ return result;
824
+ } catch (error) {
825
+ fireMetrics(metricsHook.onMetrics, {
826
+ cpuMs: 0,
827
+ durationMs: performance.now() - startedAt,
828
+ errorMessage: error instanceof Error ? error.message : String(error),
829
+ errorName: error instanceof Error ? error.name : "Error",
830
+ heapBytes: 0,
831
+ id,
832
+ mutationName: metricsHook.mutationName,
833
+ ok: false,
834
+ timestamp: Date.now()
801
835
  });
836
+ throw error;
802
837
  } finally {
803
838
  compiled.callMap.delete(callId);
804
839
  }
805
840
  };
806
841
  };
842
+ var makeRandomId = () => `hm_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
843
+ var fireMetrics = (hook, record) => {
844
+ let outcome;
845
+ try {
846
+ outcome = hook(record);
847
+ } catch {
848
+ return;
849
+ }
850
+ if (outcome instanceof Promise) {
851
+ outcome.catch(() => {});
852
+ }
853
+ };
807
854
 
808
855
  // src/engine/search.ts
809
856
  var SEARCH_SCORE_FIELD = "_score";
@@ -1242,7 +1289,8 @@ var createSyncEngine = (options = {}) => {
1242
1289
  }
1243
1290
  await writerFor(table).delete(row, ctx, tx);
1244
1291
  buffered.push({ table, change: { op: "delete", row } });
1245
- }
1292
+ },
1293
+ now: () => Date.now()
1246
1294
  };
1247
1295
  return { actions, buffered };
1248
1296
  };
@@ -1799,7 +1847,10 @@ var createSyncEngine = (options = {}) => {
1799
1847
  }
1800
1848
  mutations.set(mutation.name, mutation);
1801
1849
  if (mutation.sandboxedHandler !== undefined) {
1802
- sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox));
1850
+ sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox, options.handlerMetrics === undefined ? undefined : {
1851
+ mutationName: mutation.name,
1852
+ onMetrics: options.handlerMetrics
1853
+ }));
1803
1854
  }
1804
1855
  },
1805
1856
  registerWriter: (table, writer) => {
@@ -2323,5 +2374,5 @@ export {
2323
2374
  createPresenceHub
2324
2375
  };
2325
2376
 
2326
- //# debugId=B9056CA0A3DAE81164756E2164756E21
2377
+ //# debugId=0583BD887ED853F264756E2164756E21
2327
2378
  //# sourceMappingURL=index.js.map