graphile-bucket-provisioner-plugin 1.18.0 → 1.19.0

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/README.md CHANGED
@@ -5,7 +5,7 @@ queueing storage reconciliation.
5
5
 
6
6
  The constructive-db `storage:provision_bucket` job is the only component that
7
7
  mints physical S3 bucket names, provisions S3, and records `physical_name`.
8
- This plugin resolves the logical bucket under RLS and enqueues that job; it
8
+ This plugin resolves the logical bucket under RLS and requests that job; it
9
9
  does not call S3 or derive a physical name.
10
10
 
11
11
  ## Installation
@@ -32,16 +32,18 @@ mutation {
32
32
  bucketId
33
33
  bucketKey
34
34
  physicalName
35
- jobId
35
+ provisionRequestedAt
36
36
  }
37
37
  }
38
38
  ```
39
39
 
40
- `provisionBucket` resolves the bucket row under RLS and enqueues the same
41
- `storage:provision_bucket` job used by the database INSERT trigger. Database
42
- scope jobs include `database_id`, use the `bucket:<uuid>` queue, and have
43
- 25 attempts. Platform scope jobs use the platform trigger payload. Enqueue
44
- failures are returned as GraphQL errors.
40
+ `provisionBucket` resolves the bucket row under RLS and calls the storage
41
+ module's `<buckets>_provision(bucket_id)`, which stamps the row's
42
+ `provision_requested_at` as the caller and returns the row. The bucket's UPDATE
43
+ job trigger then enqueues the same `storage:provision_bucket` job used on
44
+ INSERT, keyed `storage:provision_bucket:<uuid>` on the `bucket:<uuid>` queue, so
45
+ repeated requests while a job is waiting collapse into it. Failures are
46
+ returned as GraphQL errors.
45
47
 
46
48
  The payload fields are:
47
49
 
@@ -50,7 +52,7 @@ The payload fields are:
50
52
  | `bucketId` | Logical bucket row ID |
51
53
  | `bucketKey` | Logical bucket key |
52
54
  | `physicalName` | Recorded physical name, or `null` while reconciliation is pending |
53
- | `jobId` | ID of the queued reconciler job |
55
+ | `provisionRequestedAt` | When reconciliation was last requested |
54
56
 
55
57
  There are no plugin options: S3 connection details, naming, CORS, versioning,
56
58
  and physical-name recording belong to the reconciler.
package/esm/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides an explicit `provisionBucket` reconciliation enqueue mutation for
4
+ * Provides an explicit `provisionBucket` reconciliation request mutation for
5
5
  * PostGraphile v5.
6
6
  */
7
7
  export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
package/esm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides an explicit `provisionBucket` reconciliation enqueue mutation for
4
+ * Provides an explicit `provisionBucket` reconciliation request mutation for
5
5
  * PostGraphile v5.
6
6
  */
7
7
  export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
package/esm/plugin.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Adds S3 bucket provisioning support to PostGraphile v5:
5
5
  *
6
- * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a
7
- * logical bucket row in the database. Reads the bucket config via RLS,
8
- * then queues the same storage reconciler job used by the INSERT trigger.
6
+ * 1. `provisionBucket` mutation — explicitly request reconciliation for a
7
+ * logical bucket row in the database. Stamps the bucket's
8
+ * `provision_requested_at` via RLS; the bucket's own job trigger then
9
+ * queues the same storage reconciler job used on INSERT.
9
10
  *
10
11
  * Detection: Uses the `@storageBuckets` smart tag on the codec (table).
11
12
  * The storage module generator in constructive-db sets this tag on the
@@ -19,7 +20,7 @@ import type { GraphileConfig } from 'graphile-config';
19
20
  * This plugin provides one reconciliation pathway:
20
21
  *
21
22
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
22
- * bucket key to enqueue reconciliation (or re-reconciliation) for the
23
+ * bucket key to request reconciliation (or re-reconciliation) for the
23
24
  * bucket. Protected by RLS on the buckets table.
24
25
  *
25
26
  */
package/esm/plugin.js CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Adds S3 bucket provisioning support to PostGraphile v5:
5
5
  *
6
- * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a
7
- * logical bucket row in the database. Reads the bucket config via RLS,
8
- * then queues the same storage reconciler job used by the INSERT trigger.
6
+ * 1. `provisionBucket` mutation — explicitly request reconciliation for a
7
+ * logical bucket row in the database. Stamps the bucket's
8
+ * `provision_requested_at` via RLS; the bucket's own job trigger then
9
+ * queues the same storage reconciler job used on INSERT.
9
10
  *
10
11
  * Detection: Uses the `@storageBuckets` smart tag on the codec (table).
11
12
  * The storage module generator in constructive-db sets this tag on the
@@ -21,24 +22,7 @@ import { extendSchema, gql } from 'graphile-utils';
21
22
  * resolution in the explicit provisionBucket mutation).
22
23
  */
23
24
  const ALL_STORAGE_MODULES_QUERY = `
