@absolutejs/sync 1.10.0 → 1.12.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.
@@ -171,6 +171,52 @@ export type SandboxConfig = {
171
171
  * reachable (e.g. CI with a known image).
172
172
  */
173
173
  backend?: 'auto' | 'ffi' | 'worker';
174
+ /**
175
+ * **Escape hatch.** Map of host functions the sandboxed handler may
176
+ * call as `unsafeHost.fnName(...args)`. The name is deliberately
177
+ * loud: anyone reading the source must see immediately that a
178
+ * sandboxed mutation is reaching through to non-deterministic host
179
+ * code (third-party API, queue push, email send, anything that
180
+ * touches the outside world).
181
+ *
182
+ * Without this option, the sandbox is hermetic — only `args`,
183
+ * `ctx`, and `actions` are reachable. Declare an entry here, name
184
+ * it visibly (e.g. `chargeStripe`, `sendSlackPing`), and the engine
185
+ * exposes it on the sandbox-side `unsafeHost` Proxy. The fn runs on
186
+ * the host; its return value is structured-cloned back across the
187
+ * isolate boundary; thrown errors propagate into the sandbox as
188
+ * normal JS errors the handler can catch.
189
+ *
190
+ * The deterministic-mutation guarantees stop at the call site —
191
+ * retries WILL re-fire these host fns (they're outside the
192
+ * transaction). Treat them as side effects and either make them
193
+ * idempotent or pair them with explicit compensation in the
194
+ * handler. Convex's actions are the same model; this is the same
195
+ * trade.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * defineMutation({
200
+ * name: 'payments:checkout',
201
+ * sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
202
+ * const order = await actions.insert('orders', { ...args, status: 'pending' });
203
+ * const receipt = await unsafeHost.chargeStripe({
204
+ * amount: order.amount,
205
+ * token: args.token,
206
+ * });
207
+ * await actions.update('orders', { id: order.id, status: 'paid', receipt });
208
+ * return order;
209
+ * }`,
210
+ * sandbox: {
211
+ * unsafeHost: {
212
+ * chargeStripe: ({ amount, token }) =>
213
+ * stripe.charges.create({ amount, source: token }),
214
+ * },
215
+ * },
216
+ * });
217
+ * ```
218
+ */
219
+ unsafeHost?: Record<string, (...args: any[]) => unknown | Promise<unknown>>;
174
220
  };
175
221
  /**
176
222
  * Build a lazy runner for one mutation's sandboxed source. The first call
@@ -178,6 +178,28 @@ export type SyncEngine = {
178
178
  * mutation / a handler throw. Drive this from the transport's mutate frame.
179
179
  */
180
180
  runMutation: (name: string, args: unknown, ctx: unknown) => Promise<unknown>;
