@adcp/sdk 14.0.0-beta.12 → 14.0.0-beta.13
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/lib/index.d.mts +3 -3
- package/dist/lib/index.d.ts +3 -3
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/index.mjs +3 -1
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/create-adcp-server.d.mts +2 -2
- package/dist/lib/server/create-adcp-server.d.ts +2 -2
- package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
- package/dist/lib/server/create-adcp-server.js +6 -1
- package/dist/lib/server/create-adcp-server.js.map +1 -1
- package/dist/lib/server/create-adcp-server.mjs +6 -1
- package/dist/lib/server/create-adcp-server.mjs.map +1 -1
- package/dist/lib/server/decisioning/index.d.mts +3 -3
- package/dist/lib/server/decisioning/index.d.ts +3 -3
- package/dist/lib/server/decisioning/index.d.ts.map +1 -1
- package/dist/lib/server/decisioning/index.js.map +1 -1
- package/dist/lib/server/decisioning/index.mjs.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.d.mts +24 -17
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts +24 -17
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +71 -42
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.mjs +75 -43
- package/dist/lib/server/decisioning/runtime/from-platform.mjs.map +1 -1
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.d.mts +15 -21
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.d.ts +15 -21
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +100 -30
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +100 -30
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs.map +1 -1
- package/dist/lib/server/decisioning/runtime/task-registry.d.mts +30 -16
- package/dist/lib/server/decisioning/runtime/task-registry.d.ts +30 -16
- package/dist/lib/server/decisioning/runtime/task-registry.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/task-registry.js +40 -20
- package/dist/lib/server/decisioning/runtime/task-registry.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/task-registry.mjs +38 -19
- package/dist/lib/server/decisioning/runtime/task-registry.mjs.map +1 -1
- package/dist/lib/server/decisioning/runtime/to-context.d.mts +2 -2
- package/dist/lib/server/decisioning/runtime/to-context.d.ts +2 -2
- package/dist/lib/server/decisioning/runtime/to-context.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/to-context.js +2 -2
- package/dist/lib/server/decisioning/runtime/to-context.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/to-context.mjs +2 -2
- package/dist/lib/server/decisioning/runtime/to-context.mjs.map +1 -1
- package/dist/lib/server/decisioning/specialisms/creative-ad-server.d.mts +3 -2
- package/dist/lib/server/decisioning/specialisms/creative-ad-server.d.ts +3 -2
- package/dist/lib/server/decisioning/specialisms/creative-ad-server.d.ts.map +1 -1
- package/dist/lib/server/decisioning/specialisms/creative-ad-server.js.map +1 -1
- package/dist/lib/server/decisioning/specialisms/creative.d.mts +10 -10
- package/dist/lib/server/decisioning/specialisms/creative.d.ts +10 -10
- package/dist/lib/server/decisioning/specialisms/creative.d.ts.map +1 -1
- package/dist/lib/server/decisioning/specialisms/creative.js.map +1 -1
- package/dist/lib/server/index.d.mts +1 -1
- package/dist/lib/server/index.d.ts +1 -1
- package/dist/lib/server/index.d.ts.map +1 -1
- package/dist/lib/server/index.js.map +1 -1
- package/dist/lib/server/index.mjs.map +1 -1
- package/dist/lib/testing/storyboard/default-invariants.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/default-invariants.js +11 -2
- package/dist/lib/testing/storyboard/default-invariants.js.map +1 -1
- package/dist/lib/testing/storyboard/default-invariants.mjs +11 -2
- package/dist/lib/testing/storyboard/default-invariants.mjs.map +1 -1
- package/dist/lib/testing/storyboard/index.d.mts +1 -1
- package/dist/lib/testing/storyboard/index.d.ts +1 -1
- package/dist/lib/testing/storyboard/index.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/index.js.map +1 -1
- package/dist/lib/testing/storyboard/index.mjs.map +1 -1
- package/dist/lib/testing/storyboard/loader.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/loader.js +14 -0
- package/dist/lib/testing/storyboard/loader.js.map +1 -1
- package/dist/lib/testing/storyboard/loader.mjs +14 -0
- package/dist/lib/testing/storyboard/loader.mjs.map +1 -1
- package/dist/lib/testing/storyboard/phase-condition.d.mts +41 -0
- package/dist/lib/testing/storyboard/phase-condition.d.ts +42 -0
- package/dist/lib/testing/storyboard/phase-condition.d.ts.map +1 -0
- package/dist/lib/testing/storyboard/phase-condition.js +215 -0
- package/dist/lib/testing/storyboard/phase-condition.js.map +1 -0
- package/dist/lib/testing/storyboard/phase-condition.mjs +189 -0
- package/dist/lib/testing/storyboard/phase-condition.mjs.map +1 -0
- package/dist/lib/testing/storyboard/request-builder.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/request-builder.js +15 -2
- package/dist/lib/testing/storyboard/request-builder.js.map +1 -1
- package/dist/lib/testing/storyboard/request-builder.mjs +15 -2
- package/dist/lib/testing/storyboard/request-builder.mjs.map +1 -1
- package/dist/lib/testing/storyboard/runner.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/runner.js +57 -23
- package/dist/lib/testing/storyboard/runner.js.map +1 -1
- package/dist/lib/testing/storyboard/runner.mjs +57 -23
- package/dist/lib/testing/storyboard/runner.mjs.map +1 -1
- package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/task-map.js +6 -2
- package/dist/lib/testing/storyboard/task-map.js.map +1 -1
- package/dist/lib/testing/storyboard/task-map.mjs +6 -2
- package/dist/lib/testing/storyboard/task-map.mjs.map +1 -1
- package/dist/lib/testing/storyboard/types.d.mts +5 -4
- package/dist/lib/testing/storyboard/types.d.ts +5 -4
- package/dist/lib/testing/storyboard/types.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/types.js.map +1 -1
- package/dist/lib/testing/storyboard/types.mjs.map +1 -1
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/testing/storyboard/webhook-assertions.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/webhook-assertions.js +60 -4
- package/dist/lib/testing/storyboard/webhook-assertions.js.map +1 -1
- package/dist/lib/testing/storyboard/webhook-assertions.mjs +60 -4
- package/dist/lib/testing/storyboard/webhook-assertions.mjs.map +1 -1
- package/dist/lib/testing/storyboard/webhook-receiver.d.mts +15 -0
- package/dist/lib/testing/storyboard/webhook-receiver.d.ts +15 -0
- package/dist/lib/testing/storyboard/webhook-receiver.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/webhook-receiver.js +57 -6
- package/dist/lib/testing/storyboard/webhook-receiver.js.map +1 -1
- package/dist/lib/testing/storyboard/webhook-receiver.mjs +57 -6
- package/dist/lib/testing/storyboard/webhook-receiver.mjs.map +1 -1
- package/dist/lib/testing/types.d.mts +2 -0
- package/dist/lib/testing/types.d.ts +2 -0
- package/dist/lib/testing/types.d.ts.map +1 -1
- package/dist/lib/testing/types.js.map +1 -1
- package/dist/lib/types/core.generated.d.mts +5 -2
- package/dist/lib/types/core.generated.d.ts +5 -2
- package/dist/lib/types/core.generated.d.ts.map +1 -1
- package/dist/lib/types/core.generated.js.map +1 -1
- package/dist/lib/types/index.d.mts +1 -1
- package/dist/lib/types/index.d.ts +1 -1
- package/dist/lib/types/index.d.ts.map +1 -1
- package/dist/lib/types/index.js.map +1 -1
- package/dist/lib/types/index.mjs.map +1 -1
- package/dist/lib/types/list-transformers.d.ts +9 -5
- package/dist/lib/types/schemas.generated.d.ts +21 -298
- package/dist/lib/types/schemas.generated.d.ts.map +1 -1
- package/dist/lib/types/schemas.generated.js +5 -2
- package/dist/lib/types/schemas.generated.js.map +1 -1
- package/dist/lib/types/schemas.generated.mjs +4 -2
- package/dist/lib/types/schemas.generated.mjs.map +1 -1
- package/dist/lib/types/server-payload-aliases.d.mts +2 -1
- package/dist/lib/types/server-payload-aliases.d.ts +2 -1
- package/dist/lib/types/server-payload-aliases.d.ts.map +1 -1
- package/dist/lib/types/server-payload-aliases.js.map +1 -1
- package/dist/lib/types/tools.generated.d.mts +5 -2
- package/dist/lib/types/tools.generated.d.ts +5 -2
- package/dist/lib/types/tools.generated.d.ts.map +1 -1
- package/dist/lib/types/tools.generated.js.map +1 -1
- package/dist/lib/utils/build-creative-return-builders.d.mts +5 -1
- package/dist/lib/utils/build-creative-return-builders.d.ts +5 -1
- package/dist/lib/utils/build-creative-return-builders.d.ts.map +1 -1
- package/dist/lib/utils/build-creative-return-builders.js +9 -3
- package/dist/lib/utils/build-creative-return-builders.js.map +1 -1
- package/dist/lib/utils/build-creative-return-builders.mjs +7 -2
- package/dist/lib/utils/build-creative-return-builders.mjs.map +1 -1
- package/dist/lib/utils/response-unwrapper.js +1 -1
- package/dist/lib/utils/response-unwrapper.js.map +1 -1
- package/dist/lib/utils/response-unwrapper.mjs +1 -1
- package/dist/lib/utils/response-unwrapper.mjs.map +1 -1
- package/dist/lib/validation/schema-loader.d.ts.map +1 -1
- package/dist/lib/validation/schema-loader.js +16 -7
- package/dist/lib/validation/schema-loader.js.map +1 -1
- package/dist/lib/validation/schema-loader.mjs +16 -7
- package/dist/lib/validation/schema-loader.mjs.map +1 -1
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/version.mjs +3 -3
- package/dist/lib/version.mjs.map +1 -1
- package/docs/llms.txt +2 -2
- package/docs/migration-5.x-to-6.x.md +3 -2
- package/docs/migration-task-registry-scoping.md +61 -0
- package/examples/hello_seller_adapter_guaranteed.ts +1 -1
- package/package.json +4 -4
- package/skills/build-decisioning-creative-template/SKILL.md +2 -2
- package/skills/build-decisioning-platform/SKILL.md +4 -2
- package/skills/build-decisioning-platform/advanced/REFERENCE.md +7 -6
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* restart and doesn't share state across instances behind a load balancer —
|
|
6
6
|
* fine for tests and local dev, broken for production HITL paths
|
|
7
7
|
* (`createMediaBuyTask`, `syncCreativesTask`, etc.). Wire this in via
|
|
8
|
-
* `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool }) })`
|
|
8
|
+
* `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool, namespace }) })`
|
|
9
9
|
* to persist task lifecycle across requests, processes, and crashes.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
*
|
|
20
20
|
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* const taskRegistryNamespace = 'tenant:my-agent';
|
|
23
|
+
* // Run once at boot, using the same trusted namespace as the registry.
|
|
24
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ namespace: taskRegistryNamespace }));
|
|
24
25
|
*
|
|
25
26
|
* const server = createAdcpServerFromPlatform(platform, {
|
|
26
27
|
* name: 'My Ad Network',
|
|
27
28
|
* version: '1.0.0',
|
|
28
|
-
* taskRegistry: createPostgresTaskRegistry({ pool }),
|
|
29
|
+
* taskRegistry: createPostgresTaskRegistry({ pool, namespace: taskRegistryNamespace }),
|
|
29
30
|
* });
|
|
30
31
|
* ```
|
|
31
32
|
*
|
|
@@ -59,6 +60,8 @@ export interface PgQueryable {
|
|
|
59
60
|
export interface CreatePostgresTaskRegistryOptions {
|
|
60
61
|
/** A `pg.Pool` instance (or any `PgQueryable`). */
|
|
61
62
|
pool: PgQueryable;
|
|
63
|
+
/** Trusted deployment/tenant namespace. Use a distinct value per hosted tenant. */
|
|
64
|
+
namespace: string;
|
|
62
65
|
/**
|
|
63
66
|
* Table name. Defaults to `'adcp_decisioning_tasks'` (vendor-prefixed to
|
|
64
67
|
* avoid collisions with the MCP-level `adcp_mcp_tasks` table from
|
|
@@ -80,12 +83,13 @@ export interface CreatePostgresTaskRegistryOptions {
|
|
|
80
83
|
* @example
|
|
81
84
|
* ```typescript
|
|
82
85
|
* import { getDecisioningTaskRegistryMigration } from '@adcp/sdk/server/decisioning';
|
|
83
|
-
* await pool.query(getDecisioningTaskRegistryMigration());
|
|
84
|
-
* await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks' }));
|
|
86
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ namespace: tenantId }));
|
|
87
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks', namespace: tenantId }));
|
|
85
88
|
* ```
|
|
86
89
|
*/
|
|
87
|
-
export declare function getDecisioningTaskRegistryMigration(options
|
|
90
|
+
export declare function getDecisioningTaskRegistryMigration(options: {
|
|
88
91
|
tableName?: string;
|
|
92
|
+
namespace: string;
|
|
89
93
|
}): string;
|
|
90
94
|
/**
|
|
91
95
|
* Build a Postgres-backed `TaskRegistry`.
|
|
@@ -95,19 +99,9 @@ export declare function getDecisioningTaskRegistryMigration(options?: {
|
|
|
95
99
|
* is enforced via SQL `WHERE status = 'submitted'` predicates so concurrent
|
|
96
100
|
* webhook deliveries can't race to overwrite each other.
|
|
97
101
|
*
|
|
98
|
-
* **Multi-tenant deployments
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* resolve a buyer to literal `account_id: 'acme'`, their tasks land in
|
|
103
|
-
* the same row keyspace and `tasks_get` can't distinguish ownership
|
|
104
|
-
* by `account_id` alone (the tenant boundary upstream IS protected by
|
|
105
|
-
* `accounts.resolve(ref, ctx)` returning each tenant's own Account, but
|
|
106
|
-
* the registry sees the same `acme` string in both rows). Recommended
|
|
107
|
-
* pattern: adopters' `accounts.resolve` returns
|
|
108
|
-
* `id: \`tenant_${tenantId}_${accountId}\`` so the table-level keys
|
|
109
|
-
* stay distinct. Alternative: separate `tableName` per tenant, one
|
|
110
|
-
* `createPostgresTaskRegistry({ pool, tableName })` per `TenantRegistry`
|
|
111
|
-
* tenant — heavier ops, stronger isolation.
|
|
102
|
+
* **Multi-tenant deployments.** `namespace` is a trusted deployment/tenant
|
|
103
|
+
* partition and participates in every query plus the primary key. Construct
|
|
104
|
+
* one registry per tenant with a stable unique namespace; never derive it
|
|
105
|
+
* from buyer request parameters. Tenants may safely share the same table.
|
|
112
106
|
*/
|
|
113
107
|
export declare function createPostgresTaskRegistry(opts: CreatePostgresTaskRegistryOptions): TaskRegistry;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* restart and doesn't share state across instances behind a load balancer —
|
|
6
6
|
* fine for tests and local dev, broken for production HITL paths
|
|
7
7
|
* (`createMediaBuyTask`, `syncCreativesTask`, etc.). Wire this in via
|
|
8
|
-
* `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool }) })`
|
|
8
|
+
* `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool, namespace }) })`
|
|
9
9
|
* to persist task lifecycle across requests, processes, and crashes.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
*
|
|
20
20
|
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* const taskRegistryNamespace = 'tenant:my-agent';
|
|
23
|
+
* // Run once at boot, using the same trusted namespace as the registry.
|
|
24
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ namespace: taskRegistryNamespace }));
|
|
24
25
|
*
|
|
25
26
|
* const server = createAdcpServerFromPlatform(platform, {
|
|
26
27
|
* name: 'My Ad Network',
|
|
27
28
|
* version: '1.0.0',
|
|
28
|
-
* taskRegistry: createPostgresTaskRegistry({ pool }),
|
|
29
|
+
* taskRegistry: createPostgresTaskRegistry({ pool, namespace: taskRegistryNamespace }),
|
|
29
30
|
* });
|
|
30
31
|
* ```
|
|
31
32
|
*
|
|
@@ -59,6 +60,8 @@ export interface PgQueryable {
|
|
|
59
60
|
export interface CreatePostgresTaskRegistryOptions {
|
|
60
61
|
/** A `pg.Pool` instance (or any `PgQueryable`). */
|
|
61
62
|
pool: PgQueryable;
|
|
63
|
+
/** Trusted deployment/tenant namespace. Use a distinct value per hosted tenant. */
|
|
64
|
+
namespace: string;
|
|
62
65
|
/**
|
|
63
66
|
* Table name. Defaults to `'adcp_decisioning_tasks'` (vendor-prefixed to
|
|
64
67
|
* avoid collisions with the MCP-level `adcp_mcp_tasks` table from
|
|
@@ -80,12 +83,13 @@ export interface CreatePostgresTaskRegistryOptions {
|
|
|
80
83
|
* @example
|
|
81
84
|
* ```typescript
|
|
82
85
|
* import { getDecisioningTaskRegistryMigration } from '@adcp/sdk/server/decisioning';
|
|
83
|
-
* await pool.query(getDecisioningTaskRegistryMigration());
|
|
84
|
-
* await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks' }));
|
|
86
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ namespace: tenantId }));
|
|
87
|
+
* await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks', namespace: tenantId }));
|
|
85
88
|
* ```
|
|
86
89
|
*/
|
|
87
|
-
export declare function getDecisioningTaskRegistryMigration(options
|
|
90
|
+
export declare function getDecisioningTaskRegistryMigration(options: {
|
|
88
91
|
tableName?: string;
|
|
92
|
+
namespace: string;
|
|
89
93
|
}): string;
|
|
90
94
|
/**
|
|
91
95
|
* Build a Postgres-backed `TaskRegistry`.
|
|
@@ -95,20 +99,10 @@ export declare function getDecisioningTaskRegistryMigration(options?: {
|
|
|
95
99
|
* is enforced via SQL `WHERE status = 'submitted'` predicates so concurrent
|
|
96
100
|
* webhook deliveries can't race to overwrite each other.
|
|
97
101
|
*
|
|
98
|
-
* **Multi-tenant deployments
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* resolve a buyer to literal `account_id: 'acme'`, their tasks land in
|
|
103
|
-
* the same row keyspace and `tasks_get` can't distinguish ownership
|
|
104
|
-
* by `account_id` alone (the tenant boundary upstream IS protected by
|
|
105
|
-
* `accounts.resolve(ref, ctx)` returning each tenant's own Account, but
|
|
106
|
-
* the registry sees the same `acme` string in both rows). Recommended
|
|
107
|
-
* pattern: adopters' `accounts.resolve` returns
|
|
108
|
-
* `id: \`tenant_${tenantId}_${accountId}\`` so the table-level keys
|
|
109
|
-
* stay distinct. Alternative: separate `tableName` per tenant, one
|
|
110
|
-
* `createPostgresTaskRegistry({ pool, tableName })` per `TenantRegistry`
|
|
111
|
-
* tenant — heavier ops, stronger isolation.
|
|
102
|
+
* **Multi-tenant deployments.** `namespace` is a trusted deployment/tenant
|
|
103
|
+
* partition and participates in every query plus the primary key. Construct
|
|
104
|
+
* one registry per tenant with a stable unique namespace; never derive it
|
|
105
|
+
* from buyer request parameters. Tenants may safely share the same table.
|
|
112
106
|
*/
|
|
113
107
|
export declare function createPostgresTaskRegistry(opts: CreatePostgresTaskRegistryOptions): TaskRegistry;
|
|
114
108
|
//# sourceMappingURL=postgres-task-registry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-task-registry.d.ts","sourceRoot":"","sources":["../../../../../src/lib/server/decisioning/runtime/postgres-task-registry.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"postgres-task-registry.d.ts","sourceRoot":"","sources":["../../../../../src/lib/server/decisioning/runtime/postgres-task-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAIH,OAAO,KAAK,EAAc,YAAY,EAAiC,MAAM,iBAAiB,CAAC;AAE/F;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CAChH;AAED,MAAM,WAAW,iCAAiC;IAChD,mDAAmD;IACnD,IAAI,EAAE,WAAW,CAAC;IAClB,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAqED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mCAAmC,CAAC,OAAO,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAoF9G;AAkCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,iCAAiC,GAAG,YAAY,CAsIhG"}
|
|
@@ -25,10 +25,18 @@ module.exports = __toCommonJS(postgres_task_registry_exports);
|
|
|
25
25
|
var import_node_crypto = require("node:crypto");
|
|
26
26
|
const DEFAULT_TABLE = "adcp_decisioning_tasks";
|
|
27
27
|
const VALID_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
28
|
+
const VALID_NAMESPACE = /^[A-Za-z0-9_.:-]{1,255}$/;
|
|
28
29
|
function assertValidIdentifier(name) {
|
|
29
|
-
if (!VALID_IDENTIFIER.test(name)) {
|
|
30
|
+
if (!VALID_IDENTIFIER.test(name) || Buffer.byteLength(name, "utf8") > 40) {
|
|
30
31
|
throw new Error(
|
|
31
|
-
`Invalid table name "${name}": must be lowercase letters, digits, or underscores, starting with a letter or underscore.`
|
|
32
|
+
`Invalid table name "${name}": must be lowercase letters, digits, or underscores, starting with a letter or underscore, and at most 40 bytes.`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function assertValidNamespace(namespace) {
|
|
37
|
+
if (typeof namespace !== "string" || !VALID_NAMESPACE.test(namespace)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Invalid registry namespace: namespace is required and must be 1-255 ASCII letters, digits, dots, underscores, colons, or hyphens."
|
|
32
40
|
);
|
|
33
41
|
}
|
|
34
42
|
}
|
|
@@ -51,14 +59,17 @@ function safeStringify(value, taskId) {
|
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
function getDecisioningTaskRegistryMigration(options) {
|
|
62
|
+
assertValidNamespace(options?.namespace);
|
|
54
63
|
const table = options?.tableName ?? DEFAULT_TABLE;
|
|
55
64
|
assertValidIdentifier(table);
|
|
65
|
+
const namespace = options.namespace;
|
|
56
66
|
return `
|
|
57
67
|
CREATE TABLE IF NOT EXISTS ${table} (
|
|
58
|
-
|
|
68
|
+
registry_namespace TEXT NOT NULL DEFAULT '${namespace}',
|
|
69
|
+
task_id TEXT NOT NULL,
|
|
59
70
|
tool TEXT NOT NULL,
|
|
60
71
|
account_id TEXT NOT NULL,
|
|
61
|
-
owner_scope TEXT,
|
|
72
|
+
owner_scope TEXT NOT NULL,
|
|
62
73
|
status TEXT NOT NULL DEFAULT 'submitted',
|
|
63
74
|
status_message TEXT,
|
|
64
75
|
result JSONB,
|
|
@@ -89,6 +100,45 @@ CREATE INDEX IF NOT EXISTS idx_${table}_status_created
|
|
|
89
100
|
ALTER TABLE ${table}
|
|
90
101
|
ADD COLUMN IF NOT EXISTS owner_scope TEXT;
|
|
91
102
|
|
|
103
|
+
ALTER TABLE ${table}
|
|
104
|
+
ADD COLUMN IF NOT EXISTS registry_namespace TEXT NOT NULL DEFAULT '__adcp_legacy_unscoped__';
|
|
105
|
+
|
|
106
|
+
UPDATE ${table}
|
|
107
|
+
SET registry_namespace = '${namespace}'
|
|
108
|
+
WHERE registry_namespace = '__adcp_legacy_unscoped__';
|
|
109
|
+
|
|
110
|
+
ALTER TABLE ${table}
|
|
111
|
+
ALTER COLUMN registry_namespace SET DEFAULT '${namespace}';
|
|
112
|
+
|
|
113
|
+
UPDATE ${table}
|
|
114
|
+
SET owner_scope = 'account:' || account_id
|
|
115
|
+
WHERE owner_scope IS NULL;
|
|
116
|
+
|
|
117
|
+
ALTER TABLE ${table}
|
|
118
|
+
ALTER COLUMN owner_scope SET NOT NULL;
|
|
119
|
+
|
|
120
|
+
DO $$
|
|
121
|
+
DECLARE
|
|
122
|
+
current_primary_key TEXT;
|
|
123
|
+
BEGIN
|
|
124
|
+
PERFORM pg_advisory_xact_lock(hashtext('adcp-task-registry:${table}'));
|
|
125
|
+
SELECT conname INTO current_primary_key
|
|
126
|
+
FROM pg_constraint
|
|
127
|
+
WHERE conrelid = '${table}'::regclass AND contype = 'p';
|
|
128
|
+
IF current_primary_key IS NOT NULL AND current_primary_key <> '${table}_scope_pkey' THEN
|
|
129
|
+
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', '${table}', current_primary_key);
|
|
130
|
+
END IF;
|
|
131
|
+
IF NOT EXISTS (
|
|
132
|
+
SELECT 1 FROM pg_constraint
|
|
133
|
+
WHERE conname = '${table}_scope_pkey'
|
|
134
|
+
AND conrelid = '${table}'::regclass
|
|
135
|
+
) THEN
|
|
136
|
+
ALTER TABLE ${table}
|
|
137
|
+
ADD CONSTRAINT ${table}_scope_pkey
|
|
138
|
+
PRIMARY KEY (registry_namespace, account_id, owner_scope, task_id);
|
|
139
|
+
END IF;
|
|
140
|
+
END $$;
|
|
141
|
+
|
|
92
142
|
CREATE INDEX IF NOT EXISTS idx_${table}_owner_account
|
|
93
143
|
ON ${table}(owner_scope, account_id);
|
|
94
144
|
`.trim();
|
|
@@ -113,18 +163,22 @@ function createPostgresTaskRegistry(opts) {
|
|
|
113
163
|
const table = opts.tableName ?? DEFAULT_TABLE;
|
|
114
164
|
assertValidIdentifier(table);
|
|
115
165
|
const pool = opts.pool;
|
|
166
|
+
const namespace = opts.namespace;
|
|
167
|
+
assertValidNamespace(namespace);
|
|
116
168
|
const backgrounds = /* @__PURE__ */ new Map();
|
|
117
169
|
return {
|
|
170
|
+
scopeVersion: 1,
|
|
118
171
|
async create(createOpts) {
|
|
119
172
|
const taskId = createOpts.overrideTaskId ?? `task_${(0, import_node_crypto.randomUUID)()}`;
|
|
120
173
|
const result = await pool.query(
|
|
121
|
-
`INSERT INTO ${table} (task_id, tool, account_id, owner_scope, status, has_webhook) VALUES ($1, $2, $3, $4, 'submitted', $5) ON CONFLICT (task_id) DO NOTHING`,
|
|
174
|
+
`INSERT INTO ${table} (task_id, tool, account_id, owner_scope, status, has_webhook, registry_namespace) VALUES ($1, $2, $3, $4, 'submitted', $5, $6) ON CONFLICT (registry_namespace, account_id, owner_scope, task_id) DO NOTHING`,
|
|
122
175
|
[
|
|
123
176
|
taskId,
|
|
124
177
|
createOpts.tool,
|
|
125
178
|
createOpts.accountId,
|
|
126
179
|
createOpts.ownerScope ?? `account:${createOpts.accountId}`,
|
|
127
|
-
createOpts.hasWebhook === true
|
|
180
|
+
createOpts.hasWebhook === true,
|
|
181
|
+
namespace
|
|
128
182
|
]
|
|
129
183
|
);
|
|
130
184
|
if ((result.rowCount ?? 0) === 0) {
|
|
@@ -132,73 +186,89 @@ function createPostgresTaskRegistry(opts) {
|
|
|
132
186
|
}
|
|
133
187
|
return { taskId };
|
|
134
188
|
},
|
|
135
|
-
async getTask(taskId) {
|
|
189
|
+
async getTask(taskId, scope) {
|
|
136
190
|
const { rows } = await pool.query(
|
|
137
191
|
`SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at
|
|
138
|
-
FROM ${table} WHERE task_id = $1`,
|
|
139
|
-
[taskId]
|
|
192
|
+
FROM ${table} WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
|
|
193
|
+
[taskId, namespace, scope.accountId, scope.ownerScope]
|
|
140
194
|
);
|
|
141
195
|
if (rows.length === 0) return null;
|
|
142
196
|
return rowToRecord(rows[0]);
|
|
143
197
|
},
|
|
198
|
+
async _getTaskUnsafe(taskId) {
|
|
199
|
+
const { rows } = await pool.query(
|
|
200
|
+
`SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at
|
|
201
|
+
FROM ${table} WHERE task_id = $1 AND registry_namespace = $2 LIMIT 2`,
|
|
202
|
+
[taskId, namespace]
|
|
203
|
+
);
|
|
204
|
+
if (rows.length !== 1) return null;
|
|
205
|
+
return rowToRecord(rows[0]);
|
|
206
|
+
},
|
|
144
207
|
async list(listOpts) {
|
|
145
|
-
if (listOpts.ownerScope === void 0) return { tasks: [] };
|
|
146
208
|
const { rows } = await pool.query(
|
|
147
209
|
`SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at
|
|
148
210
|
FROM ${table}
|
|
149
|
-
WHERE
|
|
211
|
+
WHERE registry_namespace = $1 AND account_id = $2 AND owner_scope = $3
|
|
150
212
|
ORDER BY created_at DESC, task_id DESC`,
|
|
151
|
-
[listOpts.accountId, listOpts.ownerScope
|
|
213
|
+
[namespace, listOpts.accountId, listOpts.ownerScope]
|
|
152
214
|
);
|
|
153
215
|
return { tasks: rows.map((row) => rowToRecord(row)) };
|
|
154
216
|
},
|
|
155
|
-
async complete(taskId, result) {
|
|
217
|
+
async complete(taskId, scope, result) {
|
|
156
218
|
const json = safeStringify(result, taskId);
|
|
157
219
|
assertResultSize(json, taskId);
|
|
158
220
|
await pool.query(
|
|
159
221
|
`UPDATE ${table}
|
|
160
|
-
SET status = 'completed', result = $
|
|
161
|
-
WHERE task_id = $1 AND
|
|
162
|
-
|
|
222
|
+
SET status = 'completed', result = $5::jsonb, updated_at = NOW()
|
|
223
|
+
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4
|
|
224
|
+
AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,
|
|
225
|
+
[taskId, namespace, scope.accountId, scope.ownerScope, json]
|
|
163
226
|
);
|
|
164
227
|
},
|
|
165
|
-
async fail(taskId, error, result) {
|
|
228
|
+
async fail(taskId, scope, error, result) {
|
|
166
229
|
const errorJson = safeStringify(error, taskId);
|
|
167
230
|
const resultJson = result === void 0 ? void 0 : safeStringify(result, taskId);
|
|
168
231
|
assertResultSize(errorJson, taskId);
|
|
169
232
|
if (resultJson !== void 0) assertResultSize(resultJson, taskId);
|
|
170
233
|
await pool.query(
|
|
171
234
|
`UPDATE ${table}
|
|
172
|
-
SET status = 'failed', error = $
|
|
173
|
-
WHERE task_id = $1 AND
|
|
174
|
-
|
|
235
|
+
SET status = 'failed', error = $5::jsonb, result = $6::jsonb, status_message = $7, updated_at = NOW()
|
|
236
|
+
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4
|
|
237
|
+
AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,
|
|
238
|
+
[taskId, namespace, scope.accountId, scope.ownerScope, errorJson, resultJson ?? null, error.message]
|
|
175
239
|
);
|
|
176
240
|
},
|
|
177
|
-
async updateProgress(taskId, progress) {
|
|
241
|
+
async updateProgress(taskId, scope, progress) {
|
|
178
242
|
const json = safeStringify(progress, taskId);
|
|
179
243
|
await pool.query(
|
|
180
244
|
`UPDATE ${table}
|
|
181
|
-
SET progress = $
|
|
245
|
+
SET progress = $5::jsonb,
|
|
182
246
|
status = CASE WHEN status = 'submitted' THEN 'working' ELSE status END,
|
|
183
247
|
updated_at = NOW()
|
|
184
|
-
WHERE task_id = $1 AND
|
|
185
|
-
|
|
248
|
+
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4
|
|
249
|
+
AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,
|
|
250
|
+
[taskId, namespace, scope.accountId, scope.ownerScope, json]
|
|
186
251
|
);
|
|
187
252
|
},
|
|
188
|
-
_registerBackground(taskId, completion) {
|
|
253
|
+
_registerBackground(taskId, scope, completion) {
|
|
254
|
+
const backgroundKey = JSON.stringify([scope.accountId, scope.ownerScope, taskId]);
|
|
189
255
|
const composed = completion.then(
|
|
190
256
|
() => {
|
|
191
|
-
if (backgrounds.get(
|
|
257
|
+
if (backgrounds.get(backgroundKey) === composed) backgrounds.delete(backgroundKey);
|
|
192
258
|
},
|
|
193
259
|
() => {
|
|
194
|
-
if (backgrounds.get(
|
|
260
|
+
if (backgrounds.get(backgroundKey) === composed) backgrounds.delete(backgroundKey);
|
|
195
261
|
}
|
|
196
262
|
);
|
|
197
|
-
backgrounds.set(
|
|
263
|
+
backgrounds.set(backgroundKey, composed);
|
|
198
264
|
},
|
|
199
|
-
async awaitTask(taskId) {
|
|
200
|
-
const pending = backgrounds.get(taskId);
|
|
265
|
+
async awaitTask(taskId, scope) {
|
|
266
|
+
const pending = backgrounds.get(JSON.stringify([scope.accountId, scope.ownerScope, taskId]));
|
|
201
267
|
if (pending) await pending;
|
|
268
|
+
},
|
|
269
|
+
async _awaitTaskUnsafe(taskId) {
|
|
270
|
+
const pending = Array.from(backgrounds.entries()).filter(([key]) => JSON.parse(key)[2] === taskId).map(([, completion]) => completion);
|
|
271
|
+
await Promise.all(pending);
|
|
202
272
|
}
|
|
203
273
|
};
|
|
204
274
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../src/lib/server/decisioning/runtime/postgres-task-registry.ts"],"sourcesContent":["/**\n * PostgreSQL-backed `TaskRegistry` for the v6.0 decisioning runtime.\n *\n * The default `createInMemoryTaskRegistry()` loses task state on process\n * restart and doesn't share state across instances behind a load balancer —\n * fine for tests and local dev, broken for production HITL paths\n * (`createMediaBuyTask`, `syncCreativesTask`, etc.). Wire this in via\n * `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool }) })`\n * to persist task lifecycle across requests, processes, and crashes.\n *\n * @example\n * ```typescript\n * import { Pool } from 'pg';\n * import {\n * createAdcpServerFromPlatform,\n * createPostgresTaskRegistry,\n * getDecisioningTaskRegistryMigration,\n * } from '@adcp/sdk/server/decisioning';\n *\n * const pool = new Pool({ connectionString: process.env.DATABASE_URL });\n *\n * // Run once at boot — idempotent CREATE TABLE IF NOT EXISTS.\n * await pool.query(getDecisioningTaskRegistryMigration());\n *\n * const server = createAdcpServerFromPlatform(platform, {\n * name: 'My Ad Network',\n * version: '1.0.0',\n * taskRegistry: createPostgresTaskRegistry({ pool }),\n * });\n * ```\n *\n * **Background-completion lifecycle.** `_registerBackground` / `awaitTask`\n * are PROCESS-LOCAL — promises don't serialize. When a HITL `*Task` method\n * is invoked, the returned promise lives on the originating process; if\n * that process restarts before the method completes, the task record stays\n * in `submitted` state in Postgres but no `awaitTask` resolution is\n * possible from a different instance. Production HITL flows that span\n * process boundaries should drive completion via webhook → an explicit\n * `complete()` / `fail()` call from the webhook handler, not via\n * `awaitTask`. The MCP `tasks/get` wire path reads via `getTask` and is\n * already cross-instance.\n *\n * Status: Preview / 6.0.\n *\n * @public\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { AdcpStructuredError, TaskHandoffProgress } from '../async-outcome';\nimport type { TaskRecord, TaskRegistry, TaskStatus } from './task-registry';\n\n/**\n * Minimal subset of the `pg.Pool` interface used by the registry.\n * Mirrors `PgQueryable` in `postgres-task-store.ts` so callers can pass\n * pools, transaction wrappers, or any pg-compatible query executor.\n */\nexport interface PgQueryable {\n query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount: number | null }>;\n}\n\nexport interface CreatePostgresTaskRegistryOptions {\n /** A `pg.Pool` instance (or any `PgQueryable`). */\n pool: PgQueryable;\n /**\n * Table name. Defaults to `'adcp_decisioning_tasks'` (vendor-prefixed to\n * avoid collisions with the MCP-level `adcp_mcp_tasks` table from\n * `PostgresTaskStore` and any consumer tables).\n *\n * Must match `^[a-z_][a-z0-9_]*$` — schema-qualified names are not\n * supported by this validator. Set search_path on the pool if you need\n * a non-default schema.\n */\n tableName?: string;\n}\n\n/** Default table name — vendor-prefixed to avoid collisions. */\nconst DEFAULT_TABLE = 'adcp_decisioning_tasks';\n\n/** Validates a SQL identifier to prevent injection via table names. */\nconst VALID_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;\n\nfunction assertValidIdentifier(name: string): void {\n if (!VALID_IDENTIFIER.test(name)) {\n throw new Error(\n `Invalid table name \"${name}\": must be lowercase letters, digits, ` +\n `or underscores, starting with a letter or underscore.`\n );\n }\n}\n\n/**\n * Cap on `result` / `error` JSONB column size. Adopter `*Task` returns are\n * written verbatim — a buggy or malicious adopter handing back a 1GB\n * result would OOM the Node process before pg complains. 4MB matches the\n * default Postgres `toast` row threshold and gives plenty of headroom for\n * legitimate task payloads.\n *\n * **This cap protects the DB write path only.** Adopter code that\n * serializes `result` / `error` for logs, metrics, or downstream services\n * MUST impose its own size cap — `JSON.stringify(result)` in a logger\n * call is unbounded.\n */\nconst MAX_RESULT_BYTES = 4 * 1024 * 1024;\n\nfunction assertResultSize(json: string, taskId: string): void {\n // `Buffer.byteLength` is utf-8 byte length, which is what Postgres stores.\n if (Buffer.byteLength(json, 'utf8') > MAX_RESULT_BYTES) {\n throw new Error(\n `Task ${taskId}: result/error JSON exceeds ${MAX_RESULT_BYTES} bytes ` +\n `(adopter *Task method returned an oversized payload — investigate ` +\n `whether the body should be persisted via blob storage and referenced).`\n );\n }\n}\n\n/**\n * Wrap `JSON.stringify` with a clearer error when the adopter `*Task`\n * return contains circular references. Default `TypeError: Converting\n * circular structure to JSON` doesn't surface the task id; this version\n * bubbles a registry-write error pointing at the adopter return shape.\n */\nfunction safeStringify(value: unknown, taskId: string): string {\n try {\n return JSON.stringify(value);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(\n `Task ${taskId}: adopter *Task return is not JSON-serializable: ${msg}. ` +\n `Strip circular refs / non-plain-data before returning from your *Task method.`\n );\n }\n}\n\n/**\n * Generate the SQL DDL for the decisioning task registry table.\n *\n * Run once at boot (idempotent — `CREATE TABLE IF NOT EXISTS`). Constraint\n * and index names are derived from the table name to avoid collisions when\n * multiple registries share the same database.\n *\n * @example\n * ```typescript\n * import { getDecisioningTaskRegistryMigration } from '@adcp/sdk/server/decisioning';\n * await pool.query(getDecisioningTaskRegistryMigration());\n * await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks' }));\n * ```\n */\nexport function getDecisioningTaskRegistryMigration(options?: { tableName?: string }): string {\n const table = options?.tableName ?? DEFAULT_TABLE;\n assertValidIdentifier(table);\n return `\nCREATE TABLE IF NOT EXISTS ${table} (\n task_id TEXT PRIMARY KEY,\n tool TEXT NOT NULL,\n account_id TEXT NOT NULL,\n owner_scope TEXT,\n status TEXT NOT NULL DEFAULT 'submitted',\n status_message TEXT,\n result JSONB,\n error JSONB,\n progress JSONB,\n has_webhook BOOLEAN NOT NULL DEFAULT FALSE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n CONSTRAINT ${table}_valid_status CHECK (\n -- Framework-written values: 'submitted' (initial), 'working'\n -- (after first updateProgress() call), 'completed' / 'failed'\n -- (terminal). The other 5 spec-defined states ('input-required',\n -- 'canceled', 'rejected', 'auth-required', 'unknown') are reserved\n -- for adopter-emitted transitions via the v6.1\n -- \\`taskRegistry.transition()\\` API; the v6.1 migration will widen\n -- this CHECK.\n status IN ('submitted', 'working', 'completed', 'failed')\n )\n);\n\nCREATE INDEX IF NOT EXISTS idx_${table}_account_id\n ON ${table}(account_id);\n\nCREATE INDEX IF NOT EXISTS idx_${table}_status_created\n ON ${table}(status, created_at);\n\nALTER TABLE ${table}\n ADD COLUMN IF NOT EXISTS owner_scope TEXT;\n\nCREATE INDEX IF NOT EXISTS idx_${table}_owner_account\n ON ${table}(owner_scope, account_id);\n`.trim();\n}\n\ninterface DbTaskRow {\n task_id: string;\n tool: string;\n account_id: string;\n owner_scope: string | null;\n status: TaskStatus;\n status_message: string | null;\n result: unknown;\n error: AdcpStructuredError | null;\n progress: TaskHandoffProgress | null;\n has_webhook: boolean;\n created_at: Date;\n updated_at: Date;\n}\n\nfunction rowToRecord<TResult>(row: DbTaskRow): TaskRecord<TResult> {\n return {\n taskId: row.task_id,\n tool: row.tool,\n accountId: row.account_id,\n ...(row.owner_scope ? { ownerScope: row.owner_scope } : {}),\n status: row.status,\n ...(row.status_message !== null && { statusMessage: row.status_message }),\n ...(row.result !== null && row.result !== undefined && { result: row.result as TResult }),\n ...(row.error !== null && row.error !== undefined && { error: row.error }),\n ...(row.progress !== null && row.progress !== undefined && { progress: row.progress }),\n ...(row.has_webhook && { hasWebhook: true }),\n createdAt: row.created_at.toISOString(),\n updatedAt: row.updated_at.toISOString(),\n };\n}\n\n/**\n * Build a Postgres-backed `TaskRegistry`.\n *\n * Idempotency: `complete()` and `fail()` are no-ops on already-terminal\n * tasks, matching `createInMemoryTaskRegistry()`. The terminal-state guard\n * is enforced via SQL `WHERE status = 'submitted'` predicates so concurrent\n * webhook deliveries can't race to overwrite each other.\n *\n * **Multi-tenant deployments — accountId namespacing.** When sharing a\n * single Postgres registry across tenants in a `TenantRegistry`\n * deployment (one table for all tenants), prefix `account_id` per-tenant\n * to prevent cross-tenant collisions. If tenant A and tenant B both\n * resolve a buyer to literal `account_id: 'acme'`, their tasks land in\n * the same row keyspace and `tasks_get` can't distinguish ownership\n * by `account_id` alone (the tenant boundary upstream IS protected by\n * `accounts.resolve(ref, ctx)` returning each tenant's own Account, but\n * the registry sees the same `acme` string in both rows). Recommended\n * pattern: adopters' `accounts.resolve` returns\n * `id: \\`tenant_${tenantId}_${accountId}\\`` so the table-level keys\n * stay distinct. Alternative: separate `tableName` per tenant, one\n * `createPostgresTaskRegistry({ pool, tableName })` per `TenantRegistry`\n * tenant — heavier ops, stronger isolation.\n */\nexport function createPostgresTaskRegistry(opts: CreatePostgresTaskRegistryOptions): TaskRegistry {\n const table = opts.tableName ?? DEFAULT_TABLE;\n assertValidIdentifier(table);\n const pool = opts.pool;\n\n // Process-local background tracking — see file header note. Promises\n // can't be persisted; cross-instance HITL completion happens via\n // webhook → explicit complete()/fail(), not via awaitTask.\n const backgrounds = new Map<string, Promise<void>>();\n\n return {\n async create(createOpts: {\n tool: string;\n accountId: string;\n ownerScope?: string;\n hasWebhook?: boolean;\n overrideTaskId?: string;\n }): Promise<{ taskId: string }> {\n const taskId = createOpts.overrideTaskId ?? `task_${randomUUID()}`;\n const result = await pool.query(\n `INSERT INTO ${table} (task_id, tool, account_id, owner_scope, status, has_webhook) VALUES ($1, $2, $3, $4, 'submitted', $5) ON CONFLICT (task_id) DO NOTHING`,\n [\n taskId,\n createOpts.tool,\n createOpts.accountId,\n createOpts.ownerScope ?? `account:${createOpts.accountId}`,\n createOpts.hasWebhook === true,\n ]\n );\n if ((result.rowCount ?? 0) === 0) {\n throw new Error(`task_id already registered: ${taskId}`);\n }\n return { taskId };\n },\n\n async getTask<TResult = unknown>(taskId: string): Promise<TaskRecord<TResult> | null> {\n const { rows } = await pool.query(\n `SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at\n FROM ${table} WHERE task_id = $1`,\n [taskId]\n );\n if (rows.length === 0) return null;\n return rowToRecord<TResult>(rows[0] as unknown as DbTaskRow);\n },\n\n async list(listOpts: { accountId: string; ownerScope?: string }): Promise<{ tasks: TaskRecord[] }> {\n if (listOpts.ownerScope === undefined) return { tasks: [] };\n const { rows } = await pool.query(\n `SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at\n FROM ${table}\n WHERE account_id = $1 AND (owner_scope = $2 OR (owner_scope IS NULL AND $2 = $3))\n ORDER BY created_at DESC, task_id DESC`,\n [listOpts.accountId, listOpts.ownerScope, `account:${listOpts.accountId}`]\n );\n return { tasks: rows.map(row => rowToRecord(row as unknown as DbTaskRow)) };\n },\n\n async complete<TResult>(taskId: string, result: TResult): Promise<void> {\n const json = safeStringify(result, taskId);\n assertResultSize(json, taskId);\n await pool.query(\n `UPDATE ${table}\n SET status = 'completed', result = $2::jsonb, updated_at = NOW()\n WHERE task_id = $1 AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, json]\n );\n },\n\n async fail(taskId: string, error: AdcpStructuredError, result?: unknown): Promise<void> {\n const errorJson = safeStringify(error, taskId);\n const resultJson = result === undefined ? undefined : safeStringify(result, taskId);\n assertResultSize(errorJson, taskId);\n if (resultJson !== undefined) assertResultSize(resultJson, taskId);\n await pool.query(\n `UPDATE ${table}\n SET status = 'failed', error = $2::jsonb, result = $3::jsonb, status_message = $4, updated_at = NOW()\n WHERE task_id = $1 AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, errorJson, resultJson ?? null, error.message]\n );\n },\n\n async updateProgress(taskId: string, progress: TaskHandoffProgress): Promise<void> {\n const json = safeStringify(progress, taskId);\n await pool.query(\n `UPDATE ${table}\n SET progress = $2::jsonb,\n status = CASE WHEN status = 'submitted' THEN 'working' ELSE status END,\n updated_at = NOW()\n WHERE task_id = $1 AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, json]\n );\n },\n\n _registerBackground(taskId: string, completion: Promise<void>): void {\n const composed: Promise<void> = completion.then(\n () => {\n if (backgrounds.get(taskId) === composed) backgrounds.delete(taskId);\n },\n () => {\n if (backgrounds.get(taskId) === composed) backgrounds.delete(taskId);\n }\n );\n backgrounds.set(taskId, composed);\n },\n\n async awaitTask(taskId: string): Promise<void> {\n const pending = backgrounds.get(taskId);\n if (pending) await pending;\n },\n } satisfies TaskRegistry;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+CA,yBAA2B;AA6B3B,MAAM,gBAAgB;AAGtB,MAAM,mBAAmB;AAEzB,SAAS,sBAAsB,MAAoB;AACjD,MAAI,CAAC,iBAAiB,KAAK,IAAI,GAAG;AAChC,UAAM,IAAI;AAAA,MACR,uBAAuB,IAAI;AAAA,IAE7B;AAAA,EACF;AACF;AAcA,MAAM,mBAAmB,IAAI,OAAO;AAEpC,SAAS,iBAAiB,MAAc,QAAsB;AAE5D,MAAI,OAAO,WAAW,MAAM,MAAM,IAAI,kBAAkB;AACtD,UAAM,IAAI;AAAA,MACR,QAAQ,MAAM,+BAA+B,gBAAgB;AAAA,IAG/D;AAAA,EACF;AACF;AAQA,SAAS,cAAc,OAAgB,QAAwB;AAC7D,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,SAAS,KAAK;AACZ,UAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC3D,UAAM,IAAI;AAAA,MACR,QAAQ,MAAM,oDAAoD,GAAG;AAAA,IAEvE;AAAA,EACF;AACF;AAgBO,SAAS,oCAAoC,SAA0C;AAC5F,QAAM,QAAQ,SAAS,aAAa;AACpC,wBAAsB,KAAK;AAC3B,SAAO;AAAA,6BACoB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,eAcnB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,iCAYa,KAAK;AAAA,OAC/B,KAAK;AAAA;AAAA,iCAEqB,KAAK;AAAA,OAC/B,KAAK;AAAA;AAAA,cAEE,KAAK;AAAA;AAAA;AAAA,iCAGc,KAAK;AAAA,OAC/B,KAAK;AAAA,EACV,KAAK;AACP;AAiBA,SAAS,YAAqB,KAAqC;AACjE,SAAO;AAAA,IACL,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,GAAI,IAAI,cAAc,EAAE,YAAY,IAAI,YAAY,IAAI,CAAC;AAAA,IACzD,QAAQ,IAAI;AAAA,IACZ,GAAI,IAAI,mBAAmB,QAAQ,EAAE,eAAe,IAAI,eAAe;AAAA,IACvE,GAAI,IAAI,WAAW,QAAQ,IAAI,WAAW,UAAa,EAAE,QAAQ,IAAI,OAAkB;AAAA,IACvF,GAAI,IAAI,UAAU,QAAQ,IAAI,UAAU,UAAa,EAAE,OAAO,IAAI,MAAM;AAAA,IACxE,GAAI,IAAI,aAAa,QAAQ,IAAI,aAAa,UAAa,EAAE,UAAU,IAAI,SAAS;AAAA,IACpF,GAAI,IAAI,eAAe,EAAE,YAAY,KAAK;AAAA,IAC1C,WAAW,IAAI,WAAW,YAAY;AAAA,IACtC,WAAW,IAAI,WAAW,YAAY;AAAA,EACxC;AACF;AAyBO,SAAS,2BAA2B,MAAuD;AAChG,QAAM,QAAQ,KAAK,aAAa;AAChC,wBAAsB,KAAK;AAC3B,QAAM,OAAO,KAAK;AAKlB,QAAM,cAAc,oBAAI,IAA2B;AAEnD,SAAO;AAAA,IACL,MAAM,OAAO,YAMmB;AAC9B,YAAM,SAAS,WAAW,kBAAkB,YAAQ,+BAAW,CAAC;AAChE,YAAM,SAAS,MAAM,KAAK;AAAA,QACxB,eAAe,KAAK;AAAA,QACpB;AAAA,UACE;AAAA,UACA,WAAW;AAAA,UACX,WAAW;AAAA,UACX,WAAW,cAAc,WAAW,WAAW,SAAS;AAAA,UACxD,WAAW,eAAe;AAAA,QAC5B;AAAA,MACF;AACA,WAAK,OAAO,YAAY,OAAO,GAAG;AAChC,cAAM,IAAI,MAAM,+BAA+B,MAAM,EAAE;AAAA,MACzD;AACA,aAAO,EAAE,OAAO;AAAA,IAClB;AAAA,IAEA,MAAM,QAA2B,QAAqD;AACpF,YAAM,EAAE,KAAK,IAAI,MAAM,KAAK;AAAA,QAC1B;AAAA,gBACQ,KAAK;AAAA,QACb,CAAC,MAAM;AAAA,MACT;AACA,UAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,aAAO,YAAqB,KAAK,CAAC,CAAyB;AAAA,IAC7D;AAAA,IAEA,MAAM,KAAK,UAAwF;AACjG,UAAI,SAAS,eAAe,OAAW,QAAO,EAAE,OAAO,CAAC,EAAE;AAC1D,YAAM,EAAE,KAAK,IAAI,MAAM,KAAK;AAAA,QAC1B;AAAA,gBACQ,KAAK;AAAA;AAAA;AAAA,QAGb,CAAC,SAAS,WAAW,SAAS,YAAY,WAAW,SAAS,SAAS,EAAE;AAAA,MAC3E;AACA,aAAO,EAAE,OAAO,KAAK,IAAI,SAAO,YAAY,GAA2B,CAAC,EAAE;AAAA,IAC5E;AAAA,IAEA,MAAM,SAAkB,QAAgB,QAAgC;AACtE,YAAM,OAAO,cAAc,QAAQ,MAAM;AACzC,uBAAiB,MAAM,MAAM;AAC7B,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA,QAGf,CAAC,QAAQ,IAAI;AAAA,MACf;AAAA,IACF;AAAA,IAEA,MAAM,KAAK,QAAgB,OAA4B,QAAiC;AACtF,YAAM,YAAY,cAAc,OAAO,MAAM;AAC7C,YAAM,aAAa,WAAW,SAAY,SAAY,cAAc,QAAQ,MAAM;AAClF,uBAAiB,WAAW,MAAM;AAClC,UAAI,eAAe,OAAW,kBAAiB,YAAY,MAAM;AACjE,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA,QAGf,CAAC,QAAQ,WAAW,cAAc,MAAM,MAAM,OAAO;AAAA,MACvD;AAAA,IACF;AAAA,IAEA,MAAM,eAAe,QAAgB,UAA8C;AACjF,YAAM,OAAO,cAAc,UAAU,MAAM;AAC3C,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,QAKf,CAAC,QAAQ,IAAI;AAAA,MACf;AAAA,IACF;AAAA,IAEA,oBAAoB,QAAgB,YAAiC;AACnE,YAAM,WAA0B,WAAW;AAAA,QACzC,MAAM;AACJ,cAAI,YAAY,IAAI,MAAM,MAAM,SAAU,aAAY,OAAO,MAAM;AAAA,QACrE;AAAA,QACA,MAAM;AACJ,cAAI,YAAY,IAAI,MAAM,MAAM,SAAU,aAAY,OAAO,MAAM;AAAA,QACrE;AAAA,MACF;AACA,kBAAY,IAAI,QAAQ,QAAQ;AAAA,IAClC;AAAA,IAEA,MAAM,UAAU,QAA+B;AAC7C,YAAM,UAAU,YAAY,IAAI,MAAM;AACtC,UAAI,QAAS,OAAM;AAAA,IACrB;AAAA,EACF;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../../../src/lib/server/decisioning/runtime/postgres-task-registry.ts"],"sourcesContent":["/**\n * PostgreSQL-backed `TaskRegistry` for the v6.0 decisioning runtime.\n *\n * The default `createInMemoryTaskRegistry()` loses task state on process\n * restart and doesn't share state across instances behind a load balancer —\n * fine for tests and local dev, broken for production HITL paths\n * (`createMediaBuyTask`, `syncCreativesTask`, etc.). Wire this in via\n * `createAdcpServerFromPlatform({ taskRegistry: createPostgresTaskRegistry({ pool, namespace }) })`\n * to persist task lifecycle across requests, processes, and crashes.\n *\n * @example\n * ```typescript\n * import { Pool } from 'pg';\n * import {\n * createAdcpServerFromPlatform,\n * createPostgresTaskRegistry,\n * getDecisioningTaskRegistryMigration,\n * } from '@adcp/sdk/server/decisioning';\n *\n * const pool = new Pool({ connectionString: process.env.DATABASE_URL });\n *\n * const taskRegistryNamespace = 'tenant:my-agent';\n * // Run once at boot, using the same trusted namespace as the registry.\n * await pool.query(getDecisioningTaskRegistryMigration({ namespace: taskRegistryNamespace }));\n *\n * const server = createAdcpServerFromPlatform(platform, {\n * name: 'My Ad Network',\n * version: '1.0.0',\n * taskRegistry: createPostgresTaskRegistry({ pool, namespace: taskRegistryNamespace }),\n * });\n * ```\n *\n * **Background-completion lifecycle.** `_registerBackground` / `awaitTask`\n * are PROCESS-LOCAL — promises don't serialize. When a HITL `*Task` method\n * is invoked, the returned promise lives on the originating process; if\n * that process restarts before the method completes, the task record stays\n * in `submitted` state in Postgres but no `awaitTask` resolution is\n * possible from a different instance. Production HITL flows that span\n * process boundaries should drive completion via webhook → an explicit\n * `complete()` / `fail()` call from the webhook handler, not via\n * `awaitTask`. The MCP `tasks/get` wire path reads via `getTask` and is\n * already cross-instance.\n *\n * Status: Preview / 6.0.\n *\n * @public\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { AdcpStructuredError, TaskHandoffProgress } from '../async-outcome';\nimport type { TaskRecord, TaskRegistry, TaskRegistryScope, TaskStatus } from './task-registry';\n\n/**\n * Minimal subset of the `pg.Pool` interface used by the registry.\n * Mirrors `PgQueryable` in `postgres-task-store.ts` so callers can pass\n * pools, transaction wrappers, or any pg-compatible query executor.\n */\nexport interface PgQueryable {\n query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount: number | null }>;\n}\n\nexport interface CreatePostgresTaskRegistryOptions {\n /** A `pg.Pool` instance (or any `PgQueryable`). */\n pool: PgQueryable;\n /** Trusted deployment/tenant namespace. Use a distinct value per hosted tenant. */\n namespace: string;\n /**\n * Table name. Defaults to `'adcp_decisioning_tasks'` (vendor-prefixed to\n * avoid collisions with the MCP-level `adcp_mcp_tasks` table from\n * `PostgresTaskStore` and any consumer tables).\n *\n * Must match `^[a-z_][a-z0-9_]*$` — schema-qualified names are not\n * supported by this validator. Set search_path on the pool if you need\n * a non-default schema.\n */\n tableName?: string;\n}\n\n/** Default table name — vendor-prefixed to avoid collisions. */\nconst DEFAULT_TABLE = 'adcp_decisioning_tasks';\n\n/** Validates a SQL identifier to prevent injection via table names. */\nconst VALID_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;\nconst VALID_NAMESPACE = /^[A-Za-z0-9_.:-]{1,255}$/;\n\nfunction assertValidIdentifier(name: string): void {\n if (!VALID_IDENTIFIER.test(name) || Buffer.byteLength(name, 'utf8') > 40) {\n throw new Error(\n `Invalid table name \"${name}\": must be lowercase letters, digits, ` +\n `or underscores, starting with a letter or underscore, and at most 40 bytes.`\n );\n }\n}\n\nfunction assertValidNamespace(namespace: unknown): asserts namespace is string {\n if (typeof namespace !== 'string' || !VALID_NAMESPACE.test(namespace)) {\n throw new Error(\n 'Invalid registry namespace: namespace is required and must be 1-255 ASCII letters, digits, dots, underscores, colons, or hyphens.'\n );\n }\n}\n\n/**\n * Cap on `result` / `error` JSONB column size. Adopter `*Task` returns are\n * written verbatim — a buggy or malicious adopter handing back a 1GB\n * result would OOM the Node process before pg complains. 4MB matches the\n * default Postgres `toast` row threshold and gives plenty of headroom for\n * legitimate task payloads.\n *\n * **This cap protects the DB write path only.** Adopter code that\n * serializes `result` / `error` for logs, metrics, or downstream services\n * MUST impose its own size cap — `JSON.stringify(result)` in a logger\n * call is unbounded.\n */\nconst MAX_RESULT_BYTES = 4 * 1024 * 1024;\n\nfunction assertResultSize(json: string, taskId: string): void {\n // `Buffer.byteLength` is utf-8 byte length, which is what Postgres stores.\n if (Buffer.byteLength(json, 'utf8') > MAX_RESULT_BYTES) {\n throw new Error(\n `Task ${taskId}: result/error JSON exceeds ${MAX_RESULT_BYTES} bytes ` +\n `(adopter *Task method returned an oversized payload — investigate ` +\n `whether the body should be persisted via blob storage and referenced).`\n );\n }\n}\n\n/**\n * Wrap `JSON.stringify` with a clearer error when the adopter `*Task`\n * return contains circular references. Default `TypeError: Converting\n * circular structure to JSON` doesn't surface the task id; this version\n * bubbles a registry-write error pointing at the adopter return shape.\n */\nfunction safeStringify(value: unknown, taskId: string): string {\n try {\n return JSON.stringify(value);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(\n `Task ${taskId}: adopter *Task return is not JSON-serializable: ${msg}. ` +\n `Strip circular refs / non-plain-data before returning from your *Task method.`\n );\n }\n}\n\n/**\n * Generate the SQL DDL for the decisioning task registry table.\n *\n * Run once at boot (idempotent — `CREATE TABLE IF NOT EXISTS`). Constraint\n * and index names are derived from the table name to avoid collisions when\n * multiple registries share the same database.\n *\n * @example\n * ```typescript\n * import { getDecisioningTaskRegistryMigration } from '@adcp/sdk/server/decisioning';\n * await pool.query(getDecisioningTaskRegistryMigration({ namespace: tenantId }));\n * await pool.query(getDecisioningTaskRegistryMigration({ tableName: 'my_tasks', namespace: tenantId }));\n * ```\n */\nexport function getDecisioningTaskRegistryMigration(options: { tableName?: string; namespace: string }): string {\n assertValidNamespace(options?.namespace);\n const table = options?.tableName ?? DEFAULT_TABLE;\n assertValidIdentifier(table);\n const namespace = options.namespace;\n return `\nCREATE TABLE IF NOT EXISTS ${table} (\n registry_namespace TEXT NOT NULL DEFAULT '${namespace}',\n task_id TEXT NOT NULL,\n tool TEXT NOT NULL,\n account_id TEXT NOT NULL,\n owner_scope TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'submitted',\n status_message TEXT,\n result JSONB,\n error JSONB,\n progress JSONB,\n has_webhook BOOLEAN NOT NULL DEFAULT FALSE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n\n CONSTRAINT ${table}_valid_status CHECK (\n -- Framework-written values: 'submitted' (initial), 'working'\n -- (after first updateProgress() call), 'completed' / 'failed'\n -- (terminal). The other 5 spec-defined states ('input-required',\n -- 'canceled', 'rejected', 'auth-required', 'unknown') are reserved\n -- for adopter-emitted transitions via the v6.1\n -- \\`taskRegistry.transition()\\` API; the v6.1 migration will widen\n -- this CHECK.\n status IN ('submitted', 'working', 'completed', 'failed')\n )\n);\n\nCREATE INDEX IF NOT EXISTS idx_${table}_account_id\n ON ${table}(account_id);\n\nCREATE INDEX IF NOT EXISTS idx_${table}_status_created\n ON ${table}(status, created_at);\n\nALTER TABLE ${table}\n ADD COLUMN IF NOT EXISTS owner_scope TEXT;\n\nALTER TABLE ${table}\n ADD COLUMN IF NOT EXISTS registry_namespace TEXT NOT NULL DEFAULT '__adcp_legacy_unscoped__';\n\nUPDATE ${table}\n SET registry_namespace = '${namespace}'\n WHERE registry_namespace = '__adcp_legacy_unscoped__';\n\nALTER TABLE ${table}\n ALTER COLUMN registry_namespace SET DEFAULT '${namespace}';\n\nUPDATE ${table}\n SET owner_scope = 'account:' || account_id\n WHERE owner_scope IS NULL;\n\nALTER TABLE ${table}\n ALTER COLUMN owner_scope SET NOT NULL;\n\nDO $$\nDECLARE\n current_primary_key TEXT;\nBEGIN\n PERFORM pg_advisory_xact_lock(hashtext('adcp-task-registry:${table}'));\n SELECT conname INTO current_primary_key\n FROM pg_constraint\n WHERE conrelid = '${table}'::regclass AND contype = 'p';\n IF current_primary_key IS NOT NULL AND current_primary_key <> '${table}_scope_pkey' THEN\n EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', '${table}', current_primary_key);\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = '${table}_scope_pkey'\n AND conrelid = '${table}'::regclass\n ) THEN\n ALTER TABLE ${table}\n ADD CONSTRAINT ${table}_scope_pkey\n PRIMARY KEY (registry_namespace, account_id, owner_scope, task_id);\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_${table}_owner_account\n ON ${table}(owner_scope, account_id);\n`.trim();\n}\n\ninterface DbTaskRow {\n task_id: string;\n tool: string;\n account_id: string;\n owner_scope: string | null;\n status: TaskStatus;\n status_message: string | null;\n result: unknown;\n error: AdcpStructuredError | null;\n progress: TaskHandoffProgress | null;\n has_webhook: boolean;\n created_at: Date;\n updated_at: Date;\n}\n\nfunction rowToRecord<TResult>(row: DbTaskRow): TaskRecord<TResult> {\n return {\n taskId: row.task_id,\n tool: row.tool,\n accountId: row.account_id,\n ...(row.owner_scope ? { ownerScope: row.owner_scope } : {}),\n status: row.status,\n ...(row.status_message !== null && { statusMessage: row.status_message }),\n ...(row.result !== null && row.result !== undefined && { result: row.result as TResult }),\n ...(row.error !== null && row.error !== undefined && { error: row.error }),\n ...(row.progress !== null && row.progress !== undefined && { progress: row.progress }),\n ...(row.has_webhook && { hasWebhook: true }),\n createdAt: row.created_at.toISOString(),\n updatedAt: row.updated_at.toISOString(),\n };\n}\n\n/**\n * Build a Postgres-backed `TaskRegistry`.\n *\n * Idempotency: `complete()` and `fail()` are no-ops on already-terminal\n * tasks, matching `createInMemoryTaskRegistry()`. The terminal-state guard\n * is enforced via SQL `WHERE status = 'submitted'` predicates so concurrent\n * webhook deliveries can't race to overwrite each other.\n *\n * **Multi-tenant deployments.** `namespace` is a trusted deployment/tenant\n * partition and participates in every query plus the primary key. Construct\n * one registry per tenant with a stable unique namespace; never derive it\n * from buyer request parameters. Tenants may safely share the same table.\n */\nexport function createPostgresTaskRegistry(opts: CreatePostgresTaskRegistryOptions): TaskRegistry {\n const table = opts.tableName ?? DEFAULT_TABLE;\n assertValidIdentifier(table);\n const pool = opts.pool;\n const namespace = opts.namespace;\n assertValidNamespace(namespace);\n\n // Process-local background tracking — see file header note. Promises\n // can't be persisted; cross-instance HITL completion happens via\n // webhook → explicit complete()/fail(), not via awaitTask.\n const backgrounds = new Map<string, Promise<void>>();\n\n return {\n scopeVersion: 1,\n async create(createOpts: {\n tool: string;\n accountId: string;\n ownerScope?: string;\n hasWebhook?: boolean;\n overrideTaskId?: string;\n }): Promise<{ taskId: string }> {\n const taskId = createOpts.overrideTaskId ?? `task_${randomUUID()}`;\n const result = await pool.query(\n `INSERT INTO ${table} (task_id, tool, account_id, owner_scope, status, has_webhook, registry_namespace) VALUES ($1, $2, $3, $4, 'submitted', $5, $6) ON CONFLICT (registry_namespace, account_id, owner_scope, task_id) DO NOTHING`,\n [\n taskId,\n createOpts.tool,\n createOpts.accountId,\n createOpts.ownerScope ?? `account:${createOpts.accountId}`,\n createOpts.hasWebhook === true,\n namespace,\n ]\n );\n if ((result.rowCount ?? 0) === 0) {\n throw new Error(`task_id already registered: ${taskId}`);\n }\n return { taskId };\n },\n\n async getTask<TResult = unknown>(taskId: string, scope: TaskRegistryScope): Promise<TaskRecord<TResult> | null> {\n const { rows } = await pool.query(\n `SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at\n FROM ${table} WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,\n [taskId, namespace, scope.accountId, scope.ownerScope]\n );\n if (rows.length === 0) return null;\n return rowToRecord<TResult>(rows[0] as unknown as DbTaskRow);\n },\n\n async _getTaskUnsafe<TResult = unknown>(taskId: string): Promise<TaskRecord<TResult> | null> {\n const { rows } = await pool.query(\n `SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at\n FROM ${table} WHERE task_id = $1 AND registry_namespace = $2 LIMIT 2`,\n [taskId, namespace]\n );\n if (rows.length !== 1) return null;\n return rowToRecord<TResult>(rows[0] as unknown as DbTaskRow);\n },\n\n async list(listOpts: { accountId: string; ownerScope: string }): Promise<{ tasks: TaskRecord[] }> {\n const { rows } = await pool.query(\n `SELECT task_id, tool, account_id, owner_scope, status, status_message, result, error, progress, has_webhook, created_at, updated_at\n FROM ${table}\n WHERE registry_namespace = $1 AND account_id = $2 AND owner_scope = $3\n ORDER BY created_at DESC, task_id DESC`,\n [namespace, listOpts.accountId, listOpts.ownerScope]\n );\n return { tasks: rows.map(row => rowToRecord(row as unknown as DbTaskRow)) };\n },\n\n async complete<TResult>(taskId: string, scope: TaskRegistryScope, result: TResult): Promise<void> {\n const json = safeStringify(result, taskId);\n assertResultSize(json, taskId);\n await pool.query(\n `UPDATE ${table}\n SET status = 'completed', result = $5::jsonb, updated_at = NOW()\n WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4\n AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, namespace, scope.accountId, scope.ownerScope, json]\n );\n },\n\n async fail(taskId: string, scope: TaskRegistryScope, error: AdcpStructuredError, result?: unknown): Promise<void> {\n const errorJson = safeStringify(error, taskId);\n const resultJson = result === undefined ? undefined : safeStringify(result, taskId);\n assertResultSize(errorJson, taskId);\n if (resultJson !== undefined) assertResultSize(resultJson, taskId);\n await pool.query(\n `UPDATE ${table}\n SET status = 'failed', error = $5::jsonb, result = $6::jsonb, status_message = $7, updated_at = NOW()\n WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4\n AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, namespace, scope.accountId, scope.ownerScope, errorJson, resultJson ?? null, error.message]\n );\n },\n\n async updateProgress(taskId: string, scope: TaskRegistryScope, progress: TaskHandoffProgress): Promise<void> {\n const json = safeStringify(progress, taskId);\n await pool.query(\n `UPDATE ${table}\n SET progress = $5::jsonb,\n status = CASE WHEN status = 'submitted' THEN 'working' ELSE status END,\n updated_at = NOW()\n WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4\n AND status NOT IN ('completed', 'failed', 'rejected', 'canceled')`,\n [taskId, namespace, scope.accountId, scope.ownerScope, json]\n );\n },\n\n _registerBackground(taskId: string, scope: TaskRegistryScope, completion: Promise<void>): void {\n const backgroundKey = JSON.stringify([scope.accountId, scope.ownerScope, taskId]);\n const composed: Promise<void> = completion.then(\n () => {\n if (backgrounds.get(backgroundKey) === composed) backgrounds.delete(backgroundKey);\n },\n () => {\n if (backgrounds.get(backgroundKey) === composed) backgrounds.delete(backgroundKey);\n }\n );\n backgrounds.set(backgroundKey, composed);\n },\n\n async awaitTask(taskId: string, scope: TaskRegistryScope): Promise<void> {\n const pending = backgrounds.get(JSON.stringify([scope.accountId, scope.ownerScope, taskId]));\n if (pending) await pending;\n },\n\n async _awaitTaskUnsafe(taskId: string): Promise<void> {\n const pending = Array.from(backgrounds.entries())\n .filter(([key]) => (JSON.parse(key) as [string, string, string])[2] === taskId)\n .map(([, completion]) => completion);\n await Promise.all(pending);\n },\n } satisfies TaskRegistry;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgDA,yBAA2B;AA+B3B,MAAM,gBAAgB;AAGtB,MAAM,mBAAmB;AACzB,MAAM,kBAAkB;AAExB,SAAS,sBAAsB,MAAoB;AACjD,MAAI,CAAC,iBAAiB,KAAK,IAAI,KAAK,OAAO,WAAW,MAAM,MAAM,IAAI,IAAI;AACxE,UAAM,IAAI;AAAA,MACR,uBAAuB,IAAI;AAAA,IAE7B;AAAA,EACF;AACF;AAEA,SAAS,qBAAqB,WAAiD;AAC7E,MAAI,OAAO,cAAc,YAAY,CAAC,gBAAgB,KAAK,SAAS,GAAG;AACrE,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAcA,MAAM,mBAAmB,IAAI,OAAO;AAEpC,SAAS,iBAAiB,MAAc,QAAsB;AAE5D,MAAI,OAAO,WAAW,MAAM,MAAM,IAAI,kBAAkB;AACtD,UAAM,IAAI;AAAA,MACR,QAAQ,MAAM,+BAA+B,gBAAgB;AAAA,IAG/D;AAAA,EACF;AACF;AAQA,SAAS,cAAc,OAAgB,QAAwB;AAC7D,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,SAAS,KAAK;AACZ,UAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC3D,UAAM,IAAI;AAAA,MACR,QAAQ,MAAM,oDAAoD,GAAG;AAAA,IAEvE;AAAA,EACF;AACF;AAgBO,SAAS,oCAAoC,SAA4D;AAC9G,uBAAqB,SAAS,SAAS;AACvC,QAAM,QAAQ,SAAS,aAAa;AACpC,wBAAsB,KAAK;AAC3B,QAAM,YAAY,QAAQ;AAC1B,SAAO;AAAA,6BACoB,KAAK;AAAA,8CACY,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,eAcxC,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,iCAYa,KAAK;AAAA,OAC/B,KAAK;AAAA;AAAA,iCAEqB,KAAK;AAAA,OAC/B,KAAK;AAAA;AAAA,cAEE,KAAK;AAAA;AAAA;AAAA,cAGL,KAAK;AAAA;AAAA;AAAA,SAGV,KAAK;AAAA,8BACgB,SAAS;AAAA;AAAA;AAAA,cAGzB,KAAK;AAAA,iDAC8B,SAAS;AAAA;AAAA,SAEjD,KAAK;AAAA;AAAA;AAAA;AAAA,cAIA,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,+DAO4C,KAAK;AAAA;AAAA;AAAA,wBAG5C,KAAK;AAAA,mEACsC,KAAK;AAAA,2DACb,KAAK;AAAA;AAAA;AAAA;AAAA,uBAIzC,KAAK;AAAA,wBACJ,KAAK;AAAA;AAAA,kBAEX,KAAK;AAAA,uBACA,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,iCAKK,KAAK;AAAA,OAC/B,KAAK;AAAA,EACV,KAAK;AACP;AAiBA,SAAS,YAAqB,KAAqC;AACjE,SAAO;AAAA,IACL,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,GAAI,IAAI,cAAc,EAAE,YAAY,IAAI,YAAY,IAAI,CAAC;AAAA,IACzD,QAAQ,IAAI;AAAA,IACZ,GAAI,IAAI,mBAAmB,QAAQ,EAAE,eAAe,IAAI,eAAe;AAAA,IACvE,GAAI,IAAI,WAAW,QAAQ,IAAI,WAAW,UAAa,EAAE,QAAQ,IAAI,OAAkB;AAAA,IACvF,GAAI,IAAI,UAAU,QAAQ,IAAI,UAAU,UAAa,EAAE,OAAO,IAAI,MAAM;AAAA,IACxE,GAAI,IAAI,aAAa,QAAQ,IAAI,aAAa,UAAa,EAAE,UAAU,IAAI,SAAS;AAAA,IACpF,GAAI,IAAI,eAAe,EAAE,YAAY,KAAK;AAAA,IAC1C,WAAW,IAAI,WAAW,YAAY;AAAA,IACtC,WAAW,IAAI,WAAW,YAAY;AAAA,EACxC;AACF;AAeO,SAAS,2BAA2B,MAAuD;AAChG,QAAM,QAAQ,KAAK,aAAa;AAChC,wBAAsB,KAAK;AAC3B,QAAM,OAAO,KAAK;AAClB,QAAM,YAAY,KAAK;AACvB,uBAAqB,SAAS;AAK9B,QAAM,cAAc,oBAAI,IAA2B;AAEnD,SAAO;AAAA,IACL,cAAc;AAAA,IACd,MAAM,OAAO,YAMmB;AAC9B,YAAM,SAAS,WAAW,kBAAkB,YAAQ,+BAAW,CAAC;AAChE,YAAM,SAAS,MAAM,KAAK;AAAA,QACxB,eAAe,KAAK;AAAA,QACpB;AAAA,UACE;AAAA,UACA,WAAW;AAAA,UACX,WAAW;AAAA,UACX,WAAW,cAAc,WAAW,WAAW,SAAS;AAAA,UACxD,WAAW,eAAe;AAAA,UAC1B;AAAA,QACF;AAAA,MACF;AACA,WAAK,OAAO,YAAY,OAAO,GAAG;AAChC,cAAM,IAAI,MAAM,+BAA+B,MAAM,EAAE;AAAA,MACzD;AACA,aAAO,EAAE,OAAO;AAAA,IAClB;AAAA,IAEA,MAAM,QAA2B,QAAgB,OAA+D;AAC9G,YAAM,EAAE,KAAK,IAAI,MAAM,KAAK;AAAA,QAC1B;AAAA,gBACQ,KAAK;AAAA,QACb,CAAC,QAAQ,WAAW,MAAM,WAAW,MAAM,UAAU;AAAA,MACvD;AACA,UAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,aAAO,YAAqB,KAAK,CAAC,CAAyB;AAAA,IAC7D;AAAA,IAEA,MAAM,eAAkC,QAAqD;AAC3F,YAAM,EAAE,KAAK,IAAI,MAAM,KAAK;AAAA,QAC1B;AAAA,gBACQ,KAAK;AAAA,QACb,CAAC,QAAQ,SAAS;AAAA,MACpB;AACA,UAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,aAAO,YAAqB,KAAK,CAAC,CAAyB;AAAA,IAC7D;AAAA,IAEA,MAAM,KAAK,UAAuF;AAChG,YAAM,EAAE,KAAK,IAAI,MAAM,KAAK;AAAA,QAC1B;AAAA,gBACQ,KAAK;AAAA;AAAA;AAAA,QAGb,CAAC,WAAW,SAAS,WAAW,SAAS,UAAU;AAAA,MACrD;AACA,aAAO,EAAE,OAAO,KAAK,IAAI,SAAO,YAAY,GAA2B,CAAC,EAAE;AAAA,IAC5E;AAAA,IAEA,MAAM,SAAkB,QAAgB,OAA0B,QAAgC;AAChG,YAAM,OAAO,cAAc,QAAQ,MAAM;AACzC,uBAAiB,MAAM,MAAM;AAC7B,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA;AAAA,QAIf,CAAC,QAAQ,WAAW,MAAM,WAAW,MAAM,YAAY,IAAI;AAAA,MAC7D;AAAA,IACF;AAAA,IAEA,MAAM,KAAK,QAAgB,OAA0B,OAA4B,QAAiC;AAChH,YAAM,YAAY,cAAc,OAAO,MAAM;AAC7C,YAAM,aAAa,WAAW,SAAY,SAAY,cAAc,QAAQ,MAAM;AAClF,uBAAiB,WAAW,MAAM;AAClC,UAAI,eAAe,OAAW,kBAAiB,YAAY,MAAM;AACjE,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA;AAAA,QAIf,CAAC,QAAQ,WAAW,MAAM,WAAW,MAAM,YAAY,WAAW,cAAc,MAAM,MAAM,OAAO;AAAA,MACrG;AAAA,IACF;AAAA,IAEA,MAAM,eAAe,QAAgB,OAA0B,UAA8C;AAC3G,YAAM,OAAO,cAAc,UAAU,MAAM;AAC3C,YAAM,KAAK;AAAA,QACT,UAAU,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMf,CAAC,QAAQ,WAAW,MAAM,WAAW,MAAM,YAAY,IAAI;AAAA,MAC7D;AAAA,IACF;AAAA,IAEA,oBAAoB,QAAgB,OAA0B,YAAiC;AAC7F,YAAM,gBAAgB,KAAK,UAAU,CAAC,MAAM,WAAW,MAAM,YAAY,MAAM,CAAC;AAChF,YAAM,WAA0B,WAAW;AAAA,QACzC,MAAM;AACJ,cAAI,YAAY,IAAI,aAAa,MAAM,SAAU,aAAY,OAAO,aAAa;AAAA,QACnF;AAAA,QACA,MAAM;AACJ,cAAI,YAAY,IAAI,aAAa,MAAM,SAAU,aAAY,OAAO,aAAa;AAAA,QACnF;AAAA,MACF;AACA,kBAAY,IAAI,eAAe,QAAQ;AAAA,IACzC;AAAA,IAEA,MAAM,UAAU,QAAgB,OAAyC;AACvE,YAAM,UAAU,YAAY,IAAI,KAAK,UAAU,CAAC,MAAM,WAAW,MAAM,YAAY,MAAM,CAAC,CAAC;AAC3F,UAAI,QAAS,OAAM;AAAA,IACrB;AAAA,IAEA,MAAM,iBAAiB,QAA+B;AACpD,YAAM,UAAU,MAAM,KAAK,YAAY,QAAQ,CAAC,EAC7C,OAAO,CAAC,CAAC,GAAG,MAAO,KAAK,MAAM,GAAG,EAA+B,CAAC,MAAM,MAAM,EAC7E,IAAI,CAAC,CAAC,EAAE,UAAU,MAAM,UAAU;AACrC,YAAM,QAAQ,IAAI,OAAO;AAAA,IAC3B;AAAA,EACF;AACF;","names":[]}
|