24
- SELECT
25
- sm.id,
26
- sm.database_id,
27
- sm.buckets_table_id,
28
- sm.scope,
29
- sm.entity_field,
30
- sm.entity_table_id,
31
- bs.schema_name AS buckets_schema,
32
- bt.name AS buckets_table,
33
- sm.allowed_origins,
34
- es.schema_name AS entity_schema,
35
- et.name AS entity_table
36
- FROM metaschema_modules_public.storage_module sm
37
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
38
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
39
- LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
40
- LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
41
- WHERE sm.database_id = $1
25
+ SELECT * FROM function_resolution.storage_modules()
42
26
  `;
43
27
  /**
44
28
  * Run a query against a grafast `withPgClient` client.
@@ -73,7 +57,7 @@ function scopeKeyColumn(storageModule) {
73
57
  * silently picking.
74
58
  */
75
59
  async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
76
- const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
60
+ const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY);
77
61
  const modules = result.rows;
78
62
  if (modules.length === 0) {
79
63
  throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
@@ -124,86 +108,22 @@ async function resolveDatabaseId(pgClient) {
124
108
  const result = await runQuery(pgClient, `SELECT jwt_private.current_database_id() AS id`);
125
109
  return result.rows[0]?.id ?? null;
126
110
  }
127
- async function resolveEntityContext(pgClient, storageModule) {
128
- const result = await runQuery(pgClient, `SELECT r.entity_type, r.get_org_fn_schema, r.get_org_fn
129
- FROM metaschema.resolve_entity_context_by_field($1::uuid, $2::uuid, $3::text) r`, [storageModule.database_id, storageModule.buckets_table_id, storageModule.entity_field]);
130
- return result.rows[0] ?? {
131
- entity_type: null,
132
- get_org_fn_schema: null,
133
- get_org_fn: null,
134
- };
135
- }
136
111
  /**
137
- * Mirror the storage module generator's data_job_trigger shape from
138
- * `packages/metaschema-generators/deploy/schemas/metaschema_generators/procedures/storage_module.sql`
139
- * and the add_job argument assembly in
140
- * `packages/ast-plpgsql/deploy/schemas/ast_plpgsql_helpers/procedures/triggers/job_trigger.sql`.
141
- * A callable enqueue function beside the trigger would be the durable fix for
142
- * this deliberate mirroring, but is out of scope here.
143
- *
144
- * The three scope shapes differ only in the payload entries and the identity
145
- * arguments, so those are the only parts a caller supplies — the identifier,
146
- * queue, retry, and priority policy exist exactly once.
112
+ * Request reconciliation through the storage module's generated
113
+ * `<private>.<buckets>_provision(bucket_id)`. It runs as the request role and
114
+ * stamps the bucket's `provision_requested_at` under the buckets table's own
115
+ * RLS; the bucket's UPDATE job trigger then enqueues the reconciler (keyed and
116
+ * queued on the bucket, so repeated requests collapse into one pending job).
117
+ * It returns the updated bucket row.
147
118
  */
148
- function addJobStatement(payloadEntries, identityArgs) {
149
- return `SELECT (app_jobs.add_job(
150
- identifier => 'storage:provision_bucket',
151
- payload => json_build_object(${payloadEntries}),
152
- queue_name => 'bucket:' || $1::text,
153
- max_attempts => 25,
154
- priority => 0${identityArgs}
155
- )).id AS id`;
156
- }
157
- async function enqueueReconciliationJob(pgClient, storageModule, bucket) {
158
- const scope = storageModule.scope;
159
- const entityField = storageModule.entity_field;
160
- let text;
161
- let values;
162
- if (entityField === null) {
163
- text = addJobStatement(`'id', $1::uuid,
164
- 'scope', $2::text`, '');
165
- values = [bucket.id, scope];
166
- }
167
- else if (entityField === 'database_id') {
168
- const databaseId = bucket.scope_key;
169
- if (!databaseId) {
170
- throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no database_id`);
171
- }
172
- text = addJobStatement(`'database_id', $2::uuid,
173
- 'id', $1::uuid,
174
- 'scope', $3::text`, `,
175
- db_id => $2,
176
- entity_id => $2,
177
- organization_id => NULL,
178
- entity_type => $3`);
179
- values = [bucket.id, databaseId, scope];
180
- }
181
- else if (entityField === 'owner_id') {
182
- const ownerId = bucket.scope_key;
183
- if (!ownerId) {
184
- throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no owner_id`);
185
- }
186
- const context = await resolveEntityContext(pgClient, storageModule);
187
- const orgFunction = context.get_org_fn_schema && context.get_org_fn
188
- ? `${QuoteUtils.quoteQualifiedIdentifier(context.get_org_fn_schema, context.get_org_fn)}($3::text, $2::uuid)`
189
- : 'NULL';
190
- text = addJobStatement(`'id', $1::uuid,
191
- 'owner_id', $2::uuid,
192
- 'scope', $3::text`, `,
193
- entity_id => $2,
194
- organization_id => ${orgFunction},
195
- entity_type => $3`);
196
- values = [bucket.id, ownerId, scope];
197
- }
198
- else {
199
- throw new Error(`STORAGE_BUCKET_ENTITY_FIELD_UNSUPPORTED: ${entityField}`);
200
- }
201
- const result = await runQuery(pgClient, text, values);
202
- const jobId = result.rows[0]?.id;
203
- if (!jobId) {
204
- throw new Error(`STORAGE_BUCKET_JOB_ID_MISSING: bucket ${bucket.id}`);
119
+ async function requestReconciliation(pgClient, storageModule, bucket) {
120
+ const provisionFunction = QuoteUtils.quoteQualifiedIdentifier(storageModule.private_schema, `${storageModule.buckets_table}_provision`);
121
+ const result = await runQuery(pgClient, `SELECT id, key, physical_name, to_json(provision_requested_at) #>> '{}' AS provision_requested_at FROM ${provisionFunction}($1::uuid)`, [bucket.id]);
122
+ const row = result.rows[0];
123
+ if (!row?.id) {
124
+ throw new Error(`STORAGE_BUCKET_PROVISION_EMPTY: bucket ${bucket.id}`);
205
125
  }
206
- return jobId;
126
+ return row;
207
127
  }
208
128
  // --- Plugin factory ---
209
129
  /**
@@ -212,7 +132,7 @@ async function enqueueReconciliationJob(pgClient, storageModule, bucket) {
212
132
  * This plugin provides one reconciliation pathway:
213
133
  *
214
134
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
215
- * bucket key to enqueue reconciliation (or re-reconciliation) for the
135
+ * bucket key to request reconciliation (or re-reconciliation) for the
216
136
  * bucket. Protected by RLS on the buckets table.
217
137
  *
218
138
  */
@@ -231,22 +151,23 @@ export function createBucketProvisionerPlugin() {
231
151
  }
232
152
 
233
153
  type ProvisionBucketPayload {
234
- """The logical bucket row that was queued for reconciliation."""
154
+ """The logical bucket row whose reconciliation was requested."""
235
155
  bucketId: UUID!
236
156
  bucketKey: String!
237
157
  """The physical bucket name already recorded, or null when reconciliation has not completed."""
238
158
  physicalName: String
239
- """The reconciler job enqueued to provision this bucket."""
240
- jobId: UUID!
159
+ """When reconciliation was last requested for this bucket."""
160
+ provisionRequestedAt: Datetime!
241
161
  }
242
162
 
243
163
  extend type Mutation {
244
164
  """
245
165
  Reconcile an S3 bucket for a logical bucket in the database.
246
- Reads the bucket config via RLS, then enqueues the same
247
- storage:provision_bucket job used by the INSERT trigger. This is
248
- idempotent for an already-reconciled bucket; enqueue failures become
249
- GraphQL errors.
166
+ Records the request on the bucket row via RLS; the bucket's job
167
+ trigger then enqueues the same storage:provision_bucket job used on
168
+ INSERT, and repeated requests collapse into one pending job. This is
169
+ idempotent for an already-reconciled bucket; failures become GraphQL
170
+ errors.
250
171
  """
251
172
  provisionBucket(
252
173
  input: ProvisionBucketInput!
@@ -282,12 +203,12 @@ export function createBucketProvisionerPlugin() {
282
203
  throw new Error('BUCKET_NOT_FOUND');
283
204
  }
284
205
  const { storageModule, bucket } = resolution;
285
- const jobId = await enqueueReconciliationJob(pgClient, storageModule, bucket);
206
+ const provisioned = await requestReconciliation(pgClient, storageModule, bucket);
286
207
  return {
287
- bucketId: bucket.id,
288
- bucketKey: bucket.key,
289
- physicalName: bucket.physical_name,
290
- jobId,
208
+ bucketId: provisioned.id,
209
+ bucketKey: provisioned.key,
210
+ physicalName: provisioned.physical_name,
211
+ provisionRequestedAt: provisioned.provision_requested_at,
291
212
  };
292
213
  });
293
214
  });
