@sixb/pg 0.1.12 → 0.1.14

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 (41) hide show
  1. package/dist/auth-storage/access-tokens.d.ts.map +1 -1
  2. package/dist/auth-storage/auth-storage.d.ts +2 -1
  3. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  4. package/dist/auth-storage/device-authorizations.d.ts.map +1 -1
  5. package/dist/auth-storage/rows.d.ts +7 -4
  6. package/dist/auth-storage/rows.d.ts.map +1 -1
  7. package/dist/auth-storage/sessions.d.ts +8 -1
  8. package/dist/auth-storage/sessions.d.ts.map +1 -1
  9. package/dist/auth-storage/shared.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +403 -115
  13. package/dist/index.js.map +16 -15
  14. package/dist/migrations.d.ts.map +1 -1
  15. package/dist/objects/storage.d.ts.map +1 -1
  16. package/dist/ontology-storage/materializations.d.ts +0 -2
  17. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  18. package/dist/ontology-storage/shared.d.ts +2 -1
  19. package/dist/ontology-storage/shared.d.ts.map +1 -1
  20. package/dist/pg-file-upload-session-storage.d.ts +6 -0
  21. package/dist/pg-file-upload-session-storage.d.ts.map +1 -0
  22. package/dist/pg-workflow-intervention-storage.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/auth-storage/access-tokens.ts +10 -14
  25. package/src/auth-storage/auth-storage.ts +47 -81
  26. package/src/auth-storage/device-authorizations.ts +3 -4
  27. package/src/auth-storage/rows.ts +18 -8
  28. package/src/auth-storage/sessions.ts +82 -4
  29. package/src/auth-storage/shared.ts +8 -2
  30. package/src/index.ts +5 -0
  31. package/src/migrations/046-workflow-intervention-principals.sql +15 -0
  32. package/src/migrations/047-ontology-commit-attribution.sql +50 -0
  33. package/src/migrations/048-file-upload-sessions.sql +34 -0
  34. package/src/migrations/049-native-sessions.sql +19 -0
  35. package/src/migrations/050-directory-group-memberships.sql +5 -0
  36. package/src/migrations.ts +16 -0
  37. package/src/objects/storage.ts +2 -0
  38. package/src/ontology-storage/materializations.ts +15 -58
  39. package/src/ontology-storage/shared.ts +7 -3
  40. package/src/pg-file-upload-session-storage.ts +143 -0
  41. package/src/pg-workflow-intervention-storage.ts +11 -15
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ import { PgAiLimitStorage } from "./pg-ai-limit-storage"
42
42
  import { PgAiUsageStorage } from "./pg-ai-usage-storage"
43
43
  import { createPgClient, type SQL, type SQLClient } from "./pg-client"
44
44
  import { PgExecutionStorage } from "./pg-execution-storage"
45
+ import { PgFileUploadSessionStorage } from "./pg-file-upload-session-storage"
45
46
  import { PgPipelineRunStorage } from "./pg-pipeline-run-storage"
46
47
  import { PgProjectionRunStorage } from "./pg-projection-run-storage"
47
48
  import { PgRulesStorage } from "./pg-rules-storage"
