@agent-native/core 0.120.4 → 0.121.1
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +20 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +12 -4
- package/corpus/core/src/agent/run-manager.ts +3 -0
- package/corpus/core/src/client/use-db-sync.ts +23 -21
- package/corpus/core/src/deploy/build.ts +1 -0
- package/corpus/core/src/server/core-routes-plugin.ts +3 -0
- package/corpus/core/src/server/gateway-access-check.ts +67 -0
- package/corpus/core/src/server/poll.ts +295 -9
- package/corpus/core/src/server/short-lived-token.ts +122 -0
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
- package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
- package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +16 -4
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +6 -0
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +27 -22
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +1 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/gateway-access-check.d.ts +12 -0
- package/dist/server/gateway-access-check.d.ts.map +1 -0
- package/dist/server/gateway-access-check.js +49 -0
- package/dist/server/gateway-access-check.js.map +1 -0
- package/dist/server/poll.d.ts +55 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +254 -7
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/short-lived-token.d.ts +28 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +78 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent/durable-background.ts +12 -4
- package/src/agent/run-manager.ts +3 -0
- package/src/client/use-db-sync.ts +23 -21
- package/src/deploy/build.ts +1 -0
- package/src/server/core-routes-plugin.ts +3 -0
- package/src/server/gateway-access-check.ts +67 -0
- package/src/server/poll.ts +295 -9
- package/src/server/short-lived-token.ts +122 -0
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.121.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 99997d8: Recognize Netlify's documented Function runtime marker so opted-in durable background work reaches its generated worker instead of falling back to a short synchronous route.
|
|
8
|
+
|
|
9
|
+
## 0.121.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 4e64dd1: Realtime sync: two hosted-gateway follow-ups. Both are opt-in and leave apps without hosted-realtime config unchanged.
|
|
14
|
+
- Hosted SSE reconnect now sends the client's cursor (`&since=`) on the gateway stream URL, so the gateway's connect-time catch-up replays events written during the reconnect gap immediately instead of deferring them to the next poll. First connect (cursor 0) is unaffected. Because a `since=` only takes effect when a new `EventSource` is constructed (the browser's own auto-reconnect reuses the URL frozen at construction), the hosted transport now owns reconnects: on a stream error it closes the stream and schedules its own reconnect so the next connect rebuilds the URL from the current cursor, keeping the token on transient errors and reminting on a closed stream. A late error from a replaced (stale) stream is ignored so it cannot tear down the current one. Local mode keeps native EventSource reconnect.
|
|
15
|
+
- New gateway access-check tokens (`signGatewayAccessToken`/`verifyGatewayAccessToken`, exported from `./server/short-lived-token`): per-project HMAC key, a `typ` discriminator (not interchangeable with subscribe or media tokens), and the full access query (`resourceType`/`resourceId`/`userEmail`/`orgId`) bound into the signature so the app authenticates the params, not just the caller. `verifyGatewayAccessToken` also accepts an optional expected `projectId` to bind the channel (mirroring `verifyRealtimeSubscribeToken`); the `can-see` endpoint passes its own project id when known.
|
|
16
|
+
- New endpoint `GET /_agent-native/can-see` mounted by core-routes. The hosted Realtime Gateway has no copy of an app's shareable-resource registry, so it calls this to resolve sharee visibility: the endpoint verifies a gateway access-check token against the app's per-project secret, runs the app's registry-based `resolveAccess`, and returns `{ allowed }`. Fails closed (`allowed: false`) on an unknown resource type or lookup error; 404 when the app has no realtime secret; `Cache-Control: private, no-store`.
|
|
17
|
+
- New `AppSyncStateOptions.dbAssignedVersions` (default off): allocate durable-event versions from the app's Postgres DB — a one-row allocator advanced with `GREATEST(v + 1, epoch_ms_now)` inside the same autocommit insert statement — instead of the per-writer in-memory clock counter. Hosted realtime has multiple writers per app DB (the app's serverless instances plus gateway instances), where clock skew can assign a LOWER version to a LATER event and a client whose cursor passed the higher value filters the later event out permanently; DB allocation serializes on the allocator row's lock so version order equals commit order across all writers. Buffer/emit defer until the allocated version returns (no provisional version ever reaches clients); a deterministic-id dedupe loser adopts the winner's version via `ON CONFLICT DO UPDATE ... RETURNING`; on DB failure the writer falls back to clock allocation (logged) so the in-process fast path never stalls. Versions stay epoch-ms scale, so existing cursors, seeds, and lag metrics are unaffected. The default instance enables this automatically when `AGENT_NATIVE_REALTIME_TRANSPORT=hosted` with a gateway URL; self-hosted apps are byte-identical. Postgres only; ignored on SQLite.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 3adc377: Explicitly disable dependency bundling for generated self-contained Netlify keep-warm functions.
|
|
22
|
+
|
|
3
23
|
## 0.120.4
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.1",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -114,10 +114,15 @@ function isNetlifyHostedRuntimeForDispatch(): boolean {
|
|
|
114
114
|
if (process.env.NETLIFY_LOCAL === "true") return false;
|
|
115
115
|
if (process.env.NETLIFY === "false") return false;
|
|
116
116
|
if (process.env.NETLIFY && process.env.NETLIFY !== "false") return true;
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
117
|
+
// NETLIFY is a build-only read-only variable. In deployed Functions Netlify
|
|
118
|
+
// documents URL, SITE_NAME, and SITE_ID as the runtime read-only variables;
|
|
119
|
+
// SITE_ID is the unambiguous host marker. Lambda compatibility mode also
|
|
120
|
+
// exposes AWS runtime variables, so keep the function-name fallback for older
|
|
121
|
+
// deploys. Without either check a modern Netlify Function silently selects the
|
|
122
|
+
// portable framework route even though the emitted background function exists.
|
|
123
|
+
if (process.env.SITE_ID) return true; // guard:allow-env-credential -- Netlify's read-only public site identifier is a runtime host marker, not a user credential.
|
|
124
|
+
// Non-Netlify AWS falls back inline if the /.netlify/functions dispatch
|
|
125
|
+
// fast-fails.
|
|
121
126
|
return Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
|
122
127
|
}
|
|
123
128
|
|
|
@@ -202,6 +207,9 @@ export const AGENT_CHAT_BACKGROUND_RUN_FIELD = "__backgroundRun";
|
|
|
202
207
|
* what "hosted" means.
|
|
203
208
|
*/
|
|
204
209
|
export function isHostedRuntimeForDurableBackground(): boolean {
|
|
210
|
+
if (process.env.NETLIFY_LOCAL === "true") return false;
|
|
211
|
+
if (process.env.NETLIFY === "false") return false;
|
|
212
|
+
if (process.env.SITE_ID) return true; // guard:allow-env-credential -- Netlify's read-only public site identifier is a runtime host marker, not a user credential.
|
|
205
213
|
if (
|
|
206
214
|
process.env.NETLIFY &&
|
|
207
215
|
process.env.NETLIFY !== "false" &&
|
|
@@ -319,6 +319,9 @@ export interface ResolveRunSoftTimeoutOptions {
|
|
|
319
319
|
* that clamp (and the platform wall behind it) exists.
|
|
320
320
|
*/
|
|
321
321
|
export function isHostedRuntime(): boolean {
|
|
322
|
+
if (process.env.NETLIFY_LOCAL === "true") return false;
|
|
323
|
+
if (process.env.NETLIFY === "false") return false;
|
|
324
|
+
if (process.env.SITE_ID) return true; // guard:allow-env-credential -- Netlify's read-only public site identifier is a runtime host marker, not a user credential.
|
|
322
325
|
if (
|
|
323
326
|
process.env.NETLIFY &&
|
|
324
327
|
process.env.NETLIFY !== "false" &&
|
|
@@ -330,9 +330,11 @@ class SyncTransport {
|
|
|
330
330
|
|
|
331
331
|
private get activeSseUrl(): string | false {
|
|
332
332
|
if (this.mode === "hosted" && this.gateway) {
|
|
333
|
-
return this.
|
|
334
|
-
|
|
335
|
-
|
|
333
|
+
if (!this.token) return this.gateway.sseUrl;
|
|
334
|
+
const base = `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`;
|
|
335
|
+
// Cursor lets the gateway replay the reconnect gap on connect instead of
|
|
336
|
+
// deferring it to the next poll; 0 on first connect means nothing to replay.
|
|
337
|
+
return this.versionRef > 0 ? `${base}&since=${this.versionRef}` : base;
|
|
336
338
|
}
|
|
337
339
|
return this.sseUrl;
|
|
338
340
|
}
|
|
@@ -656,27 +658,27 @@ class SyncTransport {
|
|
|
656
658
|
this.schedulePoll();
|
|
657
659
|
};
|
|
658
660
|
source.onerror = () => {
|
|
661
|
+
// A replaced/closed source can still fire late; ignore it so it can't
|
|
662
|
+
// flip the connected state or tear down the current stream.
|
|
663
|
+
if (this.eventSource !== source) return;
|
|
659
664
|
this.setSseConnected(false);
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
666
|
+
// Browser auto-reconnect reuses the URL frozen at construction, so it
|
|
667
|
+
// would replay from a stale `since`. Own the reconnect so the next
|
|
668
|
+
// connect rebuilds activeSseUrl from the current versionRef. CLOSED also
|
|
669
|
+
// refreshes the token (expired/rotated/deploy); CONNECTING keeps it. A
|
|
670
|
+
// successful reconnect resets the count in onopen; a hard-down gateway
|
|
671
|
+
// trips the threshold and health-gates to local.
|
|
672
|
+
if (source.readyState === EventSource.CLOSED) this.token = null;
|
|
673
|
+
this.closeEvents();
|
|
674
|
+
this.onGatewayTransientFailure();
|
|
675
|
+
if (this.mode === "hosted") this.scheduleGatewayReconnect();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Local mode: native EventSource reconnect is fine. Drop a CLOSED ref so a
|
|
679
|
+
// later connectEvents() (focus/visibility) can establish a fresh stream.
|
|
665
680
|
if (source.readyState === EventSource.CLOSED) {
|
|
666
681
|
this.eventSource = null;
|
|
667
|
-
if (this.mode === "hosted" && this.gateway) {
|
|
668
|
-
// A closed gateway stream is most likely an expired token or a
|
|
669
|
-
// request-timeout/deploy cycle. Re-mint and reconnect with jitter;
|
|
670
|
-
// this is NOT the poll-401 cooldown path. Each closed stream counts
|
|
671
|
-
// toward the unhealthy threshold so a hard-down gateway (or one
|
|
672
|
-
// rejecting our tokens) health-gates to local instead of looping
|
|
673
|
-
// mint+connect forever; a successful reconnect resets the count in
|
|
674
|
-
// onopen above.
|
|
675
|
-
this.token = null;
|
|
676
|
-
this.onGatewayTransientFailure();
|
|
677
|
-
if (this.mode === "hosted") this.scheduleGatewayReconnect();
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
682
|
}
|
|
681
683
|
this.schedulePoll();
|
|
682
684
|
};
|
|
@@ -159,6 +159,7 @@ import {
|
|
|
159
159
|
markDefaultPluginProvided,
|
|
160
160
|
trackPluginInit,
|
|
161
161
|
} from "./framework-request-handler.js";
|
|
162
|
+
import { createGatewayAccessCheckHandler } from "./gateway-access-check.js";
|
|
162
163
|
import { getAppBasePath, getOrigin } from "./google-oauth.js";
|
|
163
164
|
import { createGoogleRealtimeSessionHandler } from "./google-realtime-session.js";
|
|
164
165
|
import {
|
|
@@ -1429,6 +1430,8 @@ export function createCoreRoutesPlugin(
|
|
|
1429
1430
|
`${P}/realtime-token`,
|
|
1430
1431
|
createRealtimeTokenHandler(),
|
|
1431
1432
|
);
|
|
1433
|
+
// Sharee visibility check for the hosted gateway
|
|
1434
|
+
getH3App(nitroApp).use(`${P}/can-see`, createGatewayAccessCheckHandler());
|
|
1432
1435
|
|
|
1433
1436
|
// SSE
|
|
1434
1437
|
if (!options.disableSSE) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /_agent-native/can-see` — the hosted Realtime Gateway's sharee-visibility
|
|
3
|
+
* check. Verifies a gateway access-check token (rationale in
|
|
4
|
+
* short-lived-token.ts), runs the app's own `resolveAccess`, answers
|
|
5
|
+
* `{ allowed }`, and fails closed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
defineEventHandler,
|
|
10
|
+
getMethod,
|
|
11
|
+
getQuery,
|
|
12
|
+
type H3Event,
|
|
13
|
+
setResponseHeader,
|
|
14
|
+
setResponseStatus,
|
|
15
|
+
} from "h3";
|
|
16
|
+
|
|
17
|
+
import { resolveAccess } from "../sharing/access.js";
|
|
18
|
+
import { getBuilderBranchProjectId } from "./builder-browser.js";
|
|
19
|
+
import { getRealtimeSigningSecret } from "./realtime-token.js";
|
|
20
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
21
|
+
import { verifyGatewayAccessToken } from "./short-lived-token.js";
|
|
22
|
+
|
|
23
|
+
export function createGatewayAccessCheckHandler() {
|
|
24
|
+
return defineEventHandler(async (event: H3Event) => {
|
|
25
|
+
setResponseHeader(event, "Cache-Control", "private, no-store");
|
|
26
|
+
|
|
27
|
+
if (getMethod(event) !== "GET") {
|
|
28
|
+
setResponseStatus(event, 405);
|
|
29
|
+
return { error: "Method not allowed" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const secret = getRealtimeSigningSecret();
|
|
33
|
+
if (!secret) {
|
|
34
|
+
setResponseStatus(event, 404);
|
|
35
|
+
return { error: "Realtime gateway not configured" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const token = getQuery(event).token;
|
|
39
|
+
// Sync, env-only: binds the token's channel when this app's project id is
|
|
40
|
+
// known, and no-ops (undefined) for scoped-secret apps where it isn't.
|
|
41
|
+
const expectedProjectId = getBuilderBranchProjectId() || undefined;
|
|
42
|
+
const verified =
|
|
43
|
+
typeof token === "string"
|
|
44
|
+
? verifyGatewayAccessToken(token, secret, expectedProjectId)
|
|
45
|
+
: ({ ok: false, reason: "missing" } as const);
|
|
46
|
+
if (!verified.ok) {
|
|
47
|
+
setResponseStatus(event, 401);
|
|
48
|
+
return { error: "Unauthorized" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { resourceType, resourceId, userEmail, orgId } = verified;
|
|
52
|
+
return runWithRequestContext({ userEmail, orgId }, async () => {
|
|
53
|
+
try {
|
|
54
|
+
const access = await resolveAccess(
|
|
55
|
+
resourceType,
|
|
56
|
+
resourceId,
|
|
57
|
+
{ userEmail, orgId },
|
|
58
|
+
{ skipResourceBody: true },
|
|
59
|
+
);
|
|
60
|
+
return { allowed: access != null };
|
|
61
|
+
} catch {
|
|
62
|
+
// Unknown resource type or lookup failure: fail closed.
|
|
63
|
+
return { allowed: false };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -93,6 +93,42 @@ const ACCESS_CACHE_DENY_TTL_MS = 5_000;
|
|
|
93
93
|
const ACCESS_CACHE_MAX = 500;
|
|
94
94
|
const SCREEN_REFRESH_KEY = "__screen_refresh__";
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* DB-assigned version allocation (see `AppSyncStateOptions.dbAssignedVersions`).
|
|
98
|
+
* The allocator is a one-row table advanced with `GREATEST(v + 1, epoch_ms)` —
|
|
99
|
+
* the SQL analog of the in-memory `Math.max(version + 1, Date.now())` — inside
|
|
100
|
+
* the same autocommit statement as the event insert. The row lock is held to
|
|
101
|
+
* the statement's implicit commit, so version order equals commit order across
|
|
102
|
+
* every writer, closing the cross-writer clock-skew hole no sequence closes
|
|
103
|
+
* (sequences serialize allocation, not commit visibility). Single-statement is
|
|
104
|
+
* required: the gateway reaches the app DB through a pooled (transactionless)
|
|
105
|
+
* connection. `ON CONFLICT DO UPDATE` (not DO NOTHING) so a deterministic-id
|
|
106
|
+
* dedupe loser still gets the winner's version back and never emits a phantom
|
|
107
|
+
* version to its own clients. The stored JSON is restamped with the allocated
|
|
108
|
+
* version; the read path's column-wins override makes that belt-and-suspenders.
|
|
109
|
+
*/
|
|
110
|
+
const SEED_SYNC_VERSION_SQL = `
|
|
111
|
+
INSERT INTO sync_version (id, v)
|
|
112
|
+
SELECT 1, GREATEST(
|
|
113
|
+
COALESCE((SELECT MAX(version) FROM sync_events), 0),
|
|
114
|
+
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT
|
|
115
|
+
)
|
|
116
|
+
ON CONFLICT (id) DO NOTHING
|
|
117
|
+
`;
|
|
118
|
+
const ALLOCATING_INSERT_SQL = `
|
|
119
|
+
WITH alloc AS (
|
|
120
|
+
UPDATE sync_version
|
|
121
|
+
SET v = GREATEST(v + 1, (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT, (?)::BIGINT)
|
|
122
|
+
WHERE id = 1
|
|
123
|
+
RETURNING v
|
|
124
|
+
)
|
|
125
|
+
INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
126
|
+
SELECT ?, alloc.v, jsonb_set((?)::jsonb, '{version}', to_jsonb(alloc.v))::text, ?, ?, ?, ?, ?, ?, ?, ?
|
|
127
|
+
FROM alloc
|
|
128
|
+
ON CONFLICT (id) DO UPDATE SET id = excluded.id
|
|
129
|
+
RETURNING version
|
|
130
|
+
`;
|
|
131
|
+
|
|
96
132
|
function timestampValue(value: unknown): number {
|
|
97
133
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
98
134
|
if (typeof value !== "string") return 0;
|
|
@@ -341,6 +377,22 @@ export interface AppSyncStateOptions {
|
|
|
341
377
|
* across instances.
|
|
342
378
|
*/
|
|
343
379
|
deterministicEventIds?: boolean;
|
|
380
|
+
/**
|
|
381
|
+
* Allocate durable-event versions from the app's Postgres DB (a one-row
|
|
382
|
+
* allocator advanced with `GREATEST(v + 1, epoch_ms_now)` inside the insert
|
|
383
|
+
* statement) instead of the per-writer in-memory clock counter.
|
|
384
|
+
*
|
|
385
|
+
* Off by default: a single-writer app's clock counter is already monotonic.
|
|
386
|
+
* Hosted realtime has MULTIPLE writers per app DB (the app's serverless
|
|
387
|
+
* instances plus gateway instances), where clock skew can assign a LOWER
|
|
388
|
+
* version to a LATER event — a client whose cursor passed the higher value
|
|
389
|
+
* then filters the later event out forever. DB allocation serializes on the
|
|
390
|
+
* allocator row's lock, so version order equals commit order across all
|
|
391
|
+
* writers. Versions stay on the epoch-ms scale (existing cursors, the
|
|
392
|
+
* detector's timestamp-mixed seed, and lag metrics all assume it).
|
|
393
|
+
* Postgres only; ignored on SQLite.
|
|
394
|
+
*/
|
|
395
|
+
dbAssignedVersions?: boolean;
|
|
344
396
|
}
|
|
345
397
|
|
|
346
398
|
/**
|
|
@@ -354,6 +406,13 @@ export class AppSyncState {
|
|
|
354
406
|
private readonly isPg: () => boolean;
|
|
355
407
|
private readonly resolveAccessFn: AccessResolver;
|
|
356
408
|
private readonly deterministicEventIds: boolean;
|
|
409
|
+
private readonly dbAssignedVersions: boolean;
|
|
410
|
+
/** Serializes gated recordChange calls so buffer/emit order matches
|
|
411
|
+
* allocation order (interleaved allocations could buffer out of order). */
|
|
412
|
+
private recordChain: Promise<void> = Promise.resolve();
|
|
413
|
+
/** Count of DB-allocation failures that fell back to clock versions. */
|
|
414
|
+
private dbVersionFallbacks = 0;
|
|
415
|
+
private warnedListenerThrow = false;
|
|
357
416
|
|
|
358
417
|
// Timestamp-aligned versions so all serverless instances produce values in
|
|
359
418
|
// the same range (seeded from DB, then incremented via Date.now). Plain
|
|
@@ -415,6 +474,7 @@ export class AppSyncState {
|
|
|
415
474
|
this.isPg = options.isPostgres ?? isPostgres;
|
|
416
475
|
this.resolveAccessFn = options.resolveAccess ?? defaultResolveAccess;
|
|
417
476
|
this.deterministicEventIds = options.deterministicEventIds ?? false;
|
|
477
|
+
this.dbAssignedVersions = options.dbAssignedVersions ?? false;
|
|
418
478
|
this.pollEmitter.setMaxListeners(0);
|
|
419
479
|
}
|
|
420
480
|
|
|
@@ -522,6 +582,16 @@ export class AppSyncState {
|
|
|
522
582
|
"CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
|
|
523
583
|
guardOptions,
|
|
524
584
|
);
|
|
585
|
+
if (this.dbAssignedVersions) {
|
|
586
|
+
await ensureTableExists(
|
|
587
|
+
"sync_version",
|
|
588
|
+
"CREATE TABLE IF NOT EXISTS sync_version (id INT PRIMARY KEY, v BIGINT NOT NULL)",
|
|
589
|
+
guardOptions,
|
|
590
|
+
);
|
|
591
|
+
// Seed at/above both the existing durable max and wall clock, so
|
|
592
|
+
// allocated versions never land below live client cursors.
|
|
593
|
+
await client.execute(SEED_SYNC_VERSION_SQL);
|
|
594
|
+
}
|
|
525
595
|
return true;
|
|
526
596
|
}
|
|
527
597
|
|
|
@@ -561,6 +631,7 @@ export class AppSyncState {
|
|
|
561
631
|
async persistSyncEvent(
|
|
562
632
|
event: ChangeEvent,
|
|
563
633
|
dedupeKey?: string,
|
|
634
|
+
presetId?: string,
|
|
564
635
|
): Promise<void> {
|
|
565
636
|
if (!(await this.ensureSyncEventsTable())) return;
|
|
566
637
|
const client = this.getDb();
|
|
@@ -573,7 +644,9 @@ export class AppSyncState {
|
|
|
573
644
|
: `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
574
645
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
575
646
|
args: [
|
|
576
|
-
|
|
647
|
+
// presetId lets a gated fallback reuse the allocating attempt's id,
|
|
648
|
+
// so a commit-then-timeout can't produce the same event twice.
|
|
649
|
+
presetId ?? this.durableEventId(event, dedupeKey),
|
|
577
650
|
event.version,
|
|
578
651
|
JSON.stringify(event),
|
|
579
652
|
event.source,
|
|
@@ -590,6 +663,84 @@ export class AppSyncState {
|
|
|
590
663
|
await this.pruneDurableEvents(client);
|
|
591
664
|
}
|
|
592
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Persist with a DB-allocated version (see ALLOCATING_INSERT_SQL). Returns
|
|
668
|
+
* the allocated version — for a deterministic-id dedupe loser, the winner's
|
|
669
|
+
* existing version — or null when persistence is unavailable (caller falls
|
|
670
|
+
* back to clock allocation). Unlike `persistSyncEvent`, errors PROPAGATE to
|
|
671
|
+
* the caller: silence here would silently drop the event entirely, since the
|
|
672
|
+
* buffer/emit are deferred behind this call.
|
|
673
|
+
*/
|
|
674
|
+
private async persistWithDbAssignedVersion(
|
|
675
|
+
event: { source: string; type: string; key?: string; [k: string]: unknown },
|
|
676
|
+
id: string,
|
|
677
|
+
): Promise<number | null> {
|
|
678
|
+
if (!(await this.ensureSyncEventsTable())) return null;
|
|
679
|
+
const client = this.getDb();
|
|
680
|
+
const args = [
|
|
681
|
+
// Local monotonicity floor: after a clock fallback, the next allocation
|
|
682
|
+
// must not land at/below versions this writer already emitted.
|
|
683
|
+
this.version + 1,
|
|
684
|
+
id,
|
|
685
|
+
// jsonb (unlike the legacy TEXT write) rejects \\u0000 escapes; strip
|
|
686
|
+
// them rather than diverting those events to the clock fallback.
|
|
687
|
+
JSON.stringify(event).split("\\u0000").join(""),
|
|
688
|
+
event.source,
|
|
689
|
+
event.type,
|
|
690
|
+
event.key ?? null,
|
|
691
|
+
(event.owner as string | undefined) ?? null,
|
|
692
|
+
(event.orgId as string | undefined) ?? null,
|
|
693
|
+
(event.resourceType as string | undefined) ?? null,
|
|
694
|
+
(event.resourceId as string | undefined) ?? null,
|
|
695
|
+
Date.now(),
|
|
696
|
+
];
|
|
697
|
+
let result = await client.execute({ sql: ALLOCATING_INSERT_SQL, args });
|
|
698
|
+
if (result.rows.length === 0) {
|
|
699
|
+
// Allocator row missing (e.g. wiped after ensure): reseed, retry once.
|
|
700
|
+
await client.execute(SEED_SYNC_VERSION_SQL).catch(() => {});
|
|
701
|
+
result = await client.execute({ sql: ALLOCATING_INSERT_SQL, args });
|
|
702
|
+
}
|
|
703
|
+
const version = timestampValue(result.rows[0]?.version);
|
|
704
|
+
await this.pruneDurableEvents(client);
|
|
705
|
+
return version > 0 ? version : null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Read back the version of an already-committed row (a failed allocating
|
|
709
|
+
* call whose statement actually committed). Null when absent/unreachable. */
|
|
710
|
+
private async recoverCommittedVersion(id: string): Promise<number | null> {
|
|
711
|
+
try {
|
|
712
|
+
const result = await this.getDb().execute({
|
|
713
|
+
sql: "SELECT version FROM sync_events WHERE id = ?",
|
|
714
|
+
args: [id],
|
|
715
|
+
});
|
|
716
|
+
const version = timestampValue(result.rows[0]?.version);
|
|
717
|
+
return version > 0 ? version : null;
|
|
718
|
+
} catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Lift the allocator to at least `floor`. Client cursors are max-only, so
|
|
725
|
+
* any value that can become a cursor (notably the seed's app-clock
|
|
726
|
+
* `updated_at` domain) must never exceed the allocator — a cursor above it
|
|
727
|
+
* would filter later, lower-allocated events out permanently. Failure is
|
|
728
|
+
* swallowed: alignment degrades to the same soft guarantee as the clock
|
|
729
|
+
* fallback.
|
|
730
|
+
*/
|
|
731
|
+
private async alignVersionAllocator(floor: number): Promise<void> {
|
|
732
|
+
if (floor <= 0) return;
|
|
733
|
+
try {
|
|
734
|
+
if (!(await this.ensureSyncEventsTable())) return;
|
|
735
|
+
await this.getDb().execute({
|
|
736
|
+
sql: "UPDATE sync_version SET v = GREATEST(v, (?)::BIGINT) WHERE id = 1",
|
|
737
|
+
args: [floor],
|
|
738
|
+
});
|
|
739
|
+
} catch {
|
|
740
|
+
// Soft guarantee under DB failure, matching the clock fallback.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
593
744
|
async readMaxSyncEventVersion(): Promise<number> {
|
|
594
745
|
if (!(await this.ensureSyncEventsTable())) return 0;
|
|
595
746
|
try {
|
|
@@ -787,14 +938,111 @@ export class AppSyncState {
|
|
|
787
938
|
},
|
|
788
939
|
opts?: { dedupeKey?: string },
|
|
789
940
|
): void {
|
|
941
|
+
if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
|
|
942
|
+
// No provisional version may reach clients: cursors are max-only, so an
|
|
943
|
+
// emitted clock version above a later DB allocation would recreate the
|
|
944
|
+
// skew bug. Buffer/emit happen only after the DB returns the version,
|
|
945
|
+
// serialized so entries land in allocation order. The catch is a
|
|
946
|
+
// backstop: one rejected link must never poison the chain and silently
|
|
947
|
+
// drop every later event.
|
|
948
|
+
this.recordChain = this.recordChain
|
|
949
|
+
.then(() => this.recordWithDbVersion(event, opts?.dedupeKey))
|
|
950
|
+
.catch(() => {});
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
790
953
|
this.version = Math.max(this.version + 1, Date.now());
|
|
791
954
|
const entry: ChangeEvent = { ...event, version: this.version };
|
|
955
|
+
this.commitEntry(entry);
|
|
956
|
+
void this.persistSyncEvent(entry, opts?.dedupeKey);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Buffer + emit. Shared by both version-allocation modes. */
|
|
960
|
+
private commitEntry(entry: ChangeEvent): void {
|
|
792
961
|
this.buffer.push(entry);
|
|
793
962
|
if (this.buffer.length > MAX_BUFFER) {
|
|
794
963
|
this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
|
|
795
964
|
}
|
|
796
965
|
this.pollEmitter.emit(POLL_CHANGE_EVENT, entry);
|
|
797
|
-
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private async recordWithDbVersion(
|
|
969
|
+
event: { source: string; type: string; key?: string; [k: string]: unknown },
|
|
970
|
+
dedupeKey?: string,
|
|
971
|
+
): Promise<void> {
|
|
972
|
+
// One id for both the allocating attempt AND any fallback persist: if the
|
|
973
|
+
// allocating INSERT commits server-side but the call fails (e.g. a query
|
|
974
|
+
// timeout after commit), the fallback's ON CONFLICT dedupes against the
|
|
975
|
+
// committed row instead of writing the same event twice under two ids.
|
|
976
|
+
// Deterministic ids exclude version by design; the random fallback id gets
|
|
977
|
+
// a wall-clock prefix instead of its usual version prefix (the prefix is
|
|
978
|
+
// debuggability, not semantics — nothing parses row ids).
|
|
979
|
+
const id =
|
|
980
|
+
this.deterministicEventIds && dedupeKey !== undefined
|
|
981
|
+
? this.durableEventId(
|
|
982
|
+
{ ...event, version: 0 } as ChangeEvent,
|
|
983
|
+
dedupeKey,
|
|
984
|
+
)
|
|
985
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
986
|
+
let version: number | null = null;
|
|
987
|
+
try {
|
|
988
|
+
version = await this.persistWithDbAssignedVersion(event, id);
|
|
989
|
+
} catch {
|
|
990
|
+
version = null;
|
|
991
|
+
}
|
|
992
|
+
if (version == null) {
|
|
993
|
+
// The statement may have COMMITTED even though the call failed (e.g. a
|
|
994
|
+
// timeout after commit). Recover the durable row's version by the shared
|
|
995
|
+
// id so clients see the allocator-assigned value, never a divergent
|
|
996
|
+
// clock one.
|
|
997
|
+
version = await this.recoverCommittedVersion(id);
|
|
998
|
+
}
|
|
999
|
+
if (version == null) {
|
|
1000
|
+
// Availability fallback: a DB blip must not stall the in-process fast
|
|
1001
|
+
// path. Clock-allocate and emit — but first make a best effort to lift
|
|
1002
|
+
// the allocator to the fallback value, so other writers' next
|
|
1003
|
+
// allocations land above the cursors this emit will advance (max-only
|
|
1004
|
+
// cursors above the allocator would filter those events permanently).
|
|
1005
|
+
// If the DB is down the lift fails too; the ordering guarantee is soft
|
|
1006
|
+
// exactly while the DB is unhealthy.
|
|
1007
|
+
this.dbVersionFallbacks++;
|
|
1008
|
+
if (this.dbVersionFallbacks === 1) {
|
|
1009
|
+
console.warn(
|
|
1010
|
+
"[agent-native] sync version allocation failed; falling back to clock-assigned versions",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
this.version = Math.max(this.version + 1, Date.now());
|
|
1014
|
+
const entry: ChangeEvent = { ...event, version: this.version };
|
|
1015
|
+
await this.alignVersionAllocator(this.version);
|
|
1016
|
+
this.commitEntryForChain(entry);
|
|
1017
|
+
void this.persistSyncEvent(entry, dedupeKey, id);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// A deterministic-id dedupe loser adopts the winner's (possibly older)
|
|
1021
|
+
// version here — buffer filtering is a full scan, so a non-tail version is
|
|
1022
|
+
// delivered correctly and the version-keyed combined-read dedupe collapses
|
|
1023
|
+
// it against the durable row.
|
|
1024
|
+
this.version = Math.max(this.version, version);
|
|
1025
|
+
this.commitEntryForChain({ ...event, version });
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* `commitEntry` for the deferred (chained) path: the buffer push must land
|
|
1030
|
+
* and a throwing pollEmitter listener must not reject the chain — the
|
|
1031
|
+
* synchronous path propagates such throws to the recordChange caller, but
|
|
1032
|
+
* here there is no caller to propagate to, only the chain to poison.
|
|
1033
|
+
*/
|
|
1034
|
+
private commitEntryForChain(entry: ChangeEvent): void {
|
|
1035
|
+
try {
|
|
1036
|
+
this.commitEntry(entry);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
if (!this.warnedListenerThrow) {
|
|
1039
|
+
this.warnedListenerThrow = true;
|
|
1040
|
+
console.warn(
|
|
1041
|
+
"[agent-native] poll listener threw during deferred emit",
|
|
1042
|
+
err,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
798
1046
|
}
|
|
799
1047
|
|
|
800
1048
|
private recordExtensionChanges(
|
|
@@ -1079,9 +1327,7 @@ export class AppSyncState {
|
|
|
1079
1327
|
refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
|
|
1080
1328
|
}
|
|
1081
1329
|
|
|
1082
|
-
|
|
1083
|
-
this.version = Math.max(
|
|
1084
|
-
this.version,
|
|
1330
|
+
const seedMax = Math.max(
|
|
1085
1331
|
syncEventsTs,
|
|
1086
1332
|
appTs,
|
|
1087
1333
|
settingsTs,
|
|
@@ -1089,6 +1335,15 @@ export class AppSyncState {
|
|
|
1089
1335
|
extensionMarkerTs,
|
|
1090
1336
|
actionMarkerTs,
|
|
1091
1337
|
);
|
|
1338
|
+
// The seed mixes app-clock `updated_at` values that can sit AHEAD of the
|
|
1339
|
+
// allocator (a skew-fast writer's rows). Lift the allocator to the seed
|
|
1340
|
+
// BEFORE the seed can reach this.version — and through it, client
|
|
1341
|
+
// cursors — so no later allocation lands below a seeded cursor.
|
|
1342
|
+
if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
|
|
1343
|
+
await this.alignVersionAllocator(seedMax);
|
|
1344
|
+
}
|
|
1345
|
+
// Seed version — never decrease an already-set value
|
|
1346
|
+
this.version = Math.max(this.version, seedMax);
|
|
1092
1347
|
|
|
1093
1348
|
// Set baselines so checkExternalDbChanges detects future writes
|
|
1094
1349
|
this.lastAppStateTs = appTs;
|
|
@@ -1135,9 +1390,20 @@ export class AppSyncState {
|
|
|
1135
1390
|
// second overlapping run that would double-advance the watermarks.
|
|
1136
1391
|
if (this.checkPromise) return this.checkPromise;
|
|
1137
1392
|
this.lastDbCheck = now;
|
|
1138
|
-
this.checkPromise = this.doCheckExternalDbChanges()
|
|
1139
|
-
|
|
1140
|
-
|
|
1393
|
+
this.checkPromise = this.doCheckExternalDbChanges()
|
|
1394
|
+
.then(() => {
|
|
1395
|
+
// Gated: the detector's recordChange calls only SCHEDULED allocations.
|
|
1396
|
+
// The poll handler awaits this check so detected cross-process writes
|
|
1397
|
+
// land in the same response — drain the chain here to keep that
|
|
1398
|
+
// contract (and so a serverless instance frozen after responding
|
|
1399
|
+
// can't strand them). The chain never rejects (backstopped).
|
|
1400
|
+
if (this.dbAssignedVersions && this.isPg() && !syncEventsDisabled()) {
|
|
1401
|
+
return this.recordChain;
|
|
1402
|
+
}
|
|
1403
|
+
})
|
|
1404
|
+
.finally(() => {
|
|
1405
|
+
this.checkPromise = null;
|
|
1406
|
+
});
|
|
1141
1407
|
return this.checkPromise;
|
|
1142
1408
|
}
|
|
1143
1409
|
|
|
@@ -1361,12 +1627,32 @@ export class AppSyncState {
|
|
|
1361
1627
|
|
|
1362
1628
|
let _defaultState: AppSyncState | undefined;
|
|
1363
1629
|
|
|
1630
|
+
/**
|
|
1631
|
+
* Hosted-realtime gate for the default instance, env-only. Mirrors the
|
|
1632
|
+
* fail-closed transport-AND-url pair in `sentry-config.ts`'s
|
|
1633
|
+
* `resolveRealtimeClientConfig` (not imported — poll.ts is import-cycle
|
|
1634
|
+
* sensitive). Hosted apps are multi-writer (their own serverless instances
|
|
1635
|
+
* plus gateway instances share one DB), so THEY must DB-allocate versions too
|
|
1636
|
+
* — gating only the gateway would leave the app's user-event writes
|
|
1637
|
+
* clock-versioned and the cross-writer skew hole open.
|
|
1638
|
+
*/
|
|
1639
|
+
function hostedRealtimeTransportEnabled(): boolean {
|
|
1640
|
+
return (
|
|
1641
|
+
process.env.AGENT_NATIVE_REALTIME_TRANSPORT?.trim() === "hosted" &&
|
|
1642
|
+
!!process.env.AGENT_NATIVE_REALTIME_GATEWAY_URL?.trim()
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1364
1646
|
/**
|
|
1365
1647
|
* The process-wide default instance, bound to the global DB. All module-level
|
|
1366
1648
|
* exports delegate here so self-hosted apps run exactly one code path.
|
|
1367
1649
|
*/
|
|
1368
1650
|
export function getDefaultAppSyncState(): AppSyncState {
|
|
1369
|
-
if (!_defaultState)
|
|
1651
|
+
if (!_defaultState) {
|
|
1652
|
+
_defaultState = new AppSyncState({
|
|
1653
|
+
dbAssignedVersions: hostedRealtimeTransportEnabled(),
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1370
1656
|
return _defaultState;
|
|
1371
1657
|
}
|
|
1372
1658
|
|