package/esm/types.d.ts CHANGED
@@ -14,15 +14,15 @@ export interface ProvisionBucketInput {
14
14
  ownerId?: string;
15
15
  }
16
16
  /**
17
- * Result of the provisionBucket reconciliation enqueue mutation.
17
+ * Result of the provisionBucket mutation: the bucket row it stamped.
18
18
  */
19
19
  export interface ProvisionBucketPayload {
20
- /** The logical bucket row queued for reconciliation */
20
+ /** The logical bucket row whose reconciliation was requested */
21
21
  bucketId: string;
22
22
  /** The logical bucket key */
23
23
  bucketKey: string;
24
24
  /** The physical name already recorded, or null while reconciliation is pending */
25
25
  physicalName: string | null;
26
- /** The queued reconciler job */
27
- jobId: string;
26
+ /** When reconciliation was last requested */
27
+ provisionRequestedAt: string;
28
28
  }
package/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides an explicit `provisionBucket` reconciliation enqueue mutation for
4
+ * Provides an explicit `provisionBucket` reconciliation request mutation for
5
5
  * PostGraphile v5.
6
6
  */
7
7
  export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Bucket Provisioner Plugin for PostGraphile v5
4
4
  *
5
- * Provides an explicit `provisionBucket` reconciliation enqueue mutation for
5
+ * Provides an explicit `provisionBucket` reconciliation request mutation for
6
6
  * PostGraphile v5.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Bucket provisioning plugin for PostGraphile v5 — explicitly provisions S3 buckets via a GraphQL mutation",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -58,5 +58,5 @@