@@ -158,6 +159,7 @@ export class PostgresStorage implements MigrationCapableStorage {
158
159
  readonly connectorConnections: PgConnectorConnectionStorage
159
160
  readonly shareGrants: PgShareGrantStorage
160
161
  readonly shareSessions: PgShareSessionStorage
162
+ readonly fileUploadSessions: PgFileUploadSessionStorage
161
163
  readonly migrators: readonly StorageMigrator[]
162
164
 
163
165
  private readonly sql: SQL
@@ -235,6 +237,7 @@ export class PostgresStorage implements MigrationCapableStorage {
235
237
  this.connectorConnections = createOperationScopedFacade(stores.connectorConnections, scope)
236
238
  this.shareGrants = createOperationScopedFacade(stores.shareGrants, scope)
237
239
  this.shareSessions = createOperationScopedFacade(stores.shareSessions, scope)
240
+ this.fileUploadSessions = createOperationScopedFacade(stores.fileUploadSessions, scope)
238
241
  registerPostgresStorageTestingAdapter(this, (durationMs) =>
239
242
  stores.connectorConnections.advanceTimeForTesting(durationMs)
240
243
  )
@@ -371,6 +374,7 @@ function createPostgresStores(
371
374
  connectorConnections: new PgConnectorConnectionStorage(sql),
372
375
  shareGrants,
373
376
  shareSessions,
377
+ fileUploadSessions: new PgFileUploadSessionStorage(sql),
374
378
  }
375
379
  }
376
380
 
@@ -395,6 +399,7 @@ interface PostgresStoreSet {
395
399
  readonly connectorConnections: PgConnectorConnectionStorage
396
400
  readonly shareGrants: PgShareGrantStorage
397
401
  readonly shareSessions: PgShareSessionStorage
402
+ readonly fileUploadSessions: PgFileUploadSessionStorage
398
403
  }
399
404
 
400
405
  function resolveTimeoutMillis(value: number | undefined, label: string): number | undefined {
@@ -0,0 +1,15 @@
1
+ -- Intervention actors were stored as { principalType, principalId }. Rewrite them to the
2
+ -- canonical Principal shape { type, id } used by every other stored principal.
3
+ UPDATE workflow_interventions
4
+ SET submitted_by = jsonb_build_object(
5
+ 'type', submitted_by->>'principalType',
6
+ 'id', submitted_by->>'principalId'
7
+ )
8
+ WHERE submitted_by->>'principalType' IS NOT NULL;
9
+
10
+ UPDATE workflow_interventions
11
+ SET cancelled_by = jsonb_build_object(
12
+ 'type', cancelled_by->>'principalType',
13
+ 'id', cancelled_by->>'principalId'
14
+ )
15
+ WHERE cancelled_by->>'principalType' IS NOT NULL;
@@ -0,0 +1,50 @@
1
+ -- Commits and their events name who requested the write and what ran it, replacing the
2
+ -- authority-only `actor`. Both are copied from the commit's execution, which stays canonical and
3
+ -- is immutable, so the backfill is exact.
4
+ ALTER TABLE ontology_commits
5
+ ADD COLUMN requested_by JSONB,
6
+ ADD COLUMN executor JSONB;
7
+
8
+ UPDATE ontology_commits c SET
9
+ requested_by = CASE
10
+ WHEN e.requested_by_user_id IS NOT NULL
11
+ THEN jsonb_build_object('type', 'user', 'id', e.requested_by_user_id)
12
+ WHEN e.requested_by_service_account_id IS NOT NULL
13
+ THEN jsonb_build_object('type', 'serviceAccount', 'id', e.requested_by_service_account_id)
14
+ END,
15
+ executor = CASE e.executor_kind
16
+ WHEN 'request' THEN jsonb_build_object('type', 'request', 'requestId', e.executor_id)
17
+ WHEN 'agent' THEN jsonb_build_object('type', 'agent', 'runId', e.executor_id)
18
+ WHEN 'kernel' THEN jsonb_build_object(
19
+ 'type', 'kernel',
20
+ 'operation',
21
+ CASE e.authority_kernel_operation
22
+ WHEN 'ontology.recover'
23
+ THEN jsonb_build_object('type', 'ontology.recover', 'recoveryId', e.executor_id)
24
+ ELSE jsonb_build_object('type', 'ontology.indexVectors', 'indexingId', e.executor_id)
25
+ END
26
+ )
27
+ ELSE jsonb_build_object(
28
+ 'type', 'primitive',
29
+ 'kind', e.executor_kind,
30
+ 'id', e.authority_primitive_id,
31
+ 'runId', e.executor_id
32
+ )
33
+ END
34
+ FROM executions e
35
+ WHERE e.project_id = c.project_id AND e.id = c.execution_id;
36
+
37
+ ALTER TABLE ontology_commits ALTER COLUMN executor SET NOT NULL;
38
+
39
+ -- Events not yet published would otherwise reach consumers in the retired shape.
40
+ UPDATE ontology_outbox o SET envelope =
41
+ (o.envelope - 'actor')
42
+ || jsonb_build_object('executor', c.executor)
43
+ || CASE
44
+ WHEN c.requested_by IS NULL THEN '{}'::jsonb
45
+ ELSE jsonb_build_object('requestedBy', c.requested_by)
46
+ END
47
+ FROM ontology_commits c
48
+ WHERE c.project_id = o.project_id AND c.id = o.commit_id;
49
+
50
+ ALTER TABLE ontology_commits DROP COLUMN actor;
@@ -0,0 +1,34 @@
1
+ -- Durable staged-upload sessions, so an upload survives restarts and spans API replicas.
2
+ CREATE TABLE file_upload_sessions (
3
+ id TEXT PRIMARY KEY CHECK (length(trim(id)) > 0),
4
+ project_id TEXT NOT NULL CHECK (length(trim(project_id)) > 0),
5
+ owner_principal_type TEXT NOT NULL
6
+ CHECK (owner_principal_type IN ('user', 'serviceAccount', 'system')),
7
+ owner_principal_id TEXT NOT NULL CHECK (length(owner_principal_id) > 0),
8
+ strategy TEXT NOT NULL CHECK (strategy IN ('server', 'direct-put', 'multipart')),
9
+ status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'aborted')),
10
+ file_name TEXT,
11
+ media_type TEXT,
12
+ logical_path TEXT,
13
+ expected_size_bytes BIGINT CHECK (expected_size_bytes IS NULL OR expected_size_bytes >= 0),
14
+ expected_digest TEXT,
15
+ provider_upload JSONB CHECK (provider_upload IS NULL OR jsonb_typeof(provider_upload) = 'object'),
16
+ signed_parts JSONB NOT NULL CHECK (jsonb_typeof(signed_parts) = 'array'),
17
+ file_ref JSONB CHECK (file_ref IS NULL OR jsonb_typeof(file_ref) = 'object'),
18
+ created_at TIMESTAMPTZ NOT NULL,
19
+ expires_at TIMESTAMPTZ NOT NULL,
20
+ completed_at TIMESTAMPTZ,
21
+ aborted_at TIMESTAMPTZ,
22
+ -- Derived by core (fileUploadSessionReapAt); NULL while an abandoned upload awaits its abort.
23
+ reap_at TIMESTAMPTZ,
24
+ CHECK ((status = 'completed') = (completed_at IS NOT NULL)),
25
+ CHECK ((status = 'aborted') = (aborted_at IS NOT NULL))
26
+ );
27
+
28
+ CREATE INDEX idx_file_upload_sessions_reap
29
+ ON file_upload_sessions (reap_at)
30
+ WHERE reap_at IS NOT NULL;
31
+
32
+ CREATE INDEX idx_file_upload_sessions_abandoned
33
+ ON file_upload_sessions (project_id, expires_at)
34
+ WHERE status = 'pending' AND provider_upload IS NOT NULL;
@@ -0,0 +1,19 @@
1
+ -- Native clients, such as the CLI, hold their session as bearer tokens: a
2
+ -- short-lived access token, whose hash is `token_hash`, and a rotating refresh token. Browser
3
+ -- sessions leave these columns NULL; `refresh_token_hash IS NULL` is what marks a cookie session.
4
+ ALTER TABLE auth_sessions
5
+ ADD COLUMN client_name TEXT,
6
+ ADD COLUMN access_expires_at TIMESTAMPTZ,
7
+ ADD COLUMN refresh_token_hash TEXT,
8
+ ADD COLUMN previous_refresh_token_hash TEXT,
9
+ ADD COLUMN refreshed_at TIMESTAMPTZ,
10
+ ADD CONSTRAINT auth_sessions_bearer_state CHECK (
11
+ (refresh_token_hash IS NULL) = (client_name IS NULL)
12
+ AND (refresh_token_hash IS NULL) = (access_expires_at IS NULL)
13
+ AND (refresh_token_hash IS NOT NULL OR previous_refresh_token_hash IS NULL)
14
+ );
15
+
16
+ -- The device flow now starts a session instead of issuing a personal access token.
17
+ ALTER TABLE auth_device_authorizations
18
+ DROP COLUMN token_name,
19
+ DROP COLUMN token_expires_at;
@@ -0,0 +1,5 @@
1
+ -- A membership the identity provider owns is recorded with source 'directory' and synced to the
2
+ -- provider's groups on every sign-in. Service accounts never sign in, so their table is unchanged.
3
+ ALTER TABLE auth_group_memberships DROP CONSTRAINT auth_group_memberships_source_check;
4
+ ALTER TABLE auth_group_memberships ADD CONSTRAINT auth_group_memberships_source_check
5
+ CHECK (source IN ('invitation', 'manual', 'agent', 'directory'));
package/src/migrations.ts CHANGED
@@ -99,6 +99,16 @@ import vectorBatchingSql from "./migrations/044-vector-batching.sql" with { type
99
99
  import agentThreadSandboxStateSql from "./migrations/045-agent-thread-sandbox-state.sql" with {
100
100
  type: "text",
101
101
  }
102
+ import workflowInterventionPrincipalsSql from "./migrations/046-workflow-intervention-principals.sql" with {
103
+ type: "text",
104
+ }
105
+ import ontologyCommitAttributionSql from "./migrations/047-ontology-commit-attribution.sql" with {
106
+ type: "text",
107
+ }
108
+ import nativeSessionsSql from "./migrations/049-native-sessions.sql" with { type: "text" }
109
+ import directoryGroupMembershipsSql from "./migrations/050-directory-group-memberships.sql" with {
110
+ type: "text",
111
+ }
102
112
  import type { SQL, SQLClient } from "./pg-client"
103
113
 
104
114
  export interface PostgresMigrationContext {
@@ -397,6 +407,11 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
397
407
  pgSql("043-vector-indexing", vectorIndexingSql),
398
408
  pgSql("044-vector-batching", vectorBatchingSql),
399
409
  pgSql("045-agent-thread-sandbox-state", agentThreadSandboxStateSql),
410
+ pgSql("046-workflow-intervention-principals", workflowInterventionPrincipalsSql),
411
+ pgSql("047-ontology-commit-attribution", ontologyCommitAttributionSql),
412
+ pgSql("048-file-upload-sessions", fileUploadSessionsSql),
413
+ pgSql("049-native-sessions", nativeSessionsSql),
414
+ pgSql("050-directory-group-memberships", directoryGroupMembershipsSql),
400
415
  ],
401
416
  })
