@agent-native/core 0.77.7 → 0.77.10
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 +46 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/task-store.ts +25 -3
- package/corpus/core/src/agent/harness/store.ts +89 -55
- package/corpus/core/src/agent/observational-memory/store.ts +40 -14
- package/corpus/core/src/agent/production-agent.ts +18 -0
- package/corpus/core/src/agent/run-store.ts +116 -59
- package/corpus/core/src/application-state/store.ts +36 -2
- package/corpus/core/src/audit/store.ts +37 -6
- package/corpus/core/src/browser-sessions/store.ts +31 -12
- package/corpus/core/src/chat-threads/store.ts +67 -3
- package/corpus/core/src/checkpoints/store.ts +14 -3
- package/corpus/core/src/cli/create.ts +9 -0
- package/corpus/core/src/collab/storage.ts +20 -2
- package/corpus/core/src/db/ddl-guard.ts +302 -0
- package/corpus/core/src/extensions/store.ts +76 -3
- package/corpus/core/src/integrations/a2a-continuations-store.ts +69 -26
- package/corpus/core/src/integrations/config-store.ts +19 -18
- package/corpus/core/src/integrations/pending-tasks-store.ts +40 -16
- package/corpus/core/src/integrations/remote-commands-store.ts +37 -23
- package/corpus/core/src/integrations/remote-devices-store.ts +64 -20
- package/corpus/core/src/integrations/remote-push-store.ts +75 -35
- package/corpus/core/src/integrations/remote-run-events-store.ts +38 -12
- package/corpus/core/src/integrations/thread-mapping-store.ts +11 -2
- package/corpus/core/src/mcp/connect-store.ts +53 -16
- package/corpus/core/src/mcp/oauth-store.ts +32 -7
- package/corpus/core/src/notifications/store.ts +20 -4
- package/corpus/core/src/oauth-tokens/store.ts +45 -2
- package/corpus/core/src/observability/store.ts +154 -47
- package/corpus/core/src/progress/store.ts +36 -21
- package/corpus/core/src/provider-api/corpus-jobs-store.ts +25 -5
- package/corpus/core/src/provider-api/custom-registry.ts +15 -4
- package/corpus/core/src/provider-api/quota-governor.ts +15 -3
- package/corpus/core/src/provider-api/staged-datasets-store.ts +22 -5
- package/corpus/core/src/resources/store.ts +66 -31
- package/corpus/core/src/secrets/storage.ts +32 -2
- package/corpus/core/src/server/agent-teams-run-queue.ts +22 -10
- package/corpus/core/src/server/auth.ts +22 -4
- package/corpus/core/src/server/better-auth-instance.ts +49 -15
- package/corpus/core/src/server/embed-session.ts +19 -4
- package/corpus/core/src/server/identity-sso-store.ts +40 -11
- package/corpus/core/src/server/recap-image-store.ts +28 -9
- package/corpus/core/src/settings/store.ts +35 -6
- package/corpus/core/src/usage/store.ts +51 -16
- package/corpus/core/src/workspace-connections/store.ts +200 -22
- package/corpus/templates/calendar/changelog/2026-06-25-booking-link-previews-now-show-the-content-directly-on-the-g.md +6 -0
- package/corpus/templates/calendar/server/lib/booking-og-image.ts +0 -2
- package/corpus/templates/forms/changelog/2026-06-25-form-previews-now-show-the-content-directly-on-the-grid-back.md +6 -0
- package/corpus/templates/forms/server/lib/form-og-image.ts +0 -2
- package/corpus/templates/plan/app/global.css +6 -11
- package/corpus/templates/plan/app/pages/PlansPage.tsx +12 -10
- package/corpus/templates/plan/changelog/2026-06-25-plan-access-request-pages-stay-visible-during-background-ref.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-25-plan-documents-keep-their-full-reading-width-below-1200px-an.md +6 -0
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +19 -3
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/agent/harness/store.d.ts.map +1 -1
- package/dist/agent/harness/store.js +63 -34
- package/dist/agent/harness/store.js.map +1 -1
- package/dist/agent/observational-memory/store.d.ts.map +1 -1
- package/dist/agent/observational-memory/store.js +30 -8
- package/dist/agent/observational-memory/store.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +18 -0
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +103 -46
- package/dist/agent/run-store.js.map +1 -1
- package/dist/application-state/store.d.ts.map +1 -1
- package/dist/application-state/store.js +28 -2
- package/dist/application-state/store.js.map +1 -1
- package/dist/audit/store.d.ts.map +1 -1
- package/dist/audit/store.js +18 -3
- package/dist/audit/store.js.map +1 -1
- package/dist/browser-sessions/store.d.ts.map +1 -1
- package/dist/browser-sessions/store.js +22 -7
- package/dist/browser-sessions/store.js.map +1 -1
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +51 -3
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/checkpoints/store.d.ts.map +1 -1
- package/dist/checkpoints/store.js +12 -3
- package/dist/checkpoints/store.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +9 -0
- package/dist/cli/create.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/collab/storage.d.ts.map +1 -1
- package/dist/collab/storage.js +14 -2
- package/dist/collab/storage.js.map +1 -1
- package/dist/db/ddl-guard.d.ts +151 -0
- package/dist/db/ddl-guard.d.ts.map +1 -0
- package/dist/db/ddl-guard.js +265 -0
- package/dist/db/ddl-guard.js.map +1 -0
- package/dist/extensions/store.d.ts.map +1 -1
- package/dist/extensions/store.js +32 -3
- package/dist/extensions/store.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
- package/dist/integrations/a2a-continuations-store.js +44 -24
- package/dist/integrations/a2a-continuations-store.js.map +1 -1
- package/dist/integrations/config-store.d.ts.map +1 -1
- package/dist/integrations/config-store.js +17 -11
- package/dist/integrations/config-store.js.map +1 -1
- package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
- package/dist/integrations/pending-tasks-store.js +24 -16
- package/dist/integrations/pending-tasks-store.js.map +1 -1
- package/dist/integrations/remote-commands-store.d.ts.map +1 -1
- package/dist/integrations/remote-commands-store.js +29 -21
- package/dist/integrations/remote-commands-store.js.map +1 -1
- package/dist/integrations/remote-devices-store.d.ts.map +1 -1
- package/dist/integrations/remote-devices-store.js +33 -18
- package/dist/integrations/remote-devices-store.js.map +1 -1
- package/dist/integrations/remote-push-store.d.ts.map +1 -1
- package/dist/integrations/remote-push-store.js +53 -31
- package/dist/integrations/remote-push-store.js.map +1 -1
- package/dist/integrations/remote-run-events-store.d.ts.map +1 -1
- package/dist/integrations/remote-run-events-store.js +26 -10
- package/dist/integrations/remote-run-events-store.js.map +1 -1
- package/dist/integrations/thread-mapping-store.d.ts.map +1 -1
- package/dist/integrations/thread-mapping-store.js +10 -2
- package/dist/integrations/thread-mapping-store.js.map +1 -1
- package/dist/mcp/connect-store.d.ts.map +1 -1
- package/dist/mcp/connect-store.js +34 -16
- package/dist/mcp/connect-store.js.map +1 -1
- package/dist/mcp/oauth-store.d.ts.map +1 -1
- package/dist/mcp/oauth-store.js +22 -7
- package/dist/mcp/oauth-store.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/notifications/store.d.ts.map +1 -1
- package/dist/notifications/store.js +15 -3
- package/dist/notifications/store.js.map +1 -1
- package/dist/oauth-tokens/store.d.ts.map +1 -1
- package/dist/oauth-tokens/store.js +33 -2
- package/dist/oauth-tokens/store.js.map +1 -1
- package/dist/observability/routes.d.ts +7 -7
- package/dist/observability/store.d.ts.map +1 -1
- package/dist/observability/store.js +72 -29
- package/dist/observability/store.js.map +1 -1
- package/dist/progress/store.d.ts.map +1 -1
- package/dist/progress/store.js +31 -16
- package/dist/progress/store.js.map +1 -1
- package/dist/provider-api/corpus-jobs-store.d.ts.map +1 -1
- package/dist/provider-api/corpus-jobs-store.js +16 -5
- package/dist/provider-api/corpus-jobs-store.js.map +1 -1
- package/dist/provider-api/custom-registry.d.ts.map +1 -1
- package/dist/provider-api/custom-registry.js +14 -4
- package/dist/provider-api/custom-registry.js.map +1 -1
- package/dist/provider-api/quota-governor.d.ts.map +1 -1
- package/dist/provider-api/quota-governor.js +12 -3
- package/dist/provider-api/quota-governor.js.map +1 -1
- package/dist/provider-api/staged-datasets-store.d.ts.map +1 -1
- package/dist/provider-api/staged-datasets-store.js +16 -5
- package/dist/provider-api/staged-datasets-store.js.map +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/resources/store.d.ts.map +1 -1
- package/dist/resources/store.js +56 -25
- package/dist/resources/store.js.map +1 -1
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +22 -2
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-teams-run-queue.d.ts.map +1 -1
- package/dist/server/agent-teams-run-queue.js +15 -4
- package/dist/server/agent-teams-run-queue.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +16 -2
- package/dist/server/auth.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +55 -21
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/embed-session.d.ts.map +1 -1
- package/dist/server/embed-session.js +15 -4
- package/dist/server/embed-session.js.map +1 -1
- package/dist/server/identity-sso-store.d.ts.map +1 -1
- package/dist/server/identity-sso-store.js +30 -11
- package/dist/server/identity-sso-store.js.map +1 -1
- package/dist/server/recap-image-store.d.ts.map +1 -1
- package/dist/server/recap-image-store.js +21 -7
- package/dist/server/recap-image-store.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/settings/store.d.ts.map +1 -1
- package/dist/settings/store.js +30 -6
- package/dist/settings/store.js.map +1 -1
- package/dist/usage/store.d.ts.map +1 -1
- package/dist/usage/store.js +36 -11
- package/dist/usage/store.js.map +1 -1
- package/dist/workspace-connections/store.d.ts.map +1 -1
- package/dist/workspace-connections/store.js +57 -7
- package/dist/workspace-connections/store.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isPostgres,
|
|
5
5
|
intType,
|
|
6
6
|
} from "../db/client.js";
|
|
7
|
+
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
|
|
7
8
|
import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
|
|
8
9
|
import type { StoreWriteOptions } from "../settings/store.js";
|
|
9
10
|
import { emitAppStateChange, emitAppStateDelete } from "./emitter.js";
|
|
@@ -22,7 +23,7 @@ async function ensureTable(): Promise<void> {
|
|
|
22
23
|
if (!_initPromise) {
|
|
23
24
|
_initPromise = (async () => {
|
|
24
25
|
const client = getDbExec();
|
|
25
|
-
|
|
26
|
+
const createSql = `
|
|
26
27
|
CREATE TABLE IF NOT EXISTS application_state (
|
|
27
28
|
session_id TEXT NOT NULL,
|
|
28
29
|
key TEXT NOT NULL,
|
|
@@ -30,7 +31,40 @@ async function ensureTable(): Promise<void> {
|
|
|
30
31
|
updated_at ${intType()} NOT NULL,
|
|
31
32
|
PRIMARY KEY (session_id, key)
|
|
32
33
|
)
|
|
33
|
-
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
if (isPostgres()) {
|
|
37
|
+
// Hot path: the `application_state` table and its poll indexes are
|
|
38
|
+
// virtually always already present in production. Issuing `CREATE
|
|
39
|
+
// TABLE`/ `CREATE INDEX` still takes a lock that, in a fresh
|
|
40
|
+
// background-worker process behind a concurrent connection on the
|
|
41
|
+
// shared Neon DB, can block ~indefinitely (ACCESS EXCLUSIVE for CREATE
|
|
42
|
+
// TABLE; a write-blocking SHARE lock for CREATE INDEX). The ensure*
|
|
43
|
+
// wrappers probe `information_schema`/`pg_indexes` first (plain reads,
|
|
44
|
+
// no lock) and run DDL ONLY for what is actually missing, bounded by a
|
|
45
|
+
// transaction-scoped `lock_timeout`. If a swallowed lock-timeout leaves
|
|
46
|
+
// the schema still missing they RE-PROBE and THROW rather than letting
|
|
47
|
+
// init memoize success against absent schema.
|
|
48
|
+
await ensureTableExists("application_state", createSql);
|
|
49
|
+
// Older deployments created `updated_at` as 32-bit `INTEGER`; on Postgres
|
|
50
|
+
// the `Date.now()` written by appStatePut() on every turn overflows int4.
|
|
51
|
+
// Widen it in place (no-op once done / on fresh BIGINT databases).
|
|
52
|
+
await widenIntColumnsToBigInt("application_state", ["updated_at"]);
|
|
53
|
+
// Indexes for the two hot poll paths. Probe pg_indexes first (no lock)
|
|
54
|
+
// and skip the SHARE-locking CREATE INDEX when already present.
|
|
55
|
+
await ensureIndexExists(
|
|
56
|
+
"app_state_updated_at_idx",
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS app_state_updated_at_idx ON application_state (updated_at)`,
|
|
58
|
+
);
|
|
59
|
+
await ensureIndexExists(
|
|
60
|
+
"app_state_key_updated_idx",
|
|
61
|
+
`CREATE INDEX IF NOT EXISTS app_state_key_updated_idx ON application_state (key, updated_at)`,
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// SQLite (local dev): no lock problem — keep the original behaviour.
|
|
67
|
+
await client.execute(createSql);
|
|
34
68
|
// Older deployments created `updated_at` as 32-bit `INTEGER`; on Postgres
|
|
35
69
|
// the `Date.now()` written by appStatePut() on every turn overflows int4.
|
|
36
70
|
// Widen it in place (no-op once done / on fresh BIGINT databases).
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* `agent_audit_log`; reads are scoped to the caller's identity in SQL (no
|
|
8
8
|
* shares table — audit rows are never individually shared).
|
|
9
9
|
*/
|
|
10
|
-
import { getDbExec, intType,
|
|
10
|
+
import { getDbExec, intType, isPostgres } from "../db/client.js";
|
|
11
|
+
import { ensureTableExists, ensureIndexExists } from "../db/ddl-guard.js";
|
|
11
12
|
import type {
|
|
12
13
|
AuditEvent,
|
|
13
14
|
AuditQueryFilters,
|
|
@@ -20,9 +21,7 @@ export async function ensureAuditTables(): Promise<void> {
|
|
|
20
21
|
if (!_initPromise) {
|
|
21
22
|
_initPromise = (async () => {
|
|
22
23
|
const client = getDbExec();
|
|
23
|
-
|
|
24
|
-
await retryOnDdlRace(() =>
|
|
25
|
-
client.execute(`
|
|
24
|
+
const createSql = `
|
|
26
25
|
CREATE TABLE IF NOT EXISTS agent_audit_log (
|
|
27
26
|
id TEXT PRIMARY KEY,
|
|
28
27
|
created_at ${intType()} NOT NULL,
|
|
@@ -42,9 +41,41 @@ export async function ensureAuditTables(): Promise<void> {
|
|
|
42
41
|
owner_email TEXT,
|
|
43
42
|
visibility TEXT NOT NULL DEFAULT 'private'
|
|
44
43
|
)
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
if (isPostgres()) {
|
|
47
|
+
// PG-guard: probe information_schema / pg_indexes before issuing DDL to
|
|
48
|
+
// avoid ACCESS EXCLUSIVE lock contention in fresh background-worker processes.
|
|
49
|
+
await ensureTableExists("agent_audit_log", createSql);
|
|
50
|
+
await ensureIndexExists(
|
|
51
|
+
"idx_audit_owner",
|
|
52
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON agent_audit_log (owner_email, created_at)`,
|
|
53
|
+
);
|
|
54
|
+
await ensureIndexExists(
|
|
55
|
+
"idx_audit_org",
|
|
56
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_org ON agent_audit_log (org_id, created_at)`,
|
|
57
|
+
);
|
|
58
|
+
await ensureIndexExists(
|
|
59
|
+
"idx_audit_target",
|
|
60
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_target ON agent_audit_log (target_type, target_id, created_at)`,
|
|
61
|
+
);
|
|
62
|
+
await ensureIndexExists(
|
|
63
|
+
"idx_audit_turn",
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_turn ON agent_audit_log (turn_id)`,
|
|
65
|
+
);
|
|
66
|
+
await ensureIndexExists(
|
|
67
|
+
"idx_audit_actor",
|
|
68
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_actor ON agent_audit_log (actor_email, created_at)`,
|
|
69
|
+
);
|
|
70
|
+
await ensureIndexExists(
|
|
71
|
+
"idx_audit_created",
|
|
72
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_created ON agent_audit_log (created_at)`,
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
47
76
|
|
|
77
|
+
// SQLite (local dev): no lock problem — keep the original behaviour.
|
|
78
|
+
await client.execute(createSql);
|
|
48
79
|
const indexes = [
|
|
49
80
|
`CREATE INDEX IF NOT EXISTS idx_audit_owner ON agent_audit_log (owner_email, created_at)`,
|
|
50
81
|
`CREATE INDEX IF NOT EXISTS idx_audit_org ON agent_audit_log (org_id, created_at)`,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
retryOnDdlRace,
|
|
6
6
|
safeJsonParse,
|
|
7
7
|
} from "../db/client.js";
|
|
8
|
+
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
|
|
8
9
|
import type {
|
|
9
10
|
AgentNativeBrowserSession,
|
|
10
11
|
AgentNativeBrowserSessionAction,
|
|
@@ -38,8 +39,7 @@ async function ensureTables(): Promise<void> {
|
|
|
38
39
|
if (!initPromise) {
|
|
39
40
|
initPromise = (async () => {
|
|
40
41
|
const client = getDbExec();
|
|
41
|
-
|
|
42
|
-
client.execute(`
|
|
42
|
+
const createSessionsSql = `
|
|
43
43
|
CREATE TABLE IF NOT EXISTS ${SESSION_TABLE} (
|
|
44
44
|
owner_email TEXT NOT NULL,
|
|
45
45
|
session_id TEXT NOT NULL,
|
|
@@ -53,16 +53,8 @@ async function ensureTables(): Promise<void> {
|
|
|
53
53
|
expires_at ${intType()} NOT NULL,
|
|
54
54
|
PRIMARY KEY (owner_email, session_id)
|
|
55
55
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await retryOnDdlRace(() =>
|
|
59
|
-
client.execute(`
|
|
60
|
-
CREATE INDEX IF NOT EXISTS agent_native_browser_sessions_owner_seen_idx
|
|
61
|
-
ON ${SESSION_TABLE} (owner_email, last_seen_at)
|
|
62
|
-
`),
|
|
63
|
-
);
|
|
64
|
-
await retryOnDdlRace(() =>
|
|
65
|
-
client.execute(`
|
|
56
|
+
`;
|
|
57
|
+
const createRequestsSql = `
|
|
66
58
|
CREATE TABLE IF NOT EXISTS ${REQUEST_TABLE} (
|
|
67
59
|
owner_email TEXT NOT NULL,
|
|
68
60
|
session_id TEXT NOT NULL,
|
|
@@ -80,8 +72,35 @@ async function ensureTables(): Promise<void> {
|
|
|
80
72
|
error TEXT,
|
|
81
73
|
PRIMARY KEY (owner_email, request_id)
|
|
82
74
|
)
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
if (isPostgres()) {
|
|
78
|
+
// PG-guard: probe information_schema / pg_indexes first (no lock) and
|
|
79
|
+
// only issue DDL when the table/index is actually missing, wrapped in
|
|
80
|
+
// a transaction-scoped lock_timeout so a contended lock fails fast.
|
|
81
|
+
await ensureTableExists(SESSION_TABLE, createSessionsSql);
|
|
82
|
+
await ensureIndexExists(
|
|
83
|
+
"agent_native_browser_sessions_owner_seen_idx",
|
|
84
|
+
`CREATE INDEX IF NOT EXISTS agent_native_browser_sessions_owner_seen_idx ON ${SESSION_TABLE} (owner_email, last_seen_at)`,
|
|
85
|
+
);
|
|
86
|
+
await ensureTableExists(REQUEST_TABLE, createRequestsSql);
|
|
87
|
+
await ensureIndexExists(
|
|
88
|
+
"agent_native_browser_session_requests_pending_idx",
|
|
89
|
+
`CREATE INDEX IF NOT EXISTS agent_native_browser_session_requests_pending_idx ON ${REQUEST_TABLE} (owner_email, session_id, status, created_at)`,
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// SQLite (local dev): no ACCESS EXCLUSIVE lock problem — keep existing
|
|
95
|
+
// retryOnDdlRace behaviour.
|
|
96
|
+
await retryOnDdlRace(() => client.execute(createSessionsSql));
|
|
97
|
+
await retryOnDdlRace(() =>
|
|
98
|
+
client.execute(`
|
|
99
|
+
CREATE INDEX IF NOT EXISTS agent_native_browser_sessions_owner_seen_idx
|
|
100
|
+
ON ${SESSION_TABLE} (owner_email, last_seen_at)
|
|
83
101
|
`),
|
|
84
102
|
);
|
|
103
|
+
await retryOnDdlRace(() => client.execute(createRequestsSql));
|
|
85
104
|
await retryOnDdlRace(() =>
|
|
86
105
|
client.execute(`
|
|
87
106
|
CREATE INDEX IF NOT EXISTS agent_native_browser_session_requests_pending_idx
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
normalizeThreadRepository,
|
|
6
6
|
normalizeThreadTitle,
|
|
7
7
|
} from "../agent/thread-data-builder.js";
|
|
8
|
-
import { getDbExec, intType } from "../db/client.js";
|
|
8
|
+
import { getDbExec, intType, isPostgres } from "../db/client.js";
|
|
9
|
+
import {
|
|
10
|
+
ensureColumnExists,
|
|
11
|
+
ensureIndexExists,
|
|
12
|
+
ensureTableExists,
|
|
13
|
+
} from "../db/ddl-guard.js";
|
|
9
14
|
import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
|
|
10
15
|
import { emitChatThreadChange } from "./emitter.js";
|
|
11
16
|
|
|
@@ -53,7 +58,7 @@ async function ensureTable(): Promise<void> {
|
|
|
53
58
|
if (!_initPromise) {
|
|
54
59
|
_initPromise = (async () => {
|
|
55
60
|
const client = getDbExec();
|
|
56
|
-
|
|
61
|
+
const createSql = `
|
|
57
62
|
CREATE TABLE IF NOT EXISTS chat_threads (
|
|
58
63
|
id TEXT PRIMARY KEY,
|
|
59
64
|
owner_email TEXT NOT NULL,
|
|
@@ -69,7 +74,66 @@ async function ensureTable(): Promise<void> {
|
|
|
69
74
|
pinned_at ${intType()},
|
|
70
75
|
archived_at ${intType()}
|
|
71
76
|
)
|
|
72
|
-
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
if (isPostgres()) {
|
|
80
|
+
// Hot path: the `chat_threads` table and its indexes are virtually
|
|
81
|
+
// always already present in production. Issuing `CREATE TABLE`/
|
|
82
|
+
// `CREATE INDEX` still takes a lock that, in a fresh background-worker
|
|
83
|
+
// process behind a concurrent connection on the shared Neon DB, can
|
|
84
|
+
// block ~indefinitely (ACCESS EXCLUSIVE for CREATE TABLE; a write-
|
|
85
|
+
// blocking SHARE lock for CREATE INDEX). The ensure* wrappers probe
|
|
86
|
+
// `information_schema`/`pg_indexes` first (plain reads, no lock) and
|
|
87
|
+
// run DDL ONLY for what is actually missing, bounded by a transaction-
|
|
88
|
+
// scoped `lock_timeout`. If a swallowed lock-timeout leaves the schema
|
|
89
|
+
// still missing they RE-PROBE and THROW rather than letting init
|
|
90
|
+
// memoize success against absent schema. `chat_threads` is the
|
|
91
|
+
// unqualified name even though the table lives in `public`.
|
|
92
|
+
await ensureTableExists("chat_threads", createSql);
|
|
93
|
+
// Additive columns — guarded so the hot path (columns already present)
|
|
94
|
+
// skips the ACCESS EXCLUSIVE ALTER entirely.
|
|
95
|
+
for (const [col, type] of [
|
|
96
|
+
["scope_type", "TEXT"],
|
|
97
|
+
["scope_id", "TEXT"],
|
|
98
|
+
["scope_label", "TEXT"],
|
|
99
|
+
["pinned_at", intType()],
|
|
100
|
+
["archived_at", intType()],
|
|
101
|
+
] as const) {
|
|
102
|
+
await ensureColumnExists(
|
|
103
|
+
"chat_threads",
|
|
104
|
+
col,
|
|
105
|
+
`ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS ${col} ${type}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// Widen millisecond-timestamp columns that older deployments created as
|
|
109
|
+
// 32-bit `INTEGER`; on Postgres the `Date.now()` written on every turn
|
|
110
|
+
// overflows int4. No-op once widened / on fresh BIGINT databases.
|
|
111
|
+
await widenIntColumnsToBigInt("chat_threads", [
|
|
112
|
+
"created_at",
|
|
113
|
+
"updated_at",
|
|
114
|
+
"pinned_at",
|
|
115
|
+
"archived_at",
|
|
116
|
+
]);
|
|
117
|
+
// Indexes for the hot read paths. Both the sidebar list and the
|
|
118
|
+
// scoped/per-resource list filter on owner_email (and optionally
|
|
119
|
+
// scope) and sort by updated_at. Probe pg_indexes first (no lock)
|
|
120
|
+
// and skip the SHARE-locking CREATE INDEX when already present.
|
|
121
|
+
await ensureIndexExists(
|
|
122
|
+
"chat_threads_owner_updated_idx",
|
|
123
|
+
`CREATE INDEX IF NOT EXISTS chat_threads_owner_updated_idx ON chat_threads (owner_email, updated_at)`,
|
|
124
|
+
);
|
|
125
|
+
await ensureIndexExists(
|
|
126
|
+
"chat_threads_scope_updated_idx",
|
|
127
|
+
`CREATE INDEX IF NOT EXISTS chat_threads_scope_updated_idx ON chat_threads (scope_type, scope_id, updated_at)`,
|
|
128
|
+
);
|
|
129
|
+
// One-time backfill of message_count for legacy rows written before
|
|
130
|
+
// the column was maintained.
|
|
131
|
+
await backfillLegacyMessageCounts(client);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// SQLite (local dev): no lock problem — keep the original behaviour.
|
|
136
|
+
await client.execute(createSql);
|
|
73
137
|
// Additive migration for existing tables. Both SQLite and Postgres
|
|
74
138
|
// accept `ALTER TABLE ADD COLUMN` and will raise when the column
|
|
75
139
|
// already exists; the try/catch makes the call idempotent across
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getDbExec, intType } from "../db/client.js";
|
|
1
|
+
import { getDbExec, intType, isPostgres } from "../db/client.js";
|
|
2
|
+
import { ensureTableExists } from "../db/ddl-guard.js";
|
|
2
3
|
|
|
3
4
|
let _initPromise: Promise<void> | undefined;
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ async function ensureCheckpointTable(): Promise<void> {
|
|
|
6
7
|
if (!_initPromise) {
|
|
7
8
|
_initPromise = (async () => {
|
|
8
9
|
const client = getDbExec();
|
|
9
|
-
|
|
10
|
+
const createSql = `
|
|
10
11
|
CREATE TABLE IF NOT EXISTS agent_checkpoints (
|
|
11
12
|
id TEXT PRIMARY KEY,
|
|
12
13
|
thread_id TEXT NOT NULL,
|
|
@@ -15,7 +16,17 @@ async function ensureCheckpointTable(): Promise<void> {
|
|
|
15
16
|
message TEXT NOT NULL DEFAULT '',
|
|
16
17
|
created_at ${intType()} NOT NULL
|
|
17
18
|
)
|
|
18
|
-
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
if (isPostgres()) {
|
|
22
|
+
// PG-guard: probe information_schema before issuing DDL to avoid ACCESS
|
|
23
|
+
// EXCLUSIVE lock contention in fresh background-worker processes.
|
|
24
|
+
await ensureTableExists("agent_checkpoints", createSql);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// SQLite (local dev): no lock problem — keep the original behaviour.
|
|
29
|
+
await client.execute(createSql);
|
|
19
30
|
})().catch((err) => {
|
|
20
31
|
// Retry init on the next call after a failed startup.
|
|
21
32
|
_initPromise = undefined;
|
|
@@ -29,7 +29,16 @@ const STANDALONE_EXACT_DEPENDENCY_OVERRIDES: Record<string, string> = {
|
|
|
29
29
|
};
|
|
30
30
|
const SENTRY_MINIMUM_RELEASE_AGE_EXCLUDES = [
|
|
31
31
|
'"@sentry/browser"',
|
|
32
|
+
'"@sentry/browser-utils"',
|
|
33
|
+
'"@sentry/conventions"',
|
|
34
|
+
'"@sentry/core"',
|
|
35
|
+
'"@sentry/feedback"',
|
|
32
36
|
'"@sentry/node"',
|
|
37
|
+
'"@sentry/node-core"',
|
|
38
|
+
'"@sentry/opentelemetry"',
|
|
39
|
+
'"@sentry/replay"',
|
|
40
|
+
'"@sentry/replay-canvas"',
|
|
41
|
+
'"@sentry/server-utils"',
|
|
33
42
|
];
|
|
34
43
|
const FIRST_PARTY_TARBALL_SYMLINK_EXCLUDES = [
|
|
35
44
|
"*/CLAUDE.md",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getDbExec, isPostgres } from "../db/client.js";
|
|
9
|
+
import { ensureTableExists, ensureColumnExists } from "../db/ddl-guard.js";
|
|
9
10
|
|
|
10
11
|
let _initPromise: Promise<void> | undefined;
|
|
11
12
|
|
|
@@ -14,7 +15,7 @@ async function ensureTable(): Promise<void> {
|
|
|
14
15
|
_initPromise = (async () => {
|
|
15
16
|
const client = getDbExec();
|
|
16
17
|
const nowDefault = isPostgres() ? "NOW()::text" : "datetime('now')";
|
|
17
|
-
|
|
18
|
+
const createSql = `
|
|
18
19
|
CREATE TABLE IF NOT EXISTS _collab_docs (
|
|
19
20
|
doc_id TEXT PRIMARY KEY,
|
|
20
21
|
yjs_state TEXT NOT NULL,
|
|
@@ -22,7 +23,24 @@ async function ensureTable(): Promise<void> {
|
|
|
22
23
|
version INTEGER NOT NULL DEFAULT 0,
|
|
23
24
|
updated_at TEXT NOT NULL DEFAULT (${nowDefault})
|
|
24
25
|
)
|
|
25
|
-
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
if (isPostgres()) {
|
|
29
|
+
// PG-guard: probe information_schema first (no lock) and only issue
|
|
30
|
+
// DDL when the table/column is actually missing, wrapped in a
|
|
31
|
+
// transaction-scoped lock_timeout so a contended lock fails fast.
|
|
32
|
+
await ensureTableExists("_collab_docs", createSql);
|
|
33
|
+
await ensureColumnExists(
|
|
34
|
+
"_collab_docs",
|
|
35
|
+
"version",
|
|
36
|
+
`ALTER TABLE _collab_docs ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0`,
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// SQLite (local dev): no ACCESS EXCLUSIVE lock problem — keep existing
|
|
42
|
+
// create-then-additive-alter behaviour.
|
|
43
|
+
await client.execute(createSql);
|
|
26
44
|
try {
|
|
27
45
|
await client.execute(
|
|
28
46
|
`ALTER TABLE _collab_docs ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards for on-demand `ensureTable()` DDL so the common already-migrated path
|
|
3
|
+
* takes NO `ACCESS EXCLUSIVE` lock on Postgres.
|
|
4
|
+
*
|
|
5
|
+
* Lives in its own module (like `./widen-columns.js`) so stores can import it
|
|
6
|
+
* without every `vi.mock("../db/client.js")` test needing to stub it: the
|
|
7
|
+
* helpers resolve `isPostgres()` / `getDbExec()` through `client.js`, so a test
|
|
8
|
+
* that mocks the client to SQLite (`isPostgres: () => false`) makes the
|
|
9
|
+
* Postgres-only existence checks no-ops automatically.
|
|
10
|
+
*
|
|
11
|
+
* ## Why
|
|
12
|
+
*
|
|
13
|
+
* `ensureTable()` runs once per process on first DB touch. In a long-lived Node
|
|
14
|
+
* server the cost is paid once and is invisible. In a Netlify `-background`
|
|
15
|
+
* function the process is fresh, so this is the FIRST touch — and the
|
|
16
|
+
* `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN` DDL it issues takes an
|
|
17
|
+
* `ACCESS EXCLUSIVE` lock on the shared Neon Postgres database. Behind a
|
|
18
|
+
* concurrent connection that already holds a conflicting lock, that DDL can
|
|
19
|
+
* block ~indefinitely (observed >16s hangs in the bg worker vs ~1s inline).
|
|
20
|
+
*
|
|
21
|
+
* The tables/columns are essentially always already present in production, so
|
|
22
|
+
* the DDL is redundant in the hot path. These helpers let a store cheaply check
|
|
23
|
+
* `information_schema` first and only issue DDL when something is actually
|
|
24
|
+
* missing — and when DDL must run, wrap it in a short `lock_timeout` so a
|
|
25
|
+
* contended lock fails fast instead of hanging.
|
|
26
|
+
*
|
|
27
|
+
* All of this is Postgres-only behaviour gated on `isPostgres()`. On SQLite
|
|
28
|
+
* (local dev) there is no such lock problem, so callers keep their existing
|
|
29
|
+
* behaviour there.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { isPostgres, getDbExec, type DbExec } from "./client.js";
|
|
33
|
+
|
|
34
|
+
const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when running against Postgres AND the given table already exists in the
|
|
38
|
+
* `public` schema. Returns `false` on SQLite (callers gate their own behaviour
|
|
39
|
+
* there), for invalid identifiers, or when `information_schema` is unreadable —
|
|
40
|
+
* the conservative answer "not known to exist" makes the caller fall through to
|
|
41
|
+
* its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour.
|
|
42
|
+
*
|
|
43
|
+
* This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE`
|
|
44
|
+
* lock the way `CREATE`/`ALTER` would.
|
|
45
|
+
*/
|
|
46
|
+
export async function pgTableExists(
|
|
47
|
+
table: string,
|
|
48
|
+
injectedClient?: DbExec,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
if (!isPostgres() || !PLAIN_IDENTIFIER.test(table)) return false;
|
|
51
|
+
const client = injectedClient ?? getDbExec();
|
|
52
|
+
try {
|
|
53
|
+
const { rows } = await client.execute({
|
|
54
|
+
sql: `SELECT 1 FROM information_schema.tables
|
|
55
|
+
WHERE table_schema = 'public' AND table_name = ? LIMIT 1`,
|
|
56
|
+
args: [table],
|
|
57
|
+
});
|
|
58
|
+
return rows.length > 0;
|
|
59
|
+
} catch {
|
|
60
|
+
// information_schema unreadable (permissions / non-standard backend) —
|
|
61
|
+
// report "unknown" so the caller falls back to IF NOT EXISTS.
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when running against Postgres AND the given column already exists on the
|
|
68
|
+
* given table in the `public` schema. Returns `false` on SQLite, for invalid
|
|
69
|
+
* identifiers, or when `information_schema` is unreadable.
|
|
70
|
+
*
|
|
71
|
+
* Plain read — no lock taken.
|
|
72
|
+
*/
|
|
73
|
+
export async function pgColumnExists(
|
|
74
|
+
table: string,
|
|
75
|
+
column: string,
|
|
76
|
+
injectedClient?: DbExec,
|
|
77
|
+
): Promise<boolean> {
|
|
78
|
+
if (!isPostgres()) return false;
|
|
79
|
+
if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const client = injectedClient ?? getDbExec();
|
|
83
|
+
try {
|
|
84
|
+
const { rows } = await client.execute({
|
|
85
|
+
sql: `SELECT 1 FROM information_schema.columns
|
|
86
|
+
WHERE table_schema = 'public' AND table_name = ? AND column_name = ?
|
|
87
|
+
LIMIT 1`,
|
|
88
|
+
args: [table, column],
|
|
89
|
+
});
|
|
90
|
+
return rows.length > 0;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when running against Postgres AND an index with the given name already
|
|
98
|
+
* exists in the `public` schema. Returns `false` on SQLite, for invalid
|
|
99
|
+
* identifiers, or when `pg_indexes` is unreadable.
|
|
100
|
+
*
|
|
101
|
+
* `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks
|
|
102
|
+
* writes, so on a fresh background-worker process behind a concurrent
|
|
103
|
+
* connection this can hang just like a `CREATE`/`ALTER` would; checking first
|
|
104
|
+
* skips the lock on the already-migrated hot path.
|
|
105
|
+
*/
|
|
106
|
+
export async function pgIndexExists(
|
|
107
|
+
indexName: string,
|
|
108
|
+
injectedClient?: DbExec,
|
|
109
|
+
): Promise<boolean> {
|
|
110
|
+
if (!isPostgres() || !PLAIN_IDENTIFIER.test(indexName)) return false;
|
|
111
|
+
const client = injectedClient ?? getDbExec();
|
|
112
|
+
try {
|
|
113
|
+
const { rows } = await client.execute({
|
|
114
|
+
sql: `SELECT 1 FROM pg_indexes
|
|
115
|
+
WHERE schemaname = 'public' AND indexname = ? LIMIT 1`,
|
|
116
|
+
args: [indexName],
|
|
117
|
+
});
|
|
118
|
+
return rows.length > 0;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Probe → guarded-DDL → re-probe sequence for one piece of on-demand schema.
|
|
126
|
+
*
|
|
127
|
+
* This is the single safe primitive every `ensureTable()` should use so a
|
|
128
|
+
* swallowed `lock_timeout` can NEVER poison a store's init memo with missing
|
|
129
|
+
* schema. The flow:
|
|
130
|
+
*
|
|
131
|
+
* 1. `probe()` — cheap, lock-free existence check (pgTableExists / etc.).
|
|
132
|
+
* Already present → return `false` (nothing to do; the hot path takes NO
|
|
133
|
+
* lock).
|
|
134
|
+
* 2. Missing → run `ddl` through `runGuardedDdl` (bounded `lock_timeout`).
|
|
135
|
+
* - DDL completed → return `true`.
|
|
136
|
+
* - DDL was swallowed by a lock-timeout (`runGuardedDdl` returned `false`)
|
|
137
|
+
* → RE-PROBE. A concurrent connection virtually always created the
|
|
138
|
+
* object meanwhile, so the re-probe usually now reports it present →
|
|
139
|
+
* return `true`. But if it is STILL missing, the required schema does
|
|
140
|
+
* NOT exist and we must NOT let the caller memoize success — so THROW.
|
|
141
|
+
* The caller's `_initPromise` rejects and the next call retries instead
|
|
142
|
+
* of running forever against absent schema.
|
|
143
|
+
*
|
|
144
|
+
* On SQLite the probe is always `false` (helpers no-op there) and the SQLite
|
|
145
|
+
* branch of each store keeps its own create-then-catch behaviour, so this is a
|
|
146
|
+
* Postgres-path primitive in practice. Returns `true` when the object exists
|
|
147
|
+
* after this call (either pre-existing-after-timeout-race or freshly created),
|
|
148
|
+
* `false` only when it already existed up front (no DDL issued).
|
|
149
|
+
*/
|
|
150
|
+
export async function ensureSchemaObject(options: {
|
|
151
|
+
/** Lock-free existence check; `true` ⇒ already present, skip DDL. */
|
|
152
|
+
probe: () => Promise<boolean>;
|
|
153
|
+
/** DDL to run only when `probe()` reports the object missing. */
|
|
154
|
+
ddl: string;
|
|
155
|
+
/** Human-readable name of what's being ensured, for the error message. */
|
|
156
|
+
label: string;
|
|
157
|
+
/** Forwarded to `runGuardedDdl`. */
|
|
158
|
+
lockTimeout?: string;
|
|
159
|
+
/** Injectable client for tests. */
|
|
160
|
+
injectedClient?: DbExec;
|
|
161
|
+
}): Promise<boolean> {
|
|
162
|
+
const { probe, ddl, label, lockTimeout, injectedClient } = options;
|
|
163
|
+
if (await probe()) return false;
|
|
164
|
+
const ran = await runGuardedDdl(ddl, { lockTimeout, injectedClient });
|
|
165
|
+
if (ran) return true;
|
|
166
|
+
// The DDL was swallowed by a lock-timeout. The object is virtually always
|
|
167
|
+
// already correct by the time a contended boot retries (a concurrent
|
|
168
|
+
// connection created it), so re-probe before giving up.
|
|
169
|
+
if (await probe()) return true;
|
|
170
|
+
// Still missing after a swallowed timeout: do NOT memoize success with absent
|
|
171
|
+
// schema. Throw so the caller's init promise rejects and the next call
|
|
172
|
+
// retries, rather than leaving ensureTable "initialized" against a table/
|
|
173
|
+
// column/index that does not exist.
|
|
174
|
+
throw new Error(
|
|
175
|
+
`ensureSchemaObject: required schema "${label}" is still missing after a ` +
|
|
176
|
+
`lock-timed-out DDL; refusing to memoize init success. The next call will retry.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Convenience wrapper: ensure a TABLE exists (probe via `pgTableExists`).
|
|
182
|
+
* No-op-returns `false` on SQLite (probe is always false there) — callers run
|
|
183
|
+
* the SQLite create on their own branch, so this is used on the Postgres path.
|
|
184
|
+
*/
|
|
185
|
+
export async function ensureTableExists(
|
|
186
|
+
table: string,
|
|
187
|
+
createSql: string,
|
|
188
|
+
options: { lockTimeout?: string; injectedClient?: DbExec } = {},
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
return ensureSchemaObject({
|
|
191
|
+
probe: () => pgTableExists(table, options.injectedClient),
|
|
192
|
+
ddl: createSql,
|
|
193
|
+
label: `table ${table}`,
|
|
194
|
+
lockTimeout: options.lockTimeout,
|
|
195
|
+
injectedClient: options.injectedClient,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Convenience wrapper: ensure a COLUMN exists (probe via `pgColumnExists`).
|
|
201
|
+
* `addColumnSql` should be the full `ALTER TABLE … ADD COLUMN IF NOT EXISTS …`.
|
|
202
|
+
*/
|
|
203
|
+
export async function ensureColumnExists(
|
|
204
|
+
table: string,
|
|
205
|
+
column: string,
|
|
206
|
+
addColumnSql: string,
|
|
207
|
+
options: { lockTimeout?: string; injectedClient?: DbExec } = {},
|
|
208
|
+
): Promise<boolean> {
|
|
209
|
+
return ensureSchemaObject({
|
|
210
|
+
probe: () => pgColumnExists(table, column, options.injectedClient),
|
|
211
|
+
ddl: addColumnSql,
|
|
212
|
+
label: `column ${table}.${column}`,
|
|
213
|
+
lockTimeout: options.lockTimeout,
|
|
214
|
+
injectedClient: options.injectedClient,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convenience wrapper: ensure an INDEX exists (probe via `pgIndexExists`).
|
|
220
|
+
* `createIndexSql` should be the full `CREATE INDEX IF NOT EXISTS <name> …`.
|
|
221
|
+
*/
|
|
222
|
+
export async function ensureIndexExists(
|
|
223
|
+
indexName: string,
|
|
224
|
+
createIndexSql: string,
|
|
225
|
+
options: { lockTimeout?: string; injectedClient?: DbExec } = {},
|
|
226
|
+
): Promise<boolean> {
|
|
227
|
+
return ensureSchemaObject({
|
|
228
|
+
probe: () => pgIndexExists(indexName, options.injectedClient),
|
|
229
|
+
ddl: createIndexSql,
|
|
230
|
+
label: `index ${indexName}`,
|
|
231
|
+
lockTimeout: options.lockTimeout,
|
|
232
|
+
injectedClient: options.injectedClient,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */
|
|
237
|
+
export function isLockTimeoutError(err: unknown): boolean {
|
|
238
|
+
const anyErr = err as { code?: unknown; message?: unknown } | null;
|
|
239
|
+
if (anyErr?.code === "55P03") return true;
|
|
240
|
+
const msg = String(anyErr?.message ?? anyErr ?? "");
|
|
241
|
+
return /lock[_ ]?timeout|canceling statement due to lock timeout/i.test(msg);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Run a DDL statement that MUST execute (the schema is actually missing), with
|
|
246
|
+
* a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast
|
|
247
|
+
* instead of hanging the whole process.
|
|
248
|
+
*
|
|
249
|
+
* Postgres path: wrap the DDL in an explicit transaction and set
|
|
250
|
+
* `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only
|
|
251
|
+
* and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled
|
|
252
|
+
* (session-reused) connection the way a bare `SET lock_timeout` would. If the
|
|
253
|
+
* DbExec has no `transaction` (shouldn't happen for Postgres, but defensively),
|
|
254
|
+
* fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is
|
|
255
|
+
* swallowed: the table/column is virtually always already correct by the time a
|
|
256
|
+
* contended boot retries, and the caller's memoization should still resolve so
|
|
257
|
+
* the path isn't retried in a tight loop. Any non-lock-timeout error rethrows.
|
|
258
|
+
*
|
|
259
|
+
* SQLite path: no lock problem — just run the DDL directly.
|
|
260
|
+
*
|
|
261
|
+
* @returns `true` if the DDL ran to completion, `false` if it was skipped due to
|
|
262
|
+
* a lock-timeout (so the caller can decide whether to log).
|
|
263
|
+
*/
|
|
264
|
+
export async function runGuardedDdl(
|
|
265
|
+
ddl: string,
|
|
266
|
+
options: { lockTimeout?: string; injectedClient?: DbExec } = {},
|
|
267
|
+
): Promise<boolean> {
|
|
268
|
+
const client = options.injectedClient ?? getDbExec();
|
|
269
|
+
if (!isPostgres()) {
|
|
270
|
+
await client.execute(ddl);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lockTimeout = options.lockTimeout ?? "3s";
|
|
275
|
+
try {
|
|
276
|
+
if (typeof client.transaction === "function") {
|
|
277
|
+
await client.transaction(async (tx) => {
|
|
278
|
+
// SET LOCAL is transaction-scoped: it reverts on COMMIT/ROLLBACK and
|
|
279
|
+
// never persists on the pooled connection.
|
|
280
|
+
await tx.execute(`SET LOCAL lock_timeout = '${lockTimeout}'`);
|
|
281
|
+
await tx.execute(ddl);
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
// Defensive fallback: no transaction support. Use a session SET and
|
|
285
|
+
// guarantee a RESET so the timeout never leaks onto a reused connection.
|
|
286
|
+
try {
|
|
287
|
+
await client.execute(`SET lock_timeout = '${lockTimeout}'`);
|
|
288
|
+
await client.execute(ddl);
|
|
289
|
+
} finally {
|
|
290
|
+
await client.execute(`RESET lock_timeout`).catch(() => {});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (isLockTimeoutError(err)) {
|
|
296
|
+
// Contended lock — the schema is virtually always already correct by now.
|
|
297
|
+
// Proceed; the caller's memoization still resolves so we don't loop.
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|