@absolutejs/sync 1.7.6 → 1.7.8

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> = {
@@ -91,6 +91,66 @@ export type HandlerMetricsRecord = {
91
91
  * crashes must NOT also crash the caller's mutation path).
92
92
  */
93
93
  export type HandlerMetricsHook = (record: HandlerMetricsRecord) => void | Promise<void>;
94
+ /**
95
+ * Per-host configuration for an entry in {@link BridgeFetchConfig}.
96
+ * Auth is computed on the host side per call; the secret never enters
97
+ * the sandbox's JSC heap.
98
+ */
99
+ export type BridgeFetchEndpoint = {
100
+ /**
101
+ * Header values to add to every request to this host. Static — read
102
+ * once at engine construction. Use {@link authorization} for tokens
103
+ * that need to be computed per call.
104
+ */
105
+ headers?: Record<string, string>;
106
+ /**
107
+ * Compute the `Authorization` header value on each call. Synchronous
108
+ * or async. Throwing rejects the in-sandbox call without revealing
109
+ * the underlying error (the sandbox sees a generic
110
+ * "authorization callback failed").
111
+ */
112
+ authorization?: () => string | Promise<string>;
113
+ };
114
+ /**
115
+ * `actions.fetch(url, init)` allowlist + auth-injection config keyed by
116
+ * hostname. A request whose URL parses to a hostname NOT in this map is
117
+ * rejected before any network call. A request whose hostname IS in the
118
+ * map gets the configured static headers + the computed authorization
119
+ * stitched in on the host side. The sandbox source never sees the auth
120
+ * value.
121
+ *
122
+ * Hostname keys are exact (`'api.example.com'`). The special key `'*'`
123
+ * is a wildcard (use sparingly — it disables allowlisting).
124
+ *
125
+ * ```ts
126
+ * createSyncEngine({
127
+ * bridgeFetch: {
128
+ * 'api.stripe.com': {
129
+ * authorization: () => `Bearer ${process.env.STRIPE_KEY}`,
130
+ * },
131
+ * 'api.openai.com': {
132
+ * authorization: () => `Bearer ${process.env.OPENAI_KEY}`,
133
+ * headers: { 'OpenAI-Beta': 'assistants=v2' },
134
+ * },
135
+ * },
136
+ * });
137
+ * ```
138
+ */
139
+ export type BridgeFetchConfig = Record<string, BridgeFetchEndpoint>;
140
+ /**
141
+ * Response shape `actions.fetch` resolves to inside the sandbox. The
142
+ * body is materialised as text on the host (so it crosses the JSC
143
+ * boundary as a structured-cloned string). Users parse it themselves
144
+ * with `JSON.parse(res.body)` for JSON responses.
145
+ */
146
+ export type BridgeFetchResponse = {
147
+ ok: boolean;
148
+ status: number;
149
+ statusText: string;
150
+ url: string;
151
+ headers: Record<string, string>;
152
+ body: string;
153
+ };
94
154
  /** Per-mutation sandbox configuration. */
95
155
  export type SandboxConfig = {
96
156
  /** Heap memory cap (MB). Default 32. */
@@ -129,15 +189,16 @@ export type SandboxConfig = {
129
189
  */
130
190
  export declare const makeSandboxedHandler: (source: string, config?: SandboxConfig,
131
191
  /**
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.
192
+ * Engine-level extras the per-mutation config doesn't carry:
193
+ * - `metricsHook` enables per-call telemetry via
194
+ * `callable.callWithMetrics` (small cost; off without the hook).
195
+ * - `bridgeFetch` enables `actions.fetch(url, init)` inside the
196
+ * sandbox with host-side allowlist + auth injection.
139
197
  */
140
- metricsHook?: {
141
- mutationName: string;
142
- onMetrics: HandlerMetricsHook;
198
+ engineExtras?: {
199
+ metricsHook?: {
200
+ mutationName: string;
201
+ onMetrics: HandlerMetricsHook;
202
+ };
203
+ bridgeFetch?: BridgeFetchConfig;
143
204
  }) => ((args: unknown, ctx: unknown, actions: MutationActions) => Promise<unknown>);
@@ -2,7 +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
+ import { type BridgeFetchConfig, type HandlerMetricsHook } from './sandbox';
6
6
  import type { PermissionsDefinition, TablePermissions } from './permissions';
7
7
  import type { SearchCollectionDefinition } from './search';
8
8
  import type { ScheduleDefinition } from './schedule';
@@ -326,6 +326,21 @@ export type SyncEngineOptions = {
326
326
  * @see {@link HandlerMetricsRecord}
327
327
  */
328
328
  handlerMetrics?: HandlerMetricsHook;
329
+ /**
330
+ * Allowlist + auth-injection map for `actions.fetch(url, init)` calls
331
+ * issued from inside a `sandboxedHandler`. Each entry is keyed by
332
+ * hostname (`'api.stripe.com'`); the value's `authorization` is a
333
+ * sync or async callback computed on the host so the secret never
334
+ * crosses into the JSC sandbox. Requests to non-allowlisted hosts
335
+ * are rejected before any network call.
336
+ *
337
+ * Without this set, `actions.fetch` throws "no bridgeFetch config."
338
+ * Plain (non-sandboxed) handlers don't use this — they can just call
339
+ * `fetch` directly since they run in the host process.
340
+ *
341
+ * @see {@link BridgeFetchConfig}
342
+ */
343
+ bridgeFetch?: BridgeFetchConfig;
329
344
  };
330
345
  /**
331
346
  * The Tier 3 sync engine: a registry of collections plus the view syncer. It is
package/dist/index.js CHANGED
@@ -737,12 +737,14 @@ 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'),
742
+ fetch: (url, init) => __dispatch(__callId, 'fetch', url, init)
741
743
  };
742
744
  return userFn(args, ctx, actions);
743
745
  }
744
746
  `;
745
- var compile = async (source, config) => {
747
+ var compile = async (source, config, bridgeFetch) => {
746
748
  const { createIsolate, Reference } = await loadIsolatedJsc();
747
749
  const isolate = await createIsolate({
748
750
  backend: config.backend ?? "auto",
@@ -764,6 +766,10 @@ var compile = async (source, config) => {
764
766
  return a.delete(rest[0], rest[1]);
765
767
  case "change":
766
768
  return a.change(rest[0], rest[1]);
769
+ case "now":
770
+ return a.now();
771
+ case "fetch":
772
+ return runBridgeFetch(bridgeFetch, rest[0], rest[1]);
767
773
  default:
768
774
  throw new Error(`unknown sandbox action op: ${String(op)}`);
769
775
  }
@@ -779,8 +785,57 @@ var compile = async (source, config) => {
779
785
  timeoutMs: config.timeout ?? 5000
780
786
  };
781
787
  };
782
- var makeSandboxedHandler = (source, config = {}, metricsHook) => {
788
+ var runBridgeFetch = async (config, url, init) => {
789
+ if (config === undefined) {
790
+ throw new Error("actions.fetch called but the engine has no `bridgeFetch` config \u2014 " + "pass `bridgeFetch: { ... }` to createSyncEngine.");
791
+ }
792
+ let parsed;
793
+ try {
794
+ parsed = new URL(url);
795
+ } catch {
796
+ throw new Error(`actions.fetch: invalid URL "${String(url)}"`);
797
+ }
798
+ const endpoint = config[parsed.hostname] ?? (Object.prototype.hasOwnProperty.call(config, "*") ? config["*"] : undefined);
799
+ if (endpoint === undefined) {
800
+ throw new Error(`actions.fetch: hostname "${parsed.hostname}" is not allowlisted in bridgeFetch config`);
801
+ }
802
+ const headers = { ...endpoint.headers ?? {} };
803
+ if (init?.headers !== undefined) {
804
+ const incoming = init.headers;
805
+ for (const [name, value] of Object.entries(incoming)) {
806
+ if (name.toLowerCase() === "authorization")
807
+ continue;
808
+ headers[name] = value;
809
+ }
810
+ }
811
+ if (endpoint.authorization !== undefined) {
812
+ let auth;
813
+ try {
814
+ auth = await endpoint.authorization();
815
+ } catch {
816
+ throw new Error("actions.fetch: authorization callback failed");
817
+ }
818
+ headers.Authorization = auth;
819
+ }
820
+ const response = await fetch(url, { ...init, headers });
821
+ const responseHeaders = {};
822
+ response.headers.forEach((value, name) => {
823
+ responseHeaders[name] = value;
824
+ });
825
+ const body = await response.text();
826
+ return {
827
+ body,
828
+ headers: responseHeaders,
829
+ ok: response.ok,
830
+ status: response.status,
831
+ statusText: response.statusText,
832
+ url: response.url
833
+ };
834
+ };
835
+ var makeSandboxedHandler = (source, config = {}, engineExtras) => {
783
836
  let pending;
837
+ const metricsHook = engineExtras?.metricsHook;
838
+ const bridgeFetch = engineExtras?.bridgeFetch;
784
839
  const getCompiled = async () => {
785
840
  if (pending !== undefined) {
786
841
  const compiled = await pending;
@@ -788,7 +843,7 @@ var makeSandboxedHandler = (source, config = {}, metricsHook) => {
788
843
  return compiled;
789
844
  pending = undefined;
790
845
  }
791
- pending = compile(source, config);
846
+ pending = compile(source, config, bridgeFetch);
792
847
  return pending;
793
848
  };
794
849
  return async (args, ctx, actions) => {
@@ -1286,7 +1341,8 @@ var createSyncEngine = (options = {}) => {
1286
1341
  }
1287
1342
  await writerFor(table).delete(row, ctx, tx);
1288
1343
  buffered.push({ table, change: { op: "delete", row } });
1289
- }
1344
+ },
1345
+ now: () => Date.now()
1290
1346
  };
1291
1347
  return { actions, buffered };
1292
1348
  };
@@ -1843,9 +1899,12 @@ var createSyncEngine = (options = {}) => {
1843
1899
  }
1844
1900
  mutations.set(mutation.name, mutation);
1845
1901
  if (mutation.sandboxedHandler !== undefined) {
1846
- sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox, options.handlerMetrics === undefined ? undefined : {
1847
- mutationName: mutation.name,
1848
- onMetrics: options.handlerMetrics
1902
+ sandboxRunners.set(mutation.name, makeSandboxedHandler(mutation.sandboxedHandler, mutation.sandbox, {
1903
+ bridgeFetch: options.bridgeFetch,
1904
+ metricsHook: options.handlerMetrics === undefined ? undefined : {
1905
+ mutationName: mutation.name,
1906
+ onMetrics: options.handlerMetrics
1907
+ }
1849
1908
  }));
1850
1909
  }
1851
1910
  },
@@ -2370,5 +2429,5 @@ export {
2370
2429
  createPresenceHub
2371
2430
  };
2372
2431
 
2373
- //# debugId=9570274420A04D4164756E2164756E21
2432
+ //# debugId=29525303DD4B75D864756E2164756E21
2374
2433
  //# sourceMappingURL=index.js.map