181
+ /**
182
+ * Atomically run N mutations in a single transaction (sync 1.11+).
183
+ * Each `{ name, args }` spec is authorized, then handlers fire in
184
+ * order against shared buffered changes. If any handler throws, the
185
+ * entire transaction rolls back — no partial commits, no fanned-out
186
+ * diffs. On success the accumulated changes apply as ONE live batch
187
+ * and the per-mutation results return in order.
188
+ *
189
+ * No retry policy applies to batches in v0.2; configure per-mutation
190
+ * retries on individual `runMutation` calls when atomicity isn't
191
+ * needed. A failed batch passes the original error through with no
192
+ * wrapping.
193
+ *
194
+ * Requires `transaction` to be set in {@link SyncEngineOptions} for
195
+ * actual DB-level atomicity; without it the batch still buffers
196
+ * changes into one fan-out but the underlying adapter writes
197
+ * piecemeal.
198
+ */
199
+ runMutations: (specs: Array<{
200
+ name: string;
201
+ args: unknown;
202
+ }>, ctx: unknown) => Promise<unknown[]>;
181
203
  /**
182
204
  * A point-in-time snapshot of the engine for devtools: registered collections
183
205
  * (+ kind, tables, live subscription counts), mutations, schedules, readers,
package/dist/index.js CHANGED
@@ -740,7 +740,7 @@ var wrap = (source) => `
740
740
  const userFn = (${source});
741
741
  if (typeof userFn !== 'function') {
742
742
  throw new Error(
743
- 'sandboxedHandler must evaluate to (args, ctx, actions) => result; got ' +
743
+ 'sandboxedHandler must evaluate to (args, ctx, actions, unsafeHost) => result; got ' +
744
744
  typeof userFn
745
745
  );
746
746
  }
@@ -752,12 +752,24 @@ var wrap = (source) => `
752
752
  now: () => __dispatch(__callId, 'now'),
753
753
  fetch: (url, init) => __dispatch(__callId, 'fetch', url, init)
754
754
  };
755
- return userFn(args, ctx, actions);
755
+ // Escape hatch \u2014 host fns the mutation explicitly opted in to.
756
+ // The Proxy means every property access is a host call; the
757
+ // engine throws if the property name isn't declared in the
758
+ // mutation's sandbox.unsafeHost map.
759
+ const unsafeHost = new Proxy({}, {
760
+ get: (_target, fnName) => {
761
+ if (typeof fnName !== 'string') return undefined;
762
+ return (...callArgs) =>
763
+ __dispatch(__callId, 'unsafeHost', fnName, callArgs);
764
+ }
765
+ });
766
+ return userFn(args, ctx, actions, unsafeHost);
756
767
  }
757
768
  `;
758
769
  var compile = async (source, config, bridgeFetch) => {
759
770
  const { Reference, createIsolatedRunner, resolveIsolatePolicy } = await loadIsolatedJsc();
760
771
  const callMap = new Map;
772
+ const unsafeHost = config.unsafeHost;
761
773
  const dispatch = new Reference((callId, op, ...rest) => {
762
774
  const a = callMap.get(callId);
763
775
  if (a === undefined) {
@@ -776,6 +788,14 @@ var compile = async (source, config, bridgeFetch) => {
776
788
  return a.now();
777
789
  case "fetch":
778
790
  return runBridgeFetch(bridgeFetch, rest[0], rest[1]);
791
+ case "unsafeHost": {
792
+ const fnName = rest[0];
793
+ const callArgs = rest[1] ?? [];
794
+ if (unsafeHost === undefined || typeof unsafeHost[fnName] !== "function") {
795
+ throw new Error(`sandboxedHandler called unsafeHost.${fnName}() but it was not declared in the mutation's sandbox.unsafeHost config. Declare it (and only the host fns you intend to expose) to opt in to the escape hatch.`);
796
+ }
797
+ return unsafeHost[fnName](...callArgs);
798
+ }
779
799
  default:
780
800
  throw new Error(`unknown sandbox action op: ${String(op)}`);
781
801
  }
@@ -2065,6 +2085,55 @@ var createSyncEngine = (options = {}) => {
2065
2085
  }
2066
2086
  throw lastError;
2067
2087
  },
2088
+ runMutations: async (specs, ctx) => {
2089
+ if (specs.length === 0)
2090
+ return [];
2091
+ const resolved = specs.map((spec) => {
2092
+ const mutation = mutations.get(spec.name);
2093
+ if (mutation === undefined) {
2094
+ throw new Error(`Unknown mutation "${spec.name}"`);
2095
+ }
2096
+ return { args: spec.args, mutation, name: spec.name };
2097
+ });
2098
+ const runBatch = async (tx) => {
2099
+ const results = [];
2100
+ const accumulated = [];
2101
+ for (const { args, mutation, name } of resolved) {
2102
+ if (mutation.authorize !== undefined) {
2103
+ const allowed = await mutation.authorize(args, ctx);
2104
+ if (!allowed) {
2105
+ throw new UnauthorizedError(`run mutation "${name}"`);
2106
+ }
2107
+ }
2108
+ const sandboxRunner = sandboxRunners.get(name);
2109
+ const invokeHandler = sandboxRunner !== undefined ? sandboxRunner : (a, c, actions2) => Promise.resolve(mutation.handler(a, c, actions2));
2110
+ const { actions, buffered } = makeActions(tx, ctx, true);
2111
+ const result = await invokeHandler(args, ctx, actions);
2112
+ results.push(result);
2113
+ accumulated.push(...buffered);
2114
+ }
2115
+ return { accumulated, results };
2116
+ };
2117
+ try {
2118
+ const { accumulated, results } = runInTransaction !== undefined ? await runInTransaction((tx) => runBatch(tx)) : await runBatch(undefined);
2119
+ await applyChangeBatch(accumulated);
2120
+ emitActivity({
2121
+ type: "mutationBatch",
2122
+ at: Date.now(),
2123
+ names: resolved.map((entry) => entry.name),
2124
+ status: "ok"
2125
+ });
2126
+ return results;
2127
+ } catch (error) {
2128
+ emitActivity({
2129
+ type: "mutationBatch",
2130
+ at: Date.now(),
2131
+ names: resolved.map((entry) => entry.name),
2132
+ status: "error"
2133
+ });
2134
+ throw error;
2135
+ }
2136
+ },
2068
2137
  registerSchedule: (schedule) => {
2069
2138
  schedules.set(schedule.name, schedule);
2070
2139
  },
@@ -2626,5 +2695,5 @@ export {
2626
2695
  createPresenceHub
2627
2696
  };
2628
2697
 
2629
- //# debugId=2C8AB0508E99C22364756E2164756E21
2698
+ //# debugId=DA5074BFF8CB679464756E2164756E21
2630
2699
  //# sourceMappingURL=index.js.map