58
58
  "@types/node": "^22.19.11",
59
59
  "makage": "^0.8.0"
60
60
  },
61
- "gitHead": "487c4ec9ee997d7da80584a3d41d7120d4354c54"
61
+ "gitHead": "b6c80da2ba0532a4aaf0d0154901efa7911974d8"
62
62
  }
package/plugin.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Adds S3 bucket provisioning support to PostGraphile v5:
5
5
  *
6
- * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a
7
- * logical bucket row in the database. Reads the bucket config via RLS,
8
- * then queues the same storage reconciler job used by the INSERT trigger.
6
+ * 1. `provisionBucket` mutation — explicitly request reconciliation for a
7
+ * logical bucket row in the database. Stamps the bucket's
8
+ * `provision_requested_at` via RLS; the bucket's own job trigger then
9
+ * queues the same storage reconciler job used on INSERT.
9
10
  *
10
11
  * Detection: Uses the `@storageBuckets` smart tag on the codec (table).
11
12
  * The storage module generator in constructive-db sets this tag on the
@@ -19,7 +20,7 @@ import type { GraphileConfig } from 'graphile-config';
19
20
  * This plugin provides one reconciliation pathway:
20
21
  *
21
22
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
22
- * bucket key to enqueue reconciliation (or re-reconciliation) for the
23
+ * bucket key to request reconciliation (or re-reconciliation) for the
23
24
  * bucket. Protected by RLS on the buckets table.
