@opengeni/db 3.1.3-canary.38890816741001 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -9276,6 +9276,11 @@ export declare function claimWorkspaceArchiveCapture(db: Database, input: {
9276
9276
  * exact box are recorded in deadline_forced_admission_ids, captured
9277
9277
  * around, and settled with the box after termination. */
9278
9278
  deadlineMandatoryCaptureLeadMs?: number;
9279
+ /** Draining only: unsettled setup requests whose provider Start outcome
9280
+ * is unknown, owned by a turn attempt closed at least this long ago with
9281
+ * no active retained process, are captured around exactly like the
9282
+ * mandatory pre-deadline save instead of pinning the box until then. */
9283
+ abandonedSetupCaptureAfterMs?: number;
9279
9284
  /** Warm point-in-time checkpoint of a box no turn holds, kept warm by
9280
9285
  * viewers, browser/computer interactions or active background commands
9281
9286
  * (supervised or not) (e.g. while the session waits for one or for input). Requires
package/dist/index.js CHANGED
@@ -1534,6 +1534,7 @@ import {
1534
1534
  placeSubscriptionCoreTurnWithFailover,
1535
1535
  planWorkspaceCaptureGc,
1536
1536
  preflightOrganizationUserSetup,
1537
+ preflightProviderVaultLink,
1537
1538
  prepareBotMessage,
1538
1539
  prepareBrowserDownloadSave,
1539
1540
  prepareBrowserRevisionPublication,
@@ -2207,7 +2208,7 @@ import {
2207
2208
  writeCompanyProfileLearning,
2208
2209
  writeSessionContentBlobs,
2209
2210
  writeSiteDataPath
2210
- } from "./chunk-JMX6XSIF.js";
2211
+ } from "./chunk-V2X2NK7T.js";
2211
2212
  import {
2212
2213
  BROKERED_CREDENTIAL_SHAPE_HINT,
2213
2214
  ConnectionRefreshHttpError,
@@ -4860,6 +4861,7 @@ export {
4860
4861
  placeSubscriptionCoreTurnWithFailover,
4861
4862
  planWorkspaceCaptureGc,
4862
4863
  preflightOrganizationUserSetup,
4864
+ preflightProviderVaultLink,
4863
4865
  prepareBotMessage,
4864
4866
  prepareBrowserDownloadSave,
4865
4867
  prepareBrowserRevisionPublication,
package/dist/migrate.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  planMigrations,
10
10
  runMigrateCli,
11
11
  runMigrations
12
- } from "./chunk-JMX6XSIF.js";
12
+ } from "./chunk-V2X2NK7T.js";
13
13
  import "./chunk-VCJAYIPH.js";
14
14
  import "./chunk-XQHABE23.js";
15
15
  import "./chunk-4BAKPTPG.js";
package/dist/vaults.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { VaultErrorCode, type SecretPrincipal, type VaultFieldKind, type VaultItemType, type VaultMode, type VaultOwnerKind, type VaultPolicy, type VaultSource, type VaultSyncStatus, type VaultVersionCreatedByKind } from "@opengeni/contracts";
1
+ import { VaultErrorCode, type SecretPrincipal, type VaultFieldKind, type VaultItemType, type VaultMode, type VaultOwnerKind, type VaultPolicy, type VaultProviderOperations, type VaultSource, type VaultSyncStatus, type VaultVersionCreatedByKind } from "@opengeni/contracts";
2
2
  import { type Database } from "./database.js";
3
3
  export type VaultDbContext = {
4
4
  kind: "subject";
@@ -19,6 +19,15 @@ export declare class VaultError extends Error {
19
19
  export declare function isVaultError(error: unknown): error is VaultError;
20
20
  /** Map a database error raised by a vault routine to VaultError; null otherwise. */
21
21
  export declare function vaultErrorFromDatabase(error: unknown): VaultError | null;
22
+ /**
23
+ * What a provider (or its bridge) said about its own upstream freshness at
24
+ * the last successful check (0789). Opengeni's own check time is
25
+ * `lastSyncedAt`; it never stands in for `sourceSyncedAt`.
26
+ */
27
+ export type StoredProviderFreshness = {
28
+ source: "live" | "cache" | "unknown";
29
+ sourceSyncedAt: string | null;
30
+ };
22
31
  export type VaultRow = {
23
32
  id: string;
24
33
  workspaceId: string;
@@ -36,6 +45,15 @@ export type VaultRow = {
36
45
  rateLimitedUntil: string | null;
37
46
  providerConnectionId: string | null;
38
47
  providerVaultRef: string | null;
48
+ /** Effective operations the provider last reported (0789); null before the first check. */
49
+ providerOperations?: VaultProviderOperations | null;
50
+ /** The provider's own freshness observation from the last successful check (0789). */
51
+ providerFreshness?: StoredProviderFreshness | null;
52
+ /**
53
+ * Durable value-cache epoch (0789, bigint as text). Every complete mirror
54
+ * replacement advances it; provider value caches compare it before reuse.
55
+ */
56
+ providerCacheEpoch?: string;
39
57
  availability: {
40
58
  allowedWorkspaceIds: string[] | null;
41
59
  allowPersonalWorkspaces: boolean;
@@ -105,6 +123,8 @@ export type VaultFieldCiphertext = {
105
123
  source: VaultSource;
106
124
  providerConnectionId: string | null;
107
125
  providerVaultRef: string | null;
126
+ /** The vault's value-cache epoch (0789). */
127
+ providerCacheEpoch?: string;
108
128
  providerItemRef: string | null;
109
129
  providerFieldRef: string | null;
110
130
  };
@@ -137,6 +157,8 @@ export type SecretReferenceCandidate = {
137
157
  source: VaultSource;
138
158
  providerConnectionId: string | null;
139
159
  providerVaultRef: string | null;
160
+ /** The vault's value-cache epoch (0789). */
161
+ providerCacheEpoch?: string;
140
162
  itemId: string;
141
163
  itemName: string;
142
164
  itemType: VaultItemType;
@@ -349,24 +371,57 @@ export type ProviderVaultItemMirror = {
349
371
  kind: VaultFieldKind;
350
372
  }>;
351
373
  };
374
+ /**
375
+ * Replace a connected vault's metadata mirror with one complete inventory
376
+ * (at most `VAULT_LIMITS.itemsPerVault` items; more is refused with
377
+ * `provider_inventory_incomplete` and the old mirror kept). Items missing
378
+ * from `items` are removed, and the vault's cache epoch advances (0789).
379
+ */
352
380
  export declare function upsertProviderVaultItems(db: Database, context: VaultDbContext, input: {
353
381
  vaultId: string;
354
382
  contentVersion: string | null;
355
383
  items: ProviderVaultItemMirror[];
356
384
  }): Promise<void>;
385
+ /**
386
+ * Record a connected vault's sync state. `operations` and `freshness` are
387
+ * optional (0789): absent keeps the stored value, null clears it. The optional
388
+ * mirror and status settle atomically, comparing `expectedCacheEpoch` under
389
+ * the vault lock; a mismatch raises provider_conflict with superseded=true.
390
+ * Every applied settlement advances the epoch, even unchanged or failed checks.
391
+ */
357
392
  export declare function setVaultSyncStatus(db: Database, context: VaultDbContext, input: {
358
393
  vaultId: string;
359
394
  status: VaultSyncStatus;
360
395
  errorCode?: string | null;
361
396
  errorMessage?: string | null;
362
397
  rateLimitedUntil?: string | null;
398
+ /** The provider's effective operations for this vault. */
399
+ operations?: VaultProviderOperations | null;
400
+ /** The provider's own upstream freshness observation (never Opengeni's check time). */
401
+ freshness?: StoredProviderFreshness | null;
402
+ /** Reject a late refresh when another settlement advanced this epoch. */
403
+ expectedCacheEpoch?: string;
404
+ /** Complete inventory committed atomically with this successful status. */
405
+ mirror?: {
406
+ contentVersion: string | null;
407
+ items: ProviderVaultItemMirror[];
408
+ };
363
409
  }): Promise<void>;
364
- /** Link a 1Password or host vault (W5). The mirror is filled by the first sync. */
410
+ /**
411
+ * Link a connected vault (W5, 0789). Every source but `host` needs an active
412
+ * `api_key` connection in the caller's workspace whose provider identity
413
+ * matches (`1password.com` with the 1Password service-account role, or
414
+ * `vault:<source>` with the `external_vault_provider` role and
415
+ * `metadata.vaultProvider.provider = source`, version 1, not pending
416
+ * verification) and whose ownership matches `ownerKind`: a personal
417
+ * connection backs only its owner's Only-me vaults. The mirror is filled by
418
+ * the first sync.
419
+ */
365
420
  export declare function linkProviderVault(db: Database, context: VaultDbContext, input: {
366
421
  ownerKind: "user" | "workspace" | "organization";
367
422
  name: string;
368
423
  description?: string | null;
369
- source: "onepassword" | "host";
424
+ source: Exclude<VaultSource, "opengeni">;
370
425
  providerConnectionId?: string | null;
371
426
  providerVaultRef: string;
372
427
  mode: VaultMode;
@@ -377,6 +432,14 @@ export declare function linkProviderVault(db: Database, context: VaultDbContext,
377
432
  /** The caller holds account:admin. */
378
433
  allowOrganization?: boolean;
379
434
  }): Promise<VaultRow>;
435
+ /**
436
+ * Exercise the exact link policy before an irreversible provider create. The
437
+ * temporary link, personal authority and audit rows are all rolled back. This
438
+ * is a preflight, not a reservation: a race after it must return the confirmed
439
+ * upstream identity so the caller can link that vault instead of creating again.
440
+ * No database transaction is held open across provider I/O.
441
+ */
442
+ export declare function preflightProviderVaultLink(db: Database, context: VaultDbContext, input: Omit<Parameters<typeof linkProviderVault>[2], "providerVaultRef">): Promise<void>;
380
443
  export type ProviderVaultSyncTargetRow = {
381
444
  vaultId: string;
382
445
  workspaceId: string;
@@ -386,6 +449,8 @@ export type ProviderVaultSyncTargetRow = {
386
449
  providerVaultRef: string;
387
450
  providerContentVersion: string | null;
388
451
  rateLimitedUntil: string | null;
452
+ /** The vault's value-cache epoch (0789). */
453
+ providerCacheEpoch?: string;
389
454
  };
390
455
  /** The provider refs and mirror version of one reachable connected vault. */
391
456
  export declare function getProviderVaultSyncTarget(db: Database, context: VaultDbContext, vaultId: string): Promise<ProviderVaultSyncTargetRow>;
@@ -0,0 +1,318 @@
1
+ -- deployment-mode: rolling
2
+ -- External vault providers (docs/design/external-vaults-2026-10-15.md, sections
3
+ -- 6, 8, 9 and 10).
4
+ -- * New source identities: `external` (remote bridge), `protonpass`,
5
+ -- `keeper` and `bitwarden`. Each is linked through a connection, like
6
+ -- 1Password; `host` keeps its injected, connection-less provider.
7
+ -- * Linking checks provider identity, active connection status and
8
+ -- ownership here as well as in HTTP. A personal connection backs only its
9
+ -- owner's Only-me vaults; a shared connection backs only vaults of its own
10
+ -- ownership, so an organization connection never becomes a workspace one.
11
+ -- A legacy 1Password connection without recorded ownership stays a
12
+ -- workspace connection, as the API has always shown it.
13
+ -- * A linked vault keeps the effective operations its provider last
14
+ -- reported, the provider's own freshness observation (never Opengeni's
15
+ -- check time), and a durable cache epoch. Every complete mirror
16
+ -- replacement advances the epoch, so per-attempt value caches in any API
17
+ -- or worker process notice a refresh made elsewhere.
18
+ -- * A mirror replacement holds at most the native 2,000 items per vault; a
19
+ -- larger inventory is refused whole, never stored as its first 2,000.
20
+ -- * Mirror replacements of one vault run one at a time: each holds the
21
+ -- vault row lock from before it reads the mirror until it commits, so an
22
+ -- older listing cannot re-add an item that a newer one just removed and
23
+ -- leave it under the newer content version.
24
+ -- * Core refresh settlement compares the epoch read before provider I/O,
25
+ -- committing inventory and status together. Every settlement advances it;
26
+ -- late results cannot overwrite a newer completed check, even unchanged.
27
+ -- Existing rows keep their meaning: the new columns are NULL or 0 and the
28
+ -- Only-me availability default of 0780 is unchanged.
29
+ SET LOCAL lock_timeout = '5s';
30
+
31
+ ALTER TABLE vaults DROP CONSTRAINT vaults_source_check;
32
+ ALTER TABLE vaults DROP CONSTRAINT vaults_provider_shape;
33
+ ALTER TABLE vaults
34
+ ADD COLUMN provider_operations jsonb,
35
+ ADD COLUMN provider_freshness jsonb,
36
+ ADD COLUMN provider_cache_epoch bigint NOT NULL DEFAULT 0;
37
+ ALTER TABLE vaults ADD CONSTRAINT vaults_source_check CHECK (
38
+ source IN ('opengeni', 'onepassword', 'host', 'external', 'protonpass', 'keeper', 'bitwarden')
39
+ );
40
+ ALTER TABLE vaults ADD CONSTRAINT vaults_provider_shape CHECK (
41
+ (source = 'opengeni' AND provider_vault_ref IS NULL AND provider_connection_id IS NULL
42
+ AND mode = 'read_write')
43
+ OR (source IN ('onepassword', 'external', 'protonpass', 'keeper', 'bitwarden')
44
+ AND provider_vault_ref IS NOT NULL AND provider_connection_id IS NOT NULL)
45
+ OR (source = 'host' AND provider_vault_ref IS NOT NULL AND provider_connection_id IS NULL)
46
+ );
47
+ ALTER TABLE vaults ADD CONSTRAINT vaults_provider_metadata_shape CHECK (
48
+ provider_cache_epoch >= 0
49
+ AND (provider_operations IS NULL
50
+ OR (source <> 'opengeni' AND jsonb_typeof(provider_operations) = 'object'))
51
+ AND (provider_freshness IS NULL
52
+ OR (source <> 'opengeni' AND jsonb_typeof(provider_freshness) = 'object'))
53
+ );
54
+
55
+ -- Rewrites one routine in place. Each patch must match exactly once, so a
56
+ -- routine that changed since this was written fails the migration loudly.
57
+ -- The name is migration-specific because earlier migrations leave their own
58
+ -- patcher in the migrator session's pg_temp schema.
59
+ CREATE FUNCTION pg_temp.patch_vault_routine_0786(p_routine regprocedure, p_patches text[])
60
+ RETURNS void
61
+ LANGUAGE plpgsql AS $patch$
62
+ DECLARE
63
+ definition text := pg_catalog.pg_get_functiondef(p_routine);
64
+ patch text[];
65
+ BEGIN
66
+ FOREACH patch SLICE 1 IN ARRAY p_patches LOOP
67
+ IF (pg_catalog.length(definition) - pg_catalog.length(pg_catalog.replace(definition, patch[1], '')))
68
+ <> pg_catalog.length(patch[1]) THEN
69
+ RAISE EXCEPTION '0789 external vault sources: % changed near: %', p_routine, patch[1];
70
+ END IF;
71
+ definition := pg_catalog.replace(definition, patch[1], patch[2]);
72
+ END LOOP;
73
+ EXECUTE definition;
74
+ END
75
+ $patch$;
76
+
77
+ -- Summaries carry the provider's operations, freshness observation and cache epoch.
78
+ SELECT pg_temp.patch_vault_routine_0786('opengeni_private.vault_summary_json(vaults,jsonb)'::regprocedure, ARRAY[
79
+ ARRAY[$old$ 'providerVaultRef', p_vault.provider_vault_ref,
80
+ $old$,
81
+ $new$ 'providerVaultRef', p_vault.provider_vault_ref,
82
+ 'providerOperations', p_vault.provider_operations,
83
+ 'providerFreshness', p_vault.provider_freshness,
84
+ 'providerCacheEpoch', p_vault.provider_cache_epoch::text,
85
+ $new$]
86
+ ]);
87
+
88
+ -- Link: every connected source needs an active connection of that provider
89
+ -- whose ownership matches the vault it backs.
90
+ SELECT pg_temp.patch_vault_routine_0786('link_provider_vault(jsonb,jsonb)'::regprocedure, ARRAY[
91
+ ARRAY[$old$ connection_row record;
92
+ $old$,
93
+ $new$ connection_row record;
94
+ connection_ownership text;
95
+ provider_label text;
96
+ $new$],
97
+ ARRAY[$old$ IF link_source NOT IN ('onepassword', 'host') OR link_source IS NULL THEN
98
+ PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only 1Password and host vaults are linked');
99
+ END IF;
100
+ $old$,
101
+ $new$ IF link_source NOT IN ('onepassword', 'host', 'external', 'protonpass', 'keeper', 'bitwarden')
102
+ OR link_source IS NULL THEN
103
+ PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only connected vault providers are linked');
104
+ END IF;
105
+ $new$],
106
+ ARRAY[$old$ IF link_source = 'onepassword' THEN
107
+ SELECT connection.id, connection.subject_id, connection.status INTO connection_row
108
+ FROM connections connection
109
+ WHERE connection.id = connection_ref
110
+ AND connection.account_id = (context ->> 'accountId')::uuid
111
+ AND connection.workspace_id = (context ->> 'workspaceId')::uuid
112
+ AND pg_catalog.lower(connection.provider_domain) = '1password.com';
113
+ IF connection_row.id IS NULL THEN
114
+ PERFORM opengeni_private.vault_raise('vault_not_found', '1Password connection not found');
115
+ END IF;
116
+ IF connection_row.subject_id IS NOT NULL AND (
117
+ owner_kind <> 'user' OR connection_row.subject_id IS DISTINCT FROM context ->> 'subjectId'
118
+ ) THEN
119
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
120
+ 'a personal 1Password connection backs only its owner''s Only-me vaults');
121
+ END IF;
122
+ ELSE
123
+ $old$,
124
+ $new$ IF link_source <> 'host' THEN
125
+ provider_label := CASE link_source
126
+ WHEN 'onepassword' THEN '1Password'
127
+ WHEN 'protonpass' THEN 'Proton Pass'
128
+ WHEN 'keeper' THEN 'Keeper'
129
+ WHEN 'bitwarden' THEN 'Bitwarden'
130
+ ELSE 'vault provider' END;
131
+ SELECT connection.id, connection.subject_id, connection.status, connection.metadata
132
+ INTO connection_row
133
+ FROM connections connection
134
+ WHERE connection.id = connection_ref
135
+ AND connection.account_id = (context ->> 'accountId')::uuid
136
+ AND connection.workspace_id = (context ->> 'workspaceId')::uuid
137
+ AND connection.kind = 'api_key'
138
+ AND CASE WHEN link_source = 'onepassword' THEN
139
+ pg_catalog.lower(connection.provider_domain) = '1password.com'
140
+ AND connection.metadata ->> 'credentialRole' = 'onepassword_service_account'
141
+ ELSE
142
+ connection.provider_domain = 'vault:' || link_source
143
+ AND connection.metadata ->> 'credentialRole' = 'external_vault_provider'
144
+ AND connection.metadata #>> '{vaultProvider,provider}' = link_source
145
+ AND connection.metadata #>> '{vaultProvider,version}' = '1'
146
+ END;
147
+ IF connection_row.id IS NULL OR connection_row.status = 'revoked' THEN
148
+ PERFORM opengeni_private.vault_raise('vault_not_found', provider_label || ' connection not found');
149
+ END IF;
150
+ IF connection_row.status <> 'active'
151
+ OR coalesce(connection_row.metadata #>> '{vaultProvider,verificationStatus}', 'ready') <> 'ready' THEN
152
+ PERFORM opengeni_private.vault_raise('provider_auth_expired',
153
+ 'the ' || provider_label || ' connection must be verified again before its vaults are linked',
154
+ pg_catalog.jsonb_build_object('connectionId', connection_row.id));
155
+ END IF;
156
+ connection_ownership := CASE
157
+ WHEN link_source <> 'onepassword' THEN connection_row.metadata #>> '{vaultProvider,ownership}'
158
+ WHEN connection_row.subject_id IS NOT NULL THEN 'user'
159
+ WHEN connection_row.metadata ->> 'ownership' = 'organization' THEN 'organization'
160
+ WHEN connection_row.metadata ->> 'ownership' = 'user' THEN NULL
161
+ ELSE 'workspace' END;
162
+ IF connection_ownership IS NULL
163
+ OR connection_ownership NOT IN ('user', 'workspace', 'organization')
164
+ OR (connection_ownership = 'user') <> (connection_row.subject_id IS NOT NULL) THEN
165
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
166
+ 'the ' || provider_label || ' connection has no consistent owner',
167
+ pg_catalog.jsonb_build_object('connectionId', connection_row.id));
168
+ END IF;
169
+ IF connection_ownership = 'user' AND (
170
+ owner_kind <> 'user' OR connection_row.subject_id IS DISTINCT FROM context ->> 'subjectId'
171
+ ) THEN
172
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
173
+ 'a personal ' || provider_label || ' connection backs only its owner''s Only-me vaults');
174
+ END IF;
175
+ IF connection_ownership <> owner_kind THEN
176
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
177
+ 'a ' || connection_ownership || ' ' || provider_label || ' connection backs only '
178
+ || connection_ownership || ' vaults',
179
+ pg_catalog.jsonb_build_object('connectionId', connection_row.id,
180
+ 'connectionOwnership', connection_ownership, 'ownerKind', owner_kind));
181
+ END IF;
182
+ ELSE
183
+ $new$]
184
+ ]);
185
+
186
+ -- Mirror replacement: a complete inventory of at most 2,000 items, applied
187
+ -- under the vault row lock, and every replacement advances the cache epoch.
188
+ -- The lock is FOR NO KEY UPDATE, the strength the routine's own final UPDATE
189
+ -- of the vault takes anyway, so it adds no new conflicts: it is only taken
190
+ -- before the mirror is read rather than after it is rewritten. Every later
191
+ -- statement sees what a replacement that held the lock before committed.
192
+ SELECT pg_temp.patch_vault_routine_0786('upsert_provider_vault_items(uuid,text,jsonb,jsonb)'::regprocedure, ARRAY[
193
+ ARRAY[$old$ kept_refs := ARRAY(SELECT element ->> 'providerItemRef'
194
+ $old$,
195
+ $new$ SELECT locked.* INTO vault_row FROM vaults locked
196
+ WHERE locked.id = vault_row.id
197
+ FOR NO KEY UPDATE OF locked;
198
+ IF vault_row.id IS NULL THEN
199
+ PERFORM opengeni_private.vault_raise('vault_not_found', 'vault not found');
200
+ ELSIF vault_row.source = 'opengeni' THEN
201
+ PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only connected vaults have a provider mirror');
202
+ END IF;
203
+ IF pg_catalog.jsonb_typeof(coalesce(p_items, '[]'::jsonb)) <> 'array' THEN
204
+ PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
205
+ 'the provider inventory is malformed; the previous inventory was kept');
206
+ ELSIF pg_catalog.jsonb_array_length(p_items) > 2000 THEN
207
+ PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
208
+ 'a connected vault mirrors at most 2000 items; the previous inventory was kept',
209
+ pg_catalog.jsonb_build_object('vaultId', vault_row.id, 'limit', 2000));
210
+ END IF;
211
+ kept_refs := ARRAY(SELECT element ->> 'providerItemRef'
212
+ $new$],
213
+ ARRAY[$old$ UPDATE vaults SET provider_content_version = p_content_version, last_synced_at = pg_catalog.now(),
214
+ $old$,
215
+ $new$ UPDATE vaults SET provider_content_version = p_content_version, last_synced_at = pg_catalog.now(),
216
+ provider_cache_epoch = provider_cache_epoch + 1,
217
+ $new$]
218
+ ]);
219
+
220
+ -- Sync status also records the provider's effective operations and its own
221
+ -- freshness observation (both optional; a JSON null clears them).
222
+ SELECT pg_temp.patch_vault_routine_0786('set_vault_sync_status(uuid,jsonb,jsonb)'::regprocedure, ARRAY[
223
+ ARRAY[$old$ UPDATE vaults SET
224
+ sync_status = p ->> 'status',
225
+ $old$,
226
+ $new$ IF p ? 'operations' AND (
227
+ pg_catalog.jsonb_typeof(p -> 'operations') NOT IN ('object', 'null')
228
+ OR pg_catalog.octet_length((p -> 'operations')::text) > 4096) THEN
229
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
230
+ 'provider operations must be a small JSON object');
231
+ END IF;
232
+ IF p ? 'freshness' AND (
233
+ pg_catalog.jsonb_typeof(p -> 'freshness') NOT IN ('object', 'null')
234
+ OR pg_catalog.octet_length((p -> 'freshness')::text) > 1024) THEN
235
+ PERFORM opengeni_private.vault_raise('vault_permission_denied',
236
+ 'provider freshness must be a small JSON object');
237
+ END IF;
238
+ UPDATE vaults SET
239
+ sync_status = p ->> 'status',
240
+ $new$],
241
+ ARRAY[$old$ last_synced_at = CASE WHEN p ->> 'status' = 'ok' THEN pg_catalog.now() ELSE last_synced_at END,
242
+ $old$,
243
+ $new$ last_synced_at = CASE WHEN p ->> 'status' = 'ok' THEN pg_catalog.now() ELSE last_synced_at END,
244
+ provider_operations = CASE WHEN p ? 'operations'
245
+ THEN nullif(p -> 'operations', 'null'::jsonb) ELSE provider_operations END,
246
+ provider_freshness = CASE WHEN p ? 'freshness'
247
+ THEN nullif(p -> 'freshness', 'null'::jsonb) ELSE provider_freshness END,
248
+ $new$]
249
+ ]);
250
+
251
+ -- What a sync, a read and a resolution need to key provider value caches.
252
+ -- Settle a refresh under one compare-and-set fence. Provider versions are
253
+ -- opaque: the cache epoch observed before I/O is the only ordering token.
254
+ -- Every settlement advances it, including unchanged checks and failures.
255
+ -- A late result changes neither the mirror nor the newer status/freshness.
256
+ -- The optional mirror uses the same canonical writer in this transaction;
257
+ -- its capability closes on return, so reopen ours before the status UPDATE.
258
+ SELECT pg_temp.patch_vault_routine_0786('set_vault_sync_status(uuid,jsonb,jsonb)'::regprocedure, ARRAY[
259
+ ARRAY[$old$ IF p ? 'operations' AND (
260
+ $old$,
261
+ $new$ SELECT locked.* INTO vault_row FROM vaults locked
262
+ WHERE locked.id = vault_row.id FOR NO KEY UPDATE OF locked;
263
+ IF vault_row.id IS NULL THEN
264
+ PERFORM opengeni_private.vault_raise('vault_not_found', 'vault not found');
265
+ END IF;
266
+ IF p ? 'expectedCacheEpoch'
267
+ AND (p ->> 'expectedCacheEpoch')::bigint IS DISTINCT FROM vault_row.provider_cache_epoch THEN
268
+ PERFORM opengeni_private.vault_raise('provider_conflict',
269
+ 'another refresh settled first; this late inventory was not applied',
270
+ pg_catalog.jsonb_build_object('vaultId', vault_row.id, 'superseded', true));
271
+ END IF;
272
+ IF p ? 'operations' AND (
273
+ $new$],
274
+ ARRAY[$old$ UPDATE vaults SET
275
+ sync_status = p ->> 'status',
276
+ $old$,
277
+ $new$ IF p ? 'mirror' THEN
278
+ IF NOT (p ? 'expectedCacheEpoch') THEN
279
+ PERFORM opengeni_private.vault_raise('provider_conflict',
280
+ 'atomic refresh settlement requires its pre-I/O cache epoch');
281
+ END IF;
282
+ IF p ->> 'status' IS DISTINCT FROM 'ok' OR pg_catalog.jsonb_typeof(p -> 'mirror') <> 'object' THEN
283
+ PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
284
+ 'only a successful refresh may replace the inventory');
285
+ END IF;
286
+ PERFORM upsert_provider_vault_items(p_vault, p #>> '{mirror,contentVersion}',
287
+ p #> '{mirror,items}', p_principal);
288
+ PERFORM opengeni_private.vault_capability_open('write');
289
+ END IF;
290
+ UPDATE vaults SET
291
+ provider_cache_epoch = provider_cache_epoch + CASE WHEN p ? 'mirror' THEN 0 ELSE 1 END,
292
+ sync_status = p ->> 'status',
293
+ $new$]
294
+ ]);
295
+
296
+ SELECT pg_temp.patch_vault_routine_0786('provider_vault_sync_target(uuid,jsonb)'::regprocedure, ARRAY[
297
+ ARRAY[$old$ 'providerContentVersion', vault_row.provider_content_version,
298
+ $old$,
299
+ $new$ 'providerContentVersion', vault_row.provider_content_version,
300
+ 'providerCacheEpoch', vault_row.provider_cache_epoch::text,
301
+ $new$]
302
+ ]);
303
+ SELECT pg_temp.patch_vault_routine_0786('read_vault_field_ciphertext(uuid,integer,text,jsonb,jsonb)'::regprocedure, ARRAY[
304
+ ARRAY[$old$ 'providerVaultRef', vault_row.provider_vault_ref,
305
+ $old$,
306
+ $new$ 'providerVaultRef', vault_row.provider_vault_ref,
307
+ 'providerCacheEpoch', vault_row.provider_cache_epoch::text,
308
+ $new$]
309
+ ]);
310
+ SELECT pg_temp.patch_vault_routine_0786('resolve_secret_reference_candidates(jsonb,jsonb)'::regprocedure, ARRAY[
311
+ ARRAY[$old$ 'providerVaultRef', vault.provider_vault_ref,
312
+ $old$,
313
+ $new$ 'providerVaultRef', vault.provider_vault_ref,
314
+ 'providerCacheEpoch', vault.provider_cache_epoch::text,
315
+ $new$]
316
+ ]);
317
+
318
+ DROP FUNCTION pg_temp.patch_vault_routine_0786(regprocedure, text[]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "3.1.3-canary.38890816741001",
3
+ "version": "3.2.2",
4
4
  "description": "Opengeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -117,14 +117,14 @@
117
117
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
118
118
  },
119
119
  "dependencies": {
120
- "@opengeni/codemode": "3.1.3-canary.38890816741001",
121
- "@opengeni/codex": "3.1.3-canary.38890816741001",
122
- "@opengeni/config": "3.1.3-canary.38890816741001",
123
- "@opengeni/contracts": "3.1.3-canary.38890816741001",
124
- "@opengeni/network": "3.1.3-canary.38890816741001",
125
- "@opengeni/observability": "3.1.3-canary.38890816741001",
126
- "@opengeni/subscriptions": "3.1.3-canary.38890816741001",
127
- "@opengeni/xai-subscription": "3.1.3-canary.38890816741001",
120
+ "@opengeni/codemode": "3.2.2",
121
+ "@opengeni/codex": "3.2.2",
122
+ "@opengeni/config": "3.2.2",
123
+ "@opengeni/contracts": "3.2.2",
124
+ "@opengeni/network": "3.2.2",
125
+ "@opengeni/observability": "3.2.2",
126
+ "@opengeni/subscriptions": "3.2.2",
127
+ "@opengeni/xai-subscription": "3.2.2",
128
128
  "drizzle-orm": "^0.45.2",
129
129
  "postgres": "^3.4.7"
130
130
  },
package/src/index.ts CHANGED
@@ -56039,6 +56039,11 @@ export async function claimWorkspaceArchiveCapture(
56039
56039
  * exact box are recorded in deadline_forced_admission_ids, captured
56040
56040
  * around, and settled with the box after termination. */
56041
56041
  deadlineMandatoryCaptureLeadMs?: number;
56042
+ /** Draining only: unsettled setup requests whose provider Start outcome
56043
+ * is unknown, owned by a turn attempt closed at least this long ago with
56044
+ * no active retained process, are captured around exactly like the
56045
+ * mandatory pre-deadline save instead of pinning the box until then. */
56046
+ abandonedSetupCaptureAfterMs?: number;
56042
56047
  /** Warm point-in-time checkpoint of a box no turn holds, kept warm by
56043
56048
  * viewers, browser/computer interactions or active background commands
56044
56049
  * (supervised or not) (e.g. while the session waits for one or for input). Requires
@@ -56341,27 +56346,86 @@ export async function claimWorkspaceArchiveCapture(
56341
56346
  order by admission.id
56342
56347
  `);
56343
56348
  if (unsettled.length > 0) {
56344
- // Any requested rotation counts: an earlier operator rotation keeps the
56345
- // due provider-deadline rotation from being stamped separately.
56349
+ // Close to the provider deadline a drain must save now. Due rotations
56350
+ // are only stamped on warm leases, so a lease that was already
56351
+ // draining (no holder left) never gets one; requiring it let such a
56352
+ // box be killed uncaptured at the deadline. This branch is draining
56353
+ // only, so the deadline alone decides.
56346
56354
  const lead = input.deadlineMandatoryCaptureLeadMs;
56347
56355
  const [deadline] =
56348
56356
  input.liveness === "draining" && lead !== undefined && Number.isSafeInteger(lead)
56349
56357
  ? await scopedDb.execute<{ mandatory: boolean }>(sql`
56350
56358
  select (
56351
- ${row.rotation_requested_at !== null}
56352
- and ${row.provider_deadline_at ?? null}::timestamptz is not null
56359
+ ${row.provider_deadline_at ?? null}::timestamptz is not null
56353
56360
  and ${row.provider_deadline_at ?? null}::timestamptz
56354
56361
  <= now() + (${lead}::bigint * interval '1 millisecond')
56355
56362
  ) as mandatory
56356
56363
  `)
56357
56364
  : [];
56358
- if (deadline?.mandatory !== true) {
56365
+ // A setup request whose provider Start outcome is unknown can never
56366
+ // settle on a live box: no exit can be observed for it, and its turn
56367
+ // waits (recovering) for the box to be gone. Waiting for the deadline
56368
+ // kept a box with no holder running for a day. Once its owner attempt
56369
+ // has been closed for the abandonment window, with no retained process
56370
+ // of that attempt, capture around exactly these setup requests;
56371
+ // termination then settles them and the turn's ended-epoch recovery
56372
+ // resumes it on the saved workspace. Only a point-in-time (Modal
56373
+ // snapshot) capture may run around a request that might still be
56374
+ // writing; a file-by-file tar read keeps waiting for the deadline.
56375
+ const abandonAfterMs = input.abandonedSetupCaptureAfterMs;
56376
+ const [abandoned] =
56377
+ deadline?.mandatory !== true &&
56378
+ input.liveness === "draining" &&
56379
+ input.pointInTimeCapture === true &&
56380
+ abandonAfterMs !== undefined &&
56381
+ Number.isSafeInteger(abandonAfterMs) &&
56382
+ abandonAfterMs > 0
56383
+ ? await scopedDb.execute<{ abandoned: boolean }>(sql`
56384
+ select coalesce(bool_and(coalesce(
56385
+ admission.actor_kind = 'turn'
56386
+ and admission.actor_id = attempt.id
56387
+ and admission.operation in ('lazyOwnedSandboxSetup', 'eagerOwnedSandboxSetup')
56388
+ and admission.route_kind = 'home'
56389
+ and admission.route_target_id is null
56390
+ and attempt.state = 'closed'
56391
+ and attempt.closed_at
56392
+ <= now() - (${abandonAfterMs}::bigint * interval '1 millisecond')
56393
+ and turn.status = 'recovering'
56394
+ and turn.active_attempt_id is null
56395
+ and turn.execution_generation = attempt.execution_generation
56396
+ and turn.metadata -> 'sandboxSetupOutcomeUnknown' @> jsonb_build_object(
56397
+ 'version', 1, 'turnId', turn.id::text, 'attemptId', attempt.id::text,
56398
+ 'reason', 'sandbox_command_start_outcome_unknown')
56399
+ and not exists (select 1 from sandbox_retained_processes process
56400
+ where process.workspace_id = attempt.workspace_id
56401
+ and process.session_id = attempt.session_id
56402
+ and process.owner_attempt_id = attempt.id
56403
+ and process.state = 'active'),
56404
+ false)), false) as abandoned
56405
+ from sandbox_workspace_mutation_admissions admission
56406
+ left join session_turn_attempts attempt
56407
+ on attempt.account_id = admission.account_id
56408
+ and attempt.workspace_id = admission.workspace_id
56409
+ and attempt.session_id = admission.session_id
56410
+ and attempt.turn_id = admission.turn_id
56411
+ and attempt.id = admission.attempt_id
56412
+ and attempt.execution_generation = admission.execution_generation
56413
+ left join session_turns turn
56414
+ on turn.workspace_id = attempt.workspace_id
56415
+ and turn.session_id = attempt.session_id
56416
+ and turn.id = attempt.turn_id
56417
+ where admission.id = any(${`{${unsettled
56418
+ .map((admission: { id: string }) => admission.id)
56419
+ .join(",")}}`}::uuid[])
56420
+ `)
56421
+ : [];
56422
+ if (deadline?.mandatory !== true && abandoned?.abandoned !== true) {
56359
56423
  return { status: "mutation_in_progress" as const };
56360
56424
  }
56361
- // Mandatory pre-deadline save: no holder is left on the box (checked
56362
- // above), so these requests cannot finish into a capture in time.
56363
- // Record exactly them; publication excludes them, and the cold commit
56364
- // settles them only after the box is terminated.
56425
+ // Mandatory save: no holder is left on the box (checked above), so
56426
+ // these requests cannot finish into a capture in time. Record exactly
56427
+ // them; publication excludes them, and the cold commit settles them
56428
+ // only after the box is terminated.
56365
56429
  await scopedDb.execute(sql`
56366
56430
  update sandbox_leases set deadline_forced_admission_ids = ${`{${[
56367
56431
  ...forcedAdmissionIds,