402
417
 
@@ -420,3 +435,4 @@ import outboxPublicationOrderSql from "./migrations/038-outbox-publication-order
420
435
  import connectorOptionalPkceSql from "./migrations/040-connector-optional-pkce.sql" with {
421
436
  type: "text",
422
437
  }
438
+ import fileUploadSessionsSql from "./migrations/048-file-upload-sessions.sql" with { type: "text" }
@@ -88,6 +88,8 @@ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
88
88
  decimal: { equality: true, ordering: true },
89
89
  date: { equality: true, ordering: true },
90
90
  timestamp: { equality: true, ordering: true },
91
+ // jsonb equality and containment compare both fields of `{ type, id }`, whatever key order.
92
+ userRef: { equality: true },
91
93
  },
92
94
  limits: {
93
95
  totalCount: true,
@@ -1,8 +1,4 @@
1
- import type {
2
- EffectiveChangeCounts,
3
- ExpectedLinkRevision,
4
- ExpectedObjectRevision,
5
- } from "@sixb/core/internal/materialization"
1
+ import type { EffectiveChangeCounts } from "@sixb/core/internal/materialization"
6
2
  import {
7
3
  assertPinnedDatasetWatermark,
8
4
  linkRefKey,
@@ -16,6 +12,9 @@ import {
16
12
  telemetryPointSortKey,
17
13
  } from "@sixb/core/internal/materialization"
18
14
  import {
15
+ assertExpectedLinkRevision,
16
+ assertExpectedLinkScopeRevision,
17
+ assertExpectedObjectRevision,
19
18
  assertMaterializationFinalizationCorrelation,
20
19
  assertMaterializationHeader,
21
20
  assertMaterializationLaneCompletion,
@@ -126,23 +125,21 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
126
125
  true
127
126
  )
128
127
  for (const expected of input.expected.objects) {
129
- this.assertObject(objectRevisions.get(objectRefKey(expected.ref)) ?? null, expected)
128
+ assertExpectedObjectRevision(
129
+ objectRevisions.get(objectRefKey(expected.ref)) ?? null,
130
+ expected
131
+ )
130
132
  }
131
133
  const linkRevisions = await reader.effectiveLinkLastCommits(
132
134
  input.expected.links.map((expected) => expected.ref),
133
135
  true
134
136
  )
135
137
  for (const expected of input.expected.links) {
136
- this.assertLink(linkRevisions.get(linkRefKey(expected.ref)), expected)
138
+ assertExpectedLinkRevision(linkRevisions.get(linkRefKey(expected.ref)), expected)
137
139
  }
138
140
  const linkScopeRevisions = await reader.linkScopeRevisions(input.expected.linkScopes)
139
141
  for (const [index, expected] of input.expected.linkScopes.entries()) {
140
- if (linkScopeRevisions[index]?.fingerprint !== expected.fingerprint) {
141
- throw new MaterializationConflictError(
142
- "effective-state",
143
- `Expected link scope changed for ${expected.source.objectTypeId}:${expected.source.primaryId}.${expected.linkId}.`
144
- )
145
- }
142
+ assertExpectedLinkScopeRevision(linkScopeRevisions[index]?.fingerprint, expected)
146
143
  }
147
144
  const points = await reader.exactPoints(input.expected.points, true)
148
145
  const pointRevisions = new Map(
@@ -514,48 +511,6 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
514
511
  }
515
512
  }
516
513
 
517
- private assertObject(
518
- row: { readonly version: number; readonly lastCommitId: string | null } | null,
519
- expected: ExpectedObjectRevision
520
- ): void {
521
- if (!expected.exists) {
522
- if (row) {
523
- throw new MaterializationConflictError(
524
- "effective-state",
525
- `Expected object ${objectRefKey(expected.ref)} to be absent.`
526
- )
527
- }
528
- return
529
- }
530
- if (!row || row.version !== expected.version || row.lastCommitId !== expected.lastCommitId) {
531
- throw new MaterializationConflictError(
532
- "effective-state",
533
- `Expected object ${objectRefKey(expected.ref)} changed.`
534
- )
535
- }
536
- }
537
-
538
- private assertLink(
539
- lastCommitId: string | null | undefined,
540
- expected: ExpectedLinkRevision
541
- ): void {
542
- if (!expected.exists) {
543
- if (lastCommitId !== undefined) {
544
- throw new MaterializationConflictError(
545
- "effective-state",
546
- `Expected link ${linkRefKey(expected.ref)} to be absent.`
547
- )
548
- }
549
- return
550
- }
551
- if (lastCommitId === undefined || lastCommitId !== expected.lastCommitId) {
552
- throw new MaterializationConflictError(
553
- "effective-state",
554
- `Expected link ${linkRefKey(expected.ref)} changed.`
555
- )
556
- }
557
- }
558
-
559
514
  private async assertFinalization(
560
515
  session: PgMaterializationSessionState,
561
516
  input: FinalizeMaterializationInput
@@ -873,18 +828,20 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
873
828
  ): Promise<OntologyCommitRecord> {
874
829
  const { commit } = header
875
830
  const origin = originColumns(commit.origin)
876
- const actor = commit.actor === undefined ? null : jsonParameter(this.sql, commit.actor)
831
+ const requestedBy =
832
+ commit.requestedBy === undefined ? null : jsonParameter(this.sql, commit.requestedBy)
877
833
  const rows = await this.sql<PgOntologyCommitRow[]>`
878
834
  INSERT INTO ontology_commits (
879
835
  project_id, id, idempotency_key, request_hash, execution_id,
880
- origin_kind, origin_run_id, origin_batch_ordinal, origin, actor,
836
+ origin_kind, origin_run_id, origin_batch_ordinal, origin, requested_by, executor,
881
837
  ontology_revision, projection_revision, ownership_hash,
882
838
  intent, result, committed_at
883
839
  ) VALUES (
884
840
  ${commit.projectId}, ${commit.id}, ${commit.idempotencyKey}, ${commit.requestHash},
885
841
  ${commit.executionId},
886
842
  ${origin.kind}, ${origin.runId}, ${origin.batchOrdinal},
887
- ${jsonParameter(this.sql, commit.origin)}, ${actor}, ${commit.ontologyRevision},
843
+ ${jsonParameter(this.sql, commit.origin)}, ${requestedBy},
844
+ ${jsonParameter(this.sql, commit.executor)}, ${commit.ontologyRevision},
888
845
  ${commit.projectionRevision ?? null}, ${commit.ownershipHash ?? null},
889
846
  ${jsonParameter(this.sql, commit.intent)},
890
847
  ${jsonParameter(this.sql, input.finalization.result)}, ${commit.committedAt}
@@ -39,7 +39,8 @@ export interface PgOntologyCommitRow {
39
39
  readonly origin_run_id: string | null
40
40
  readonly origin_batch_ordinal: number | string | null
41
41
  readonly origin: unknown
42
- readonly actor: unknown | null
42
+ readonly requested_by: unknown | null
43
+ readonly executor: unknown
43
44
  readonly ontology_revision: string
44
45
  readonly projection_revision: string | null
45
46
  readonly ownership_hash: string | null
@@ -187,11 +188,14 @@ export function commitRecord(row: PgOntologyCommitRow): OntologyCommitRecord {
187
188
  requestHash: row.request_hash,
188
189
  executionId: row.execution_id,
189
190
  origin: structuredClone(row.origin) as OntologyCommitWrite["origin"],
190
- ...(row.actor === null
191
+ ...(row.requested_by === null
191
192
  ? {}
192
193
  : {
193
- actor: structuredClone(row.actor) as NonNullable<OntologyCommitWrite["actor"]>,
194
+ requestedBy: structuredClone(row.requested_by) as NonNullable<
195
+ OntologyCommitWrite["requestedBy"]
196
+ >,
194
197
  }),
198
+ executor: structuredClone(row.executor) as OntologyCommitWrite["executor"],
195
199
  ontologyRevision: row.ontology_revision,
196
200
  ...(row.projection_revision === null ? {} : { projectionRevision: row.projection_revision }),
197
201
  ...(row.ownership_hash === null ? {} : { ownershipHash: row.ownership_hash }),
@@ -0,0 +1,143 @@
1
+ import {
2
+ DurableFileUploadSessions,
3
+ type FileUploadSessionPersistence,
4
+ type FileUploadSessionPersistenceBackend,
5
+ fileUploadSessionReapAt,
6
+ parseFileUploadSessionRow,
7
+ } from "@sixb/core/internal/file-upload-session-storage-provider"
8
+ import type { FileUploadSession, ListAbandonedFileUploadSessionsInput } from "@sixb/core/storage"
9
+ import { type PgStoreClient, runPgTransaction } from "./transactions"
10
+
11
+ export class PgFileUploadSessionStorage extends DurableFileUploadSessions {
12
+ constructor(sql: PgStoreClient) {
13
+ super(new PgFileUploadSessionBackend(sql))
14
+ }
15
+ }
16
+
17
+ class PgFileUploadSessionBackend implements FileUploadSessionPersistenceBackend {
18
+ constructor(private readonly sql: PgStoreClient) {}
19
+
20
+ read<T>(run: (persistence: FileUploadSessionPersistence) => Promise<T>): Promise<T> {
21
+ return run(new PgFileUploadSessionPersistence(this.sql, false))
22
+ }
23
+
24
+ transaction<T>(run: (persistence: FileUploadSessionPersistence) => Promise<T>): Promise<T> {
25
+ return runPgTransaction(this.sql, (tx) => run(new PgFileUploadSessionPersistence(tx, true)))
26
+ }
27
+ }
28
+
29
+ class PgFileUploadSessionPersistence implements FileUploadSessionPersistence {
30
+ constructor(
31
+ private readonly sql: PgStoreClient,
32
+ private readonly locking: boolean
33
+ ) {}
34
+
35
+ async now(): Promise<Date> {
36
+ const [row] = await this.sql<{ readonly now: Date }[]>`SELECT clock_timestamp() AS now`
37
+ if (!row) throw new Error("[SixbPg] Database clock returned no row.")
38
+ return new Date(row.now)
39
+ }
40
+
41
+ async insert(session: FileUploadSession): Promise<boolean> {
42
+ // A unique violation would abort the surrounding transaction; report the conflict instead.
43
+ const rows = await this.sql`
44
+ INSERT INTO file_upload_sessions (
45
+ id, project_id, owner_principal_type, owner_principal_id, strategy, status, file_name,
46
+ media_type, logical_path, expected_size_bytes, expected_digest, provider_upload,
47
+ signed_parts, file_ref, created_at, expires_at, completed_at, aborted_at, reap_at
48
+ ) VALUES (
49
+ ${session.id}, ${session.projectId}, ${session.principal.type}, ${session.principal.id},
50
+ ${session.strategy}, ${session.status}, ${session.fileName ?? null}, ${session.mediaType ?? null},
51
+ ${session.logicalPath ?? null}, ${session.expectedSizeBytes ?? null},
52
+ ${session.expectedDigest ?? null}, ${jsonText(session.providerUpload)}::text::jsonb,
53
+ ${JSON.stringify(session.signedParts)}::text::jsonb, ${jsonText(session.fileRef)}::text::jsonb,
54
+ ${session.createdAt}, ${session.expiresAt}, ${session.completedAt ?? null},
55
+ ${session.abortedAt ?? null}, ${fileUploadSessionReapAt(session)}
56
+ )
57
+ ON CONFLICT (id) DO NOTHING
58
+ RETURNING id
59
+ `
60
+ return rows.length === 1
61
+ }
62
+
63
+ async get(uploadId: string): Promise<FileUploadSession | null> {
64
+ const [row] = this.locking
65
+ ? await this.sql<PgFileUploadSessionRow[]>`
66
+ SELECT * FROM file_upload_sessions WHERE id = ${uploadId} FOR UPDATE
67
+ `
68
+ : await this.sql<PgFileUploadSessionRow[]>`
69
+ SELECT * FROM file_upload_sessions WHERE id = ${uploadId}
70
+ `
71
+ return row ? sessionFromRow(row) : null
72
+ }
73
+
74
+ async update(session: FileUploadSession): Promise<void> {
75
+ await this.sql`
76
+ UPDATE file_upload_sessions
77
+ SET status = ${session.status},
78
+ provider_upload = ${jsonText(session.providerUpload)}::text::jsonb,
79
+ signed_parts = ${JSON.stringify(session.signedParts)}::text::jsonb,
80
+ file_ref = ${jsonText(session.fileRef)}::text::jsonb,
81
+ created_at = ${session.createdAt},
82
+ expires_at = ${session.expiresAt},
83
+ completed_at = ${session.completedAt ?? null},
84
+ aborted_at = ${session.abortedAt ?? null},
85
+ reap_at = ${fileUploadSessionReapAt(session)}
86
+ WHERE id = ${session.id}
87
+ `
88
+ }
89
+
90
+ async listAbandoned(
91
+ input: ListAbandonedFileUploadSessionsInput
92
+ ): Promise<readonly FileUploadSession[]> {
93
+ const rows = await this.sql<PgFileUploadSessionRow[]>`
94
+ SELECT * FROM file_upload_sessions
95
+ WHERE project_id = ${input.projectId} AND status = 'pending'
96
+ AND provider_upload IS NOT NULL AND expires_at <= ${input.now}
97
+ ORDER BY expires_at, id COLLATE "C"
98
+ LIMIT ${input.limit}
99
+ `
100
+ return rows.map(sessionFromRow)
101
+ }
102
+
103
+ async deleteReapable(now: Date): Promise<number> {
104
+ const rows = await this.sql`
105
+ DELETE FROM file_upload_sessions
106
+ WHERE reap_at IS NOT NULL AND reap_at <= ${now}
107
+ RETURNING id
108
+ `
109
+ return rows.length
110
+ }
111
+ }
112
+
113
+ interface PgFileUploadSessionRow {
114
+ readonly [column: string]: unknown
115
+ }
116
+
117
+ function sessionFromRow(row: PgFileUploadSessionRow): FileUploadSession {
118
+ return parseFileUploadSessionRow({
119
+ id: row.id,
120
+ projectId: row.project_id,
121
+ ownerPrincipalType: row.owner_principal_type,
122
+ ownerPrincipalId: row.owner_principal_id,
123
+ strategy: row.strategy,
124
+ status: row.status,
125
+ fileName: row.file_name,
126
+ mediaType: row.media_type,
127
+ logicalPath: row.logical_path,
128
+ expectedSizeBytes: row.expected_size_bytes,
129
+ expectedDigest: row.expected_digest,
130
+ providerUpload: row.provider_upload,
131
+ signedParts: row.signed_parts,
132
+ fileRef: row.file_ref,
133
+ createdAt: row.created_at,
134
+ expiresAt: row.expires_at,
135
+ completedAt: row.completed_at,
136
+ abortedAt: row.aborted_at,
137
+ })
138
+ }
139
+
140
+ /** JSON text for a nullable jsonb column; always bind with the `::text::jsonb` double cast. */
141
+ function jsonText(value: object | undefined): string | null {
142
+ return value === undefined ? null : JSON.stringify(value)
143
+ }
@@ -5,8 +5,8 @@ import type {
5
5
  ExpireWorkflowInterventionInput,
6
6
  ListWorkflowInterventionsInput,
7
7
  ListWorkflowInterventionsResult,
8
+ Principal,
8
9
  SubmitWorkflowInterventionInput,
9
- WorkflowInterventionActor,
10
10
  WorkflowInterventionRecord,
11
11
  WorkflowInterventionStorage,
12
12
  } from "@sixb/core/storage"
@@ -78,7 +78,7 @@ export class PgWorkflowInterventionStorage implements WorkflowInterventionStorag
78
78
  SET
79
79
  status = ${"submitted"},
80
80
  submitted_at = ${input.submittedAt ?? new Date()},
81
- submitted_by = ${serializeActor(input.submittedBy)}::text::jsonb,
81
+ submitted_by = ${serializePrincipal(input.submittedBy)}::text::jsonb,
82
82
  response = ${serializeRecord(input.response)}::text::jsonb
83
83
  WHERE project_id = ${input.projectId} AND id = ${input.id}
84
84
  RETURNING *
@@ -97,7 +97,7 @@ export class PgWorkflowInterventionStorage implements WorkflowInterventionStorag
97
97
  SET
98
98
  status = ${"cancelled"},
99
99
  cancelled_at = ${input.cancelledAt ?? new Date()},
100
- cancelled_by = ${serializeActor(input.cancelledBy)}::text::jsonb
100
+ cancelled_by = ${serializePrincipal(input.cancelledBy)}::text::jsonb
101
101
  WHERE project_id = ${input.projectId} AND id = ${input.id}
102
102
  RETURNING *
103
103
  `
@@ -268,16 +268,12 @@ function parseRecord(value: WorkflowIOSnapshot | string): WorkflowIOSnapshot {
268
268
  return typeof value === "string" ? (JSON.parse(value) as WorkflowIOSnapshot) : value
269
269
  }
270
270
 
271
- function serializeActor(actor: WorkflowInterventionActor | undefined): string | null {
272
- return actor ? JSON.stringify(actor) : null
271
+ function serializePrincipal(principal: Principal | undefined): string | null {
272
+ return principal ? JSON.stringify(principal) : null
273
273
  }
274
274
 
275
- function parseActor(
276
- value: WorkflowInterventionActor | string | null
277
- ): WorkflowInterventionActor | undefined {
278
- return typeof value === "string"
279
- ? (JSON.parse(value) as WorkflowInterventionActor)
280
- : (value ?? undefined)
275
+ function parsePrincipal(value: Principal | string | null): Principal | undefined {
276
+ return typeof value === "string" ? (JSON.parse(value) as Principal) : (value ?? undefined)
281
277
  }
282
278
 
283
279
  function rowToWorkflowInterventionRecord(
@@ -299,10 +295,10 @@ function rowToWorkflowInterventionRecord(
299
295
  requestedAt: new Date(row.requested_at),
300
296
  expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
301
297
  submittedAt: row.submitted_at ? new Date(row.submitted_at) : undefined,
302
- submittedBy: parseActor(row.submitted_by),
298
+ submittedBy: parsePrincipal(row.submitted_by),
303
299
  response: row.response ? parseRecord(row.response) : undefined,
304
300
  cancelledAt: row.cancelled_at ? new Date(row.cancelled_at) : undefined,
305
- cancelledBy: parseActor(row.cancelled_by),
301
+ cancelledBy: parsePrincipal(row.cancelled_by),
306
302
  expiredAt: row.expired_at ? new Date(row.expired_at) : undefined,
307
303
  }
308
304
  }
@@ -331,9 +327,9 @@ interface WorkflowInterventionDatabaseRow {
331
327
  requested_at: Date | string
332
328
  expires_at: Date | string | null
333
329
  submitted_at: Date | string | null
334
- submitted_by: WorkflowInterventionActor | string | null
330
+ submitted_by: Principal | string | null
335
331
  response: WorkflowIOSnapshot | string | null
336
332
  cancelled_at: Date | string | null
337
- cancelled_by: WorkflowInterventionActor | string | null
333
+ cancelled_by: Principal | string | null
338
334
  expired_at: Date | string | null
339
335
  }