24
25
  *
25
26
  */
package/plugin.js CHANGED
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * Adds S3 bucket provisioning support to PostGraphile v5:
6
6
  *
7
- * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a
8
- * logical bucket row in the database. Reads the bucket config via RLS,
9
- * then queues the same storage reconciler job used by the INSERT trigger.
7
+ * 1. `provisionBucket` mutation — explicitly request reconciliation for a
8
+ * logical bucket row in the database. Stamps the bucket's
9
+ * `provision_requested_at` via RLS; the bucket's own job trigger then
10
+ * queues the same storage reconciler job used on INSERT.
10
11
  *
11
12
  * Detection: Uses the `@storageBuckets` smart tag on the codec (table).
12
13
  * The storage module generator in constructive-db sets this tag on the
@@ -25,24 +26,7 @@ const graphile_utils_1 = require("graphile-utils");
25
26
  * resolution in the explicit provisionBucket mutation).
26
27
  */
27
28
  const ALL_STORAGE_MODULES_QUERY = `
28
- SELECT
29
- sm.id,
30
- sm.database_id,
31
- sm.buckets_table_id,
32
- sm.scope,
33
- sm.entity_field,
34
- sm.entity_table_id,
35
- bs.schema_name AS buckets_schema,
36
- bt.name AS buckets_table,
37
- sm.allowed_origins,
38
- es.schema_name AS entity_schema,
39
- et.name AS entity_table
40
- FROM metaschema_modules_public.storage_module sm
41
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
42
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
43
- LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
44
- LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
45
- WHERE sm.database_id = $1
29
+ SELECT * FROM function_resolution.storage_modules()
46
30
  `;
47
31
  /**
48
32
  * Run a query against a grafast `withPgClient` client.
@@ -77,7 +61,7 @@ function scopeKeyColumn(storageModule) {
77
61
  * silently picking.
78
62
  */
79
63
  async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
80
- const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
64
+ const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY);
81
65
  const modules = result.rows;
