@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.
Files changed (192) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +46 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/task-store.ts +25 -3
  5. package/corpus/core/src/agent/harness/store.ts +89 -55
  6. package/corpus/core/src/agent/observational-memory/store.ts +40 -14
  7. package/corpus/core/src/agent/production-agent.ts +18 -0
  8. package/corpus/core/src/agent/run-store.ts +116 -59
  9. package/corpus/core/src/application-state/store.ts +36 -2
  10. package/corpus/core/src/audit/store.ts +37 -6
  11. package/corpus/core/src/browser-sessions/store.ts +31 -12
  12. package/corpus/core/src/chat-threads/store.ts +67 -3
  13. package/corpus/core/src/checkpoints/store.ts +14 -3
  14. package/corpus/core/src/cli/create.ts +9 -0
  15. package/corpus/core/src/collab/storage.ts +20 -2
  16. package/corpus/core/src/db/ddl-guard.ts +302 -0
  17. package/corpus/core/src/extensions/store.ts +76 -3
  18. package/corpus/core/src/integrations/a2a-continuations-store.ts +69 -26
  19. package/corpus/core/src/integrations/config-store.ts +19 -18
  20. package/corpus/core/src/integrations/pending-tasks-store.ts +40 -16
  21. package/corpus/core/src/integrations/remote-commands-store.ts +37 -23
  22. package/corpus/core/src/integrations/remote-devices-store.ts +64 -20
  23. package/corpus/core/src/integrations/remote-push-store.ts +75 -35
  24. package/corpus/core/src/integrations/remote-run-events-store.ts +38 -12
  25. package/corpus/core/src/integrations/thread-mapping-store.ts +11 -2
  26. package/corpus/core/src/mcp/connect-store.ts +53 -16
  27. package/corpus/core/src/mcp/oauth-store.ts +32 -7
  28. package/corpus/core/src/notifications/store.ts +20 -4
  29. package/corpus/core/src/oauth-tokens/store.ts +45 -2
  30. package/corpus/core/src/observability/store.ts +154 -47
  31. package/corpus/core/src/progress/store.ts +36 -21
  32. package/corpus/core/src/provider-api/corpus-jobs-store.ts +25 -5
  33. package/corpus/core/src/provider-api/custom-registry.ts +15 -4
  34. package/corpus/core/src/provider-api/quota-governor.ts +15 -3
  35. package/corpus/core/src/provider-api/staged-datasets-store.ts +22 -5
  36. package/corpus/core/src/resources/store.ts +66 -31
  37. package/corpus/core/src/secrets/storage.ts +32 -2
  38. package/corpus/core/src/server/agent-teams-run-queue.ts +22 -10
  39. package/corpus/core/src/server/auth.ts +22 -4
  40. package/corpus/core/src/server/better-auth-instance.ts +49 -15
  41. package/corpus/core/src/server/embed-session.ts +19 -4
  42. package/corpus/core/src/server/identity-sso-store.ts +40 -11
  43. package/corpus/core/src/server/recap-image-store.ts +28 -9
  44. package/corpus/core/src/settings/store.ts +35 -6
  45. package/corpus/core/src/usage/store.ts +51 -16
  46. package/corpus/core/src/workspace-connections/store.ts +200 -22
  47. package/corpus/templates/calendar/changelog/2026-06-25-booking-link-previews-now-show-the-content-directly-on-the-g.md +6 -0
  48. package/corpus/templates/calendar/server/lib/booking-og-image.ts +0 -2
  49. package/corpus/templates/forms/changelog/2026-06-25-form-previews-now-show-the-content-directly-on-the-grid-back.md +6 -0
  50. package/corpus/templates/forms/server/lib/form-og-image.ts +0 -2
  51. package/corpus/templates/plan/app/global.css +6 -11
  52. package/corpus/templates/plan/app/pages/PlansPage.tsx +12 -10
  53. package/corpus/templates/plan/changelog/2026-06-25-plan-access-request-pages-stay-visible-during-background-ref.md +6 -0
  54. package/corpus/templates/plan/changelog/2026-06-25-plan-documents-keep-their-full-reading-width-below-1200px-an.md +6 -0
  55. package/dist/a2a/task-store.d.ts.map +1 -1
  56. package/dist/a2a/task-store.js +19 -3
  57. package/dist/a2a/task-store.js.map +1 -1
  58. package/dist/agent/harness/store.d.ts.map +1 -1
  59. package/dist/agent/harness/store.js +63 -34
  60. package/dist/agent/harness/store.js.map +1 -1
  61. package/dist/agent/observational-memory/store.d.ts.map +1 -1
  62. package/dist/agent/observational-memory/store.js +30 -8
  63. package/dist/agent/observational-memory/store.js.map +1 -1
  64. package/dist/agent/production-agent.d.ts.map +1 -1
  65. package/dist/agent/production-agent.js +18 -0
  66. package/dist/agent/production-agent.js.map +1 -1
  67. package/dist/agent/run-store.d.ts.map +1 -1
  68. package/dist/agent/run-store.js +103 -46
  69. package/dist/agent/run-store.js.map +1 -1
  70. package/dist/application-state/store.d.ts.map +1 -1
  71. package/dist/application-state/store.js +28 -2
  72. package/dist/application-state/store.js.map +1 -1
  73. package/dist/audit/store.d.ts.map +1 -1
  74. package/dist/audit/store.js +18 -3
  75. package/dist/audit/store.js.map +1 -1
  76. package/dist/browser-sessions/store.d.ts.map +1 -1
  77. package/dist/browser-sessions/store.js +22 -7
  78. package/dist/browser-sessions/store.js.map +1 -1
  79. package/dist/chat-threads/store.d.ts.map +1 -1
  80. package/dist/chat-threads/store.js +51 -3
  81. package/dist/chat-threads/store.js.map +1 -1
  82. package/dist/checkpoints/store.d.ts.map +1 -1
  83. package/dist/checkpoints/store.js +12 -3
  84. package/dist/checkpoints/store.js.map +1 -1
  85. package/dist/cli/create.d.ts.map +1 -1
  86. package/dist/cli/create.js +9 -0
  87. package/dist/cli/create.js.map +1 -1
  88. package/dist/collab/routes.d.ts +2 -2
  89. package/dist/collab/storage.d.ts.map +1 -1
  90. package/dist/collab/storage.js +14 -2
  91. package/dist/collab/storage.js.map +1 -1
  92. package/dist/db/ddl-guard.d.ts +151 -0
  93. package/dist/db/ddl-guard.d.ts.map +1 -0
  94. package/dist/db/ddl-guard.js +265 -0
  95. package/dist/db/ddl-guard.js.map +1 -0
  96. package/dist/extensions/store.d.ts.map +1 -1
  97. package/dist/extensions/store.js +32 -3
  98. package/dist/extensions/store.js.map +1 -1
  99. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  100. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  101. package/dist/integrations/a2a-continuations-store.js +44 -24
  102. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  103. package/dist/integrations/config-store.d.ts.map +1 -1
  104. package/dist/integrations/config-store.js +17 -11
  105. package/dist/integrations/config-store.js.map +1 -1
  106. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  107. package/dist/integrations/pending-tasks-store.js +24 -16
  108. package/dist/integrations/pending-tasks-store.js.map +1 -1
  109. package/dist/integrations/remote-commands-store.d.ts.map +1 -1
  110. package/dist/integrations/remote-commands-store.js +29 -21
  111. package/dist/integrations/remote-commands-store.js.map +1 -1
  112. package/dist/integrations/remote-devices-store.d.ts.map +1 -1
  113. package/dist/integrations/remote-devices-store.js +33 -18
  114. package/dist/integrations/remote-devices-store.js.map +1 -1
  115. package/dist/integrations/remote-push-store.d.ts.map +1 -1
  116. package/dist/integrations/remote-push-store.js +53 -31
  117. package/dist/integrations/remote-push-store.js.map +1 -1
  118. package/dist/integrations/remote-run-events-store.d.ts.map +1 -1
  119. package/dist/integrations/remote-run-events-store.js +26 -10
  120. package/dist/integrations/remote-run-events-store.js.map +1 -1
  121. package/dist/integrations/thread-mapping-store.d.ts.map +1 -1
  122. package/dist/integrations/thread-mapping-store.js +10 -2
  123. package/dist/integrations/thread-mapping-store.js.map +1 -1
  124. package/dist/mcp/connect-store.d.ts.map +1 -1
  125. package/dist/mcp/connect-store.js +34 -16
  126. package/dist/mcp/connect-store.js.map +1 -1
  127. package/dist/mcp/oauth-store.d.ts.map +1 -1
  128. package/dist/mcp/oauth-store.js +22 -7
  129. package/dist/mcp/oauth-store.js.map +1 -1
  130. package/dist/notifications/routes.d.ts +2 -2
  131. package/dist/notifications/store.d.ts.map +1 -1
  132. package/dist/notifications/store.js +15 -3
  133. package/dist/notifications/store.js.map +1 -1
  134. package/dist/oauth-tokens/store.d.ts.map +1 -1
  135. package/dist/oauth-tokens/store.js +33 -2
  136. package/dist/oauth-tokens/store.js.map +1 -1
  137. package/dist/observability/routes.d.ts +7 -7
  138. package/dist/observability/store.d.ts.map +1 -1
  139. package/dist/observability/store.js +72 -29
  140. package/dist/observability/store.js.map +1 -1
  141. package/dist/progress/store.d.ts.map +1 -1
  142. package/dist/progress/store.js +31 -16
  143. package/dist/progress/store.js.map +1 -1
  144. package/dist/provider-api/corpus-jobs-store.d.ts.map +1 -1
  145. package/dist/provider-api/corpus-jobs-store.js +16 -5
  146. package/dist/provider-api/corpus-jobs-store.js.map +1 -1
  147. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  148. package/dist/provider-api/custom-registry.js +14 -4
  149. package/dist/provider-api/custom-registry.js.map +1 -1
  150. package/dist/provider-api/quota-governor.d.ts.map +1 -1
  151. package/dist/provider-api/quota-governor.js +12 -3
  152. package/dist/provider-api/quota-governor.js.map +1 -1
  153. package/dist/provider-api/staged-datasets-store.d.ts.map +1 -1
  154. package/dist/provider-api/staged-datasets-store.js +16 -5
  155. package/dist/provider-api/staged-datasets-store.js.map +1 -1
  156. package/dist/resources/handlers.d.ts +2 -2
  157. package/dist/resources/store.d.ts.map +1 -1
  158. package/dist/resources/store.js +56 -25
  159. package/dist/resources/store.js.map +1 -1
  160. package/dist/secrets/storage.d.ts.map +1 -1
  161. package/dist/secrets/storage.js +22 -2
  162. package/dist/secrets/storage.js.map +1 -1
  163. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  164. package/dist/server/agent-teams-run-queue.d.ts.map +1 -1
  165. package/dist/server/agent-teams-run-queue.js +15 -4
  166. package/dist/server/agent-teams-run-queue.js.map +1 -1
  167. package/dist/server/auth.d.ts.map +1 -1
  168. package/dist/server/auth.js +16 -2
  169. package/dist/server/auth.js.map +1 -1
  170. package/dist/server/better-auth-instance.d.ts.map +1 -1
  171. package/dist/server/better-auth-instance.js +55 -21
  172. package/dist/server/better-auth-instance.js.map +1 -1
  173. package/dist/server/embed-session.d.ts.map +1 -1
  174. package/dist/server/embed-session.js +15 -4
  175. package/dist/server/embed-session.js.map +1 -1
  176. package/dist/server/identity-sso-store.d.ts.map +1 -1
  177. package/dist/server/identity-sso-store.js +30 -11
  178. package/dist/server/identity-sso-store.js.map +1 -1
  179. package/dist/server/recap-image-store.d.ts.map +1 -1
  180. package/dist/server/recap-image-store.js +21 -7
  181. package/dist/server/recap-image-store.js.map +1 -1
  182. package/dist/server/transcribe-voice.d.ts +1 -1
  183. package/dist/settings/store.d.ts.map +1 -1
  184. package/dist/settings/store.js +30 -6
  185. package/dist/settings/store.js.map +1 -1
  186. package/dist/usage/store.d.ts.map +1 -1
  187. package/dist/usage/store.js +36 -11
  188. package/dist/usage/store.js.map +1 -1
  189. package/dist/workspace-connections/store.d.ts.map +1 -1
  190. package/dist/workspace-connections/store.js +57 -7
  191. package/dist/workspace-connections/store.js.map +1 -1
  192. 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
- await client.execute(`
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, retryOnDdlRace } from "../db/client.js";
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
- await retryOnDdlRace(() =>
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
- await client.execute(`
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
- await client.execute(`
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
- await client.execute(`
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
+ }