@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.
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +68 -9
- package/dist/engine/index.js.map +5 -5
- package/dist/engine/mutation.d.ts +22 -0
- package/dist/engine/sandbox.d.ts +71 -10
- package/dist/engine/syncEngine.d.ts +16 -1
- package/dist/index.js +68 -9
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
|
@@ -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> = {
|
package/dist/engine/sandbox.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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,
|
|
1847
|
-
|
|
1848
|
-
|
|
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=
|
|
2432
|
+
//# debugId=29525303DD4B75D864756E2164756E21
|
|
2374
2433
|
//# sourceMappingURL=index.js.map
|