82
66
  if (modules.length === 0) {
83
67
  throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
@@ -128,86 +112,22 @@ async function resolveDatabaseId(pgClient) {
128
112
  const result = await runQuery(pgClient, `SELECT jwt_private.current_database_id() AS id`);
129
113
  return result.rows[0]?.id ?? null;
130
114
  }
131
- async function resolveEntityContext(pgClient, storageModule) {
132
- const result = await runQuery(pgClient, `SELECT r.entity_type, r.get_org_fn_schema, r.get_org_fn
133
- FROM metaschema.resolve_entity_context_by_field($1::uuid, $2::uuid, $3::text) r`, [storageModule.database_id, storageModule.buckets_table_id, storageModule.entity_field]);
134
- return result.rows[0] ?? {
135
- entity_type: null,
136
- get_org_fn_schema: null,
137
- get_org_fn: null,
138
- };
139
- }
140
115
  /**
141
- * Mirror the storage module generator's data_job_trigger shape from
142
- * `packages/metaschema-generators/deploy/schemas/metaschema_generators/procedures/storage_module.sql`
143
- * and the add_job argument assembly in
144
- * `packages/ast-plpgsql/deploy/schemas/ast_plpgsql_helpers/procedures/triggers/job_trigger.sql`.
145
- * A callable enqueue function beside the trigger would be the durable fix for
146
- * this deliberate mirroring, but is out of scope here.
147
- *
148
- * The three scope shapes differ only in the payload entries and the identity
149
- * arguments, so those are the only parts a caller supplies — the identifier,
150
- * queue, retry, and priority policy exist exactly once.
116
+ * Request reconciliation through the storage module's generated
117
+ * `<private>.<buckets>_provision(bucket_id)`. It runs as the request role and
118
+ * stamps the bucket's `provision_requested_at` under the buckets table's own
119
+ * RLS; the bucket's UPDATE job trigger then enqueues the reconciler (keyed and
120
+ * queued on the bucket, so repeated requests collapse into one pending job).
121
+ * It returns the updated bucket row.
151
122
  */
152
- function addJobStatement(payloadEntries, identityArgs) {
153
- return `SELECT (app_jobs.add_job(
154
- identifier => 'storage:provision_bucket',
155
- payload => json_build_object(${payloadEntries}),
156
- queue_name => 'bucket:' || $1::text,
157
- max_attempts => 25,
158
- priority => 0${identityArgs}
159
- )).id AS id`;
160
- }
161
- async function enqueueReconciliationJob(pgClient, storageModule, bucket) {
162
- const scope = storageModule.scope;
163
- const entityField = storageModule.entity_field;
164
- let text;
165
- let values;
166
- if (entityField === null) {
167
- text = addJobStatement(`'id', $1::uuid,
168
- 'scope', $2::text`, '');
169
- values = [bucket.id, scope];
170
- }
171
- else if (entityField === 'database_id') {
172
- const databaseId = bucket.scope_key;
173
- if (!databaseId) {
174
- throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no database_id`);
175
- }
176
- text = addJobStatement(`'database_id', $2::uuid,
177
- 'id', $1::uuid,
178
- 'scope', $3::text`, `,
179
- db_id => $2,
180
- entity_id => $2,
181
- organization_id => NULL,
182
- entity_type => $3`);
183
- values = [bucket.id, databaseId, scope];
184
- }
185
- else if (entityField === 'owner_id') {
186
- const ownerId = bucket.scope_key;
187
- if (!ownerId) {
188
- throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no owner_id`);
189
- }
190
- const context = await resolveEntityContext(pgClient, storageModule);
191
- const orgFunction = context.get_org_fn_schema && context.get_org_fn
192
- ? `${quotes_1.QuoteUtils.quoteQualifiedIdentifier(context.get_org_fn_schema, context.get_org_fn)}($3::text, $2::uuid)`
193
- : 'NULL';
194
- text = addJobStatement(`'id', $1::uuid,
195
- 'owner_id', $2::uuid,
196
- 'scope', $3::text`, `,
197
- entity_id => $2,
198
- organization_id => ${orgFunction},
199
- entity_type => $3`);
200
- values = [bucket.id, ownerId, scope];
201
- }
202
- else {
203
- throw new Error(`STORAGE_BUCKET_ENTITY_FIELD_UNSUPPORTED: ${entityField}`);
204
- }
205
- const result = await runQuery(pgClient, text, values);
206
- const jobId = result.rows[0]?.id;
207
- if (!jobId) {
208
- throw new Error(`STORAGE_BUCKET_JOB_ID_MISSING: bucket ${bucket.id}`);
123
+ async function requestReconciliation(pgClient, storageModule, bucket) {
124
+ const provisionFunction = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.private_schema, `${storageModule.buckets_table}_provision`);
125
+ const result = await runQuery(pgClient, `SELECT id, key, physical_name, to_json(provision_requested_at) #>> '{}' AS provision_requested_at FROM ${provisionFunction}($1::uuid)`, [bucket.id]);
126
+ const row = result.rows[0];
127
+ if (!row?.id) {
128
+ throw new Error(`STORAGE_BUCKET_PROVISION_EMPTY: bucket ${bucket.id}`);
209
129
  }
210
- return jobId;
130
+ return row;
211
131
  }
212
132
  // --- Plugin factory ---
213
133
  /**
@@ -216,7 +136,7 @@ async function enqueueReconciliationJob(pgClient, storageModule, bucket) {
216
136
  * This plugin provides one reconciliation pathway:
217
137
  *
218
138
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
219
- * bucket key to enqueue reconciliation (or re-reconciliation) for the
139
+ * bucket key to request reconciliation (or re-reconciliation) for the
220
140
  * bucket. Protected by RLS on the buckets table.
221
141
  *
222
142
  */
@@ -235,22 +155,23 @@ function createBucketProvisionerPlugin() {
235
155
  }
236
156
 
237
157
  type ProvisionBucketPayload {
238
- """The logical bucket row that was queued for reconciliation."""
158
+ """The logical bucket row whose reconciliation was requested."""
239
159
  bucketId: UUID!
240
160
  bucketKey: String!
241
161
  """The physical bucket name already recorded, or null when reconciliation has not completed."""
242
162
  physicalName: String
243
- """The reconciler job enqueued to provision this bucket."""
244
- jobId: UUID!
163
+ """When reconciliation was last requested for this bucket."""
164
+ provisionRequestedAt: Datetime!
245
165
  }
246
166
 
247
167
  extend type Mutation {
248
168
  """
249
169
  Reconcile an S3 bucket for a logical bucket in the database.
250
- Reads the bucket config via RLS, then enqueues the same
251
- storage:provision_bucket job used by the INSERT trigger. This is
252
- idempotent for an already-reconciled bucket; enqueue failures become
253
- GraphQL errors.
170
+ Records the request on the bucket row via RLS; the bucket's job
171
+ trigger then enqueues the same storage:provision_bucket job used on
172
+ INSERT, and repeated requests collapse into one pending job. This is
173
+ idempotent for an already-reconciled bucket; failures become GraphQL
174
+ errors.
254
175
  """
255
176
  provisionBucket(
256
177
  input: ProvisionBucketInput!
@@ -286,12 +207,12 @@ function createBucketProvisionerPlugin() {
286
207
  throw new Error('BUCKET_NOT_FOUND');
287
208
  }
288
209
  const { storageModule, bucket } = resolution;
289
- const jobId = await enqueueReconciliationJob(pgClient, storageModule, bucket);
210
+ const provisioned = await requestReconciliation(pgClient, storageModule, bucket);
290
211
  return {
291
- bucketId: bucket.id,
292
- bucketKey: bucket.key,
293
- physicalName: bucket.physical_name,
294
- jobId,
212
+ bucketId: provisioned.id,
213
+ bucketKey: provisioned.key,
214
+ physicalName: provisioned.physical_name,
215
+ provisionRequestedAt: provisioned.provision_requested_at,
295
216
  };
296
217
  });
297
218
  });
package/types.d.ts CHANGED
@@ -14,15 +14,15 @@ export interface ProvisionBucketInput {
14
14
  ownerId?: string;
15
15
  }
16
16
  /**
17
- * Result of the provisionBucket reconciliation enqueue mutation.
17
+ * Result of the provisionBucket mutation: the bucket row it stamped.
18
18
  */
19
19
  export interface ProvisionBucketPayload {
20
- /** The logical bucket row queued for reconciliation */
20
+ /** The logical bucket row whose reconciliation was requested */
21
21
  bucketId: string;
22
22
  /** The logical bucket key */
23
23
  bucketKey: string;
24
24
  /** The physical name already recorded, or null while reconciliation is pending */
25
25
  physicalName: string | null;
26
- /** The queued reconciler job */
27
- jobId: string;
26
+ /** When reconciliation was last requested */
27
+ provisionRequestedAt: string;
28
28
  }