@vercel/factory 0.0.17 → 0.0.18
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/CHANGELOG.md +46 -0
- package/dist/api-contracts.d.mts +665 -715
- package/dist/api-contracts.mjs +91 -4
- package/dist/api-contracts.mjs.map +1 -1
- package/dist/api-task-graph.mjs +14 -5
- package/dist/api-task-graph.mjs.map +1 -1
- package/dist/api.d.mts +58 -11
- package/dist/api.mjs +715 -399
- package/dist/api.mjs.map +1 -1
- package/dist/approval-access.d.mts +1 -1
- package/dist/approval-contracts.d.mts +3 -4
- package/dist/approval-contracts.mjs +1 -2
- package/dist/approval-contracts.mjs.map +1 -1
- package/dist/blob/index.d.mts +1 -1
- package/dist/budget.mjs +20 -3
- package/dist/budget.mjs.map +1 -1
- package/dist/change-verification/brief.d.mts +1 -1
- package/dist/change-verification/dispatch.mjs +8 -1
- package/dist/change-verification/dispatch.mjs.map +1 -1
- package/dist/changes/eve-record-change.mjs +4 -1
- package/dist/changes/eve-record-change.mjs.map +1 -1
- package/dist/client-stream.d.mts +19 -2
- package/dist/client-stream.mjs +115 -53
- package/dist/client-stream.mjs.map +1 -1
- package/dist/client-transcript.d.mts +25 -2
- package/dist/client-transcript.mjs +95 -13
- package/dist/client-transcript.mjs.map +1 -1
- package/dist/client.d.mts +145 -19
- package/dist/client.mjs +89 -16
- package/dist/client.mjs.map +1 -1
- package/dist/code-review/contracts.d.mts +1 -0
- package/dist/code-review/eve-post-review.d.mts +4 -2
- package/dist/code-review/eve-post-review.mjs +8 -0
- package/dist/code-review/eve-post-review.mjs.map +1 -1
- package/dist/code-review/github-reporter.d.mts +6 -2
- package/dist/code-review/github-reporter.mjs +86 -35
- package/dist/code-review/github-reporter.mjs.map +1 -1
- package/dist/code-review/preparation.d.mts +2 -1
- package/dist/code-review/preparation.mjs +2 -1
- package/dist/code-review/preparation.mjs.map +1 -1
- package/dist/code-review/stall.mjs +11 -2
- package/dist/code-review/stall.mjs.map +1 -1
- package/dist/connectors/github.d.mts +1 -1
- package/dist/connectors/index.d.mts +1 -1
- package/dist/connectors/slack.d.mts +1 -1
- package/dist/delivery-metrics/metrics.d.mts +1 -1
- package/dist/dispatch.mjs +3 -3
- package/dist/dispatch.mjs.map +1 -1
- package/dist/eve/index.d.mts +1 -1
- package/dist/eve/index.mjs +8 -1
- package/dist/eve/index.mjs.map +1 -1
- package/dist/eve/task-execution.d.mts +4 -4
- package/dist/eve/transcript.d.mts +3 -1
- package/dist/eve/transcript.mjs.map +1 -1
- package/dist/finding-remediation/admission.d.mts +1 -1
- package/dist/finding-remediation/brief.d.mts +1 -1
- package/dist/github-app.d.mts +1 -1
- package/dist/github-publication.d.mts +1 -1
- package/dist/github.d.mts +9 -1
- package/dist/github.mjs +29 -11
- package/dist/github.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/intake-contracts.d.mts +1 -0
- package/dist/intake-contracts.mjs +2 -1
- package/dist/intake-contracts.mjs.map +1 -1
- package/dist/integrations/github-issues.d.mts +53 -0
- package/dist/integrations/github-issues.mjs +170 -0
- package/dist/integrations/github-issues.mjs.map +1 -0
- package/dist/integrations/github.d.mts +2 -1
- package/dist/integrations/github.mjs +2 -1
- package/dist/multi-repository-changes/coordination.mjs +1 -1
- package/dist/planning/reconcile.mjs +15 -2
- package/dist/planning/reconcile.mjs.map +1 -1
- package/dist/postgres/index.d.mts +2 -1
- package/dist/postgres/index.mjs +11 -5
- package/dist/postgres/index.mjs.map +1 -1
- package/dist/postgres/indexed-codec.mjs +137 -0
- package/dist/postgres/indexed-codec.mjs.map +1 -0
- package/dist/postgres/indexed-schema.mjs +99 -0
- package/dist/postgres/indexed-schema.mjs.map +1 -0
- package/dist/postgres/indexed.d.mts +28 -0
- package/dist/postgres/indexed.mjs +277 -0
- package/dist/postgres/indexed.mjs.map +1 -0
- package/dist/postgres/record-predicates.mjs +23 -0
- package/dist/postgres/record-predicates.mjs.map +1 -0
- package/dist/presets/software-development/deepsec-brief.d.mts +1 -1
- package/dist/presets/software-development/dispatch.mjs +1 -1
- package/dist/presets/software-development/dispatch.mjs.map +1 -1
- package/dist/presets/software-development/recovery.mjs +9 -2
- package/dist/presets/software-development/recovery.mjs.map +1 -1
- package/dist/pull-requests/eve-fetch.d.mts +4 -1
- package/dist/pull-requests/eve-fetch.mjs +9 -0
- package/dist/pull-requests/eve-fetch.mjs.map +1 -1
- package/dist/pull-requests/github-publisher.d.mts +1 -1
- package/dist/pull-requests/github-publisher.mjs +19 -7
- package/dist/pull-requests/github-publisher.mjs.map +1 -1
- package/dist/repositories.mjs +20 -13
- package/dist/repositories.mjs.map +1 -1
- package/dist/sandbox/execution-run-ledger.d.mts +2 -2
- package/dist/sandbox/execution-run-ledger.mjs.map +1 -1
- package/dist/sandbox/execution.d.mts +2 -2
- package/dist/sandbox/execution.mjs.map +1 -1
- package/dist/schema/catalog.mjs +1 -1
- package/dist/schema/change.d.mts +1 -1
- package/dist/schema/communication.d.mts +2 -2
- package/dist/schema/factory-config.d.mts +1 -1
- package/dist/schema/outbox.d.mts +6 -6
- package/dist/schema/session-summary.d.mts +218 -0
- package/dist/schema/session-summary.mjs +68 -0
- package/dist/schema/session-summary.mjs.map +1 -0
- package/dist/schema/session.d.mts +39 -59
- package/dist/schema/session.mjs +6 -6
- package/dist/schema/session.mjs.map +1 -1
- package/dist/schema/task-graph.d.mts +24 -24
- package/dist/schema/transcript.d.mts +141 -3
- package/dist/schema/transcript.mjs +19 -3
- package/dist/schema/transcript.mjs.map +1 -1
- package/dist/schema/transitions.d.mts +1 -1
- package/dist/schema/transitions.mjs +1 -1
- package/dist/schema/transitions.mjs.map +1 -1
- package/dist/signals.d.mts +1 -1
- package/dist/stall.mjs +10 -2
- package/dist/stall.mjs.map +1 -1
- package/dist/storage.d.mts +10 -1
- package/dist/storage.mjs +7 -1
- package/dist/store/approvals.d.mts +14 -0
- package/dist/store/approvals.mjs +78 -0
- package/dist/store/approvals.mjs.map +1 -0
- package/dist/store/conversations.d.mts +59 -0
- package/dist/store/conversations.mjs +549 -0
- package/dist/store/conversations.mjs.map +1 -0
- package/dist/store/driver.d.mts +1 -1
- package/dist/store/driver.mjs.map +1 -1
- package/dist/store/engine.d.mts +38 -16
- package/dist/store/engine.mjs +229 -106
- package/dist/store/engine.mjs.map +1 -1
- package/dist/store/indexed-engine.d.mts +57 -0
- package/dist/store/indexed-engine.mjs +155 -0
- package/dist/store/indexed-engine.mjs.map +1 -0
- package/dist/store/indexed-memory.d.mts +7 -0
- package/dist/store/indexed-memory.mjs +200 -0
- package/dist/store/indexed-memory.mjs.map +1 -0
- package/dist/store/indexed-projections.d.mts +280 -0
- package/dist/store/indexed-projections.mjs +481 -0
- package/dist/store/indexed-projections.mjs.map +1 -0
- package/dist/store/indexed.d.mts +101 -0
- package/dist/store/indexed.mjs +19 -0
- package/dist/store/indexed.mjs.map +1 -0
- package/dist/store/iterate.d.mts +9 -0
- package/dist/store/iterate.mjs +23 -0
- package/dist/store/iterate.mjs.map +1 -0
- package/dist/store/query-scope.mjs +62 -0
- package/dist/store/query-scope.mjs.map +1 -0
- package/dist/store/query.d.mts +32 -0
- package/dist/store/query.mjs +103 -0
- package/dist/store/query.mjs.map +1 -0
- package/dist/store/session-history.mjs +24 -0
- package/dist/store/session-history.mjs.map +1 -0
- package/dist/store/slack-pr-notifications.d.mts +2 -2
- package/dist/store/slack-pr-notifications.mjs.map +1 -1
- package/dist/store/state.d.mts +26 -0
- package/dist/store/state.mjs +95 -0
- package/dist/store/state.mjs.map +1 -0
- package/dist/store/task-effects.d.mts +1 -1
- package/dist/store/task-graphs.d.mts +1 -1
- package/dist/store/task-messages.d.mts +1 -1
- package/dist/store/task-work.d.mts +1 -1
- package/dist/store/task-work.mjs +1 -1
- package/dist/store/transcripts.mjs +308 -0
- package/dist/store/transcripts.mjs.map +1 -0
- package/dist/sweep.d.mts +1 -1
- package/dist/sweep.mjs +12 -7
- package/dist/sweep.mjs.map +1 -1
- package/dist/task-communication.mjs +15 -1
- package/dist/task-communication.mjs.map +1 -1
- package/dist/task-protocols.d.mts +3 -3
- package/dist/tasks.d.mts +1 -1
- package/dist/vercel.d.mts +1 -1
- package/dist/work-triage/find-existing-work.d.mts +1 -1
- package/dist/work-triage/find-existing-work.mjs +12 -2
- package/dist/work-triage/find-existing-work.mjs.map +1 -1
- package/docs/index.md +4 -0
- package/docs/recipes/persistence-recovery.md +15 -9
- package/docs/recipes/postgres-indexed-engine.md +108 -0
- package/docs/recipes/postgres-indexed-storage.md +145 -0
- package/package.json +3 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find-existing-work.mjs","names":[],"sources":["../../src/work-triage/find-existing-work.ts"],"sourcesContent":["import type { Change } from \"../schema/change\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\nimport type { OpenPullRequest } from \"../pull-requests/contracts\";\nimport type { RepositoryId } from \"../schema/id\";\nimport type { RepositorySlug } from \"../schema/repository\";\n\n/** Provider-neutral readers used to discover work already in progress. */\nexport interface ExistingWorkLookupOptions {\n listOpenPullRequests: (repository: RepositorySlug) => Promise<readonly OpenPullRequest[]>;\n}\n\n/** Stores, repository identity, and provider lookup used for one existing-work query. */\nexport interface FindExistingWorkInput extends ExistingWorkLookupOptions {\n stores: Pick<FactoryStores, \"changes\" | \"repositories\" | \"tasks\">;\n repositoryId: RepositoryId;\n}\n\n/** The refusal or existing pull requests, Changes, and active Tasks found during triage. */\nexport type ExistingWorkLookupResult =\n | { found: false; reasonCode: \"repository_not_found\"; reason: string }\n | {\n found: true;\n openPullRequests: readonly OpenPullRequest[];\n openChanges: readonly Change[];\n activeTasks: readonly Task[];\n };\n\nconst openChangeStates: ReadonlySet<Change[\"state\"]> = new Set([\"verifying\", \"ready\"]);\nconst activeTaskStates: ReadonlySet<Task[\"state\"]> = new Set([\"queued\", \"running\", \"needs_human\"]);\n\n/** Find open pull requests, Changes, and active Tasks for one repository. */\nexport async function findExistingWork(\n input: FindExistingWorkInput,\n): Promise<ExistingWorkLookupResult> {\n const repository = await input.stores.repositories.get(input.repositoryId);\n if (repository === null) {\n return {\n found: false,\n reasonCode: \"repository_not_found\",\n reason: `repository ${input.repositoryId} does not exist`,\n };\n }\n const [openPullRequests, changes, tasks] = await Promise.all([\n input.listOpenPullRequests(repository.slug),\n input.stores.changes.list(),\n input.stores.tasks.list
|
|
1
|
+
{"version":3,"file":"find-existing-work.mjs","names":[],"sources":["../../src/work-triage/find-existing-work.ts"],"sourcesContent":["import { collectFactoryRecords } from \"../store/iterate\";\nimport type { Change } from \"../schema/change\";\nimport type { Task } from \"../schema/task\";\nimport type { FactoryStores } from \"../store/engine\";\nimport type { OpenPullRequest } from \"../pull-requests/contracts\";\nimport type { RepositoryId } from \"../schema/id\";\nimport type { RepositorySlug } from \"../schema/repository\";\n\n/** Provider-neutral readers used to discover work already in progress. */\nexport interface ExistingWorkLookupOptions {\n listOpenPullRequests: (repository: RepositorySlug) => Promise<readonly OpenPullRequest[]>;\n}\n\n/** Stores, repository identity, and provider lookup used for one existing-work query. */\nexport interface FindExistingWorkInput extends ExistingWorkLookupOptions {\n stores: Pick<FactoryStores, \"changes\" | \"repositories\" | \"tasks\">;\n repositoryId: RepositoryId;\n}\n\n/** The refusal or existing pull requests, Changes, and active Tasks found during triage. */\nexport type ExistingWorkLookupResult =\n | { found: false; reasonCode: \"repository_not_found\"; reason: string }\n | {\n found: true;\n openPullRequests: readonly OpenPullRequest[];\n openChanges: readonly Change[];\n activeTasks: readonly Task[];\n };\n\nconst openChangeStates: ReadonlySet<Change[\"state\"]> = new Set([\"verifying\", \"ready\"]);\nconst activeTaskStates: ReadonlySet<Task[\"state\"]> = new Set([\"queued\", \"running\", \"needs_human\"]);\n\n/** Find open pull requests, Changes, and active Tasks for one repository. */\nexport async function findExistingWork(\n input: FindExistingWorkInput,\n): Promise<ExistingWorkLookupResult> {\n const repository = await input.stores.repositories.get(input.repositoryId);\n if (repository === null) {\n return {\n found: false,\n reasonCode: \"repository_not_found\",\n reason: `repository ${input.repositoryId} does not exist`,\n };\n }\n const [openPullRequests, changes, tasks] = await Promise.all([\n input.listOpenPullRequests(repository.slug),\n collectFactoryRecords(input.stores.changes.list, {\n limit: 100,\n match: [...openChangeStates].map((state) => ({ repositoryId: input.repositoryId, state })),\n }),\n collectFactoryRecords(input.stores.tasks.list, {\n limit: 100,\n match: { repositoryIds: [input.repositoryId] },\n }),\n ]);\n return {\n found: true,\n openPullRequests,\n openChanges: changes.filter(\n (change) => change.repositoryId === input.repositoryId && openChangeStates.has(change.state),\n ),\n activeTasks: tasks.filter(\n (task) => task.repositoryIds.includes(input.repositoryId) && activeTaskStates.has(task.state),\n ),\n };\n}\n"],"mappings":";;AA6BA,MAAM,mCAAiD,IAAI,IAAI,CAAC,aAAa,OAAO,CAAC;AACrF,MAAM,mCAA+C,IAAI,IAAI;CAAC;CAAU;CAAW;AAAa,CAAC;;AAGjG,eAAsB,iBACpB,OACmC;CACnC,MAAM,aAAa,MAAM,MAAM,OAAO,aAAa,IAAI,MAAM,YAAY;CACzE,IAAI,eAAe,MACjB,OAAO;EACL,OAAO;EACP,YAAY;EACZ,QAAQ,cAAc,MAAM,aAAa;CAC3C;CAEF,MAAM,CAAC,kBAAkB,SAAS,SAAS,MAAM,QAAQ,IAAI;EAC3D,MAAM,qBAAqB,WAAW,IAAI;EAC1C,sBAAsB,MAAM,OAAO,QAAQ,MAAM;GAC/C,OAAO;GACP,OAAO,CAAC,GAAG,gBAAgB,CAAC,CAAC,KAAK,WAAW;IAAE,cAAc,MAAM;IAAc;GAAM,EAAE;EAC3F,CAAC;EACD,sBAAsB,MAAM,OAAO,MAAM,MAAM;GAC7C,OAAO;GACP,OAAO,EAAE,eAAe,CAAC,MAAM,YAAY,EAAE;EAC/C,CAAC;CACH,CAAC;CACD,OAAO;EACL,OAAO;EACP;EACA,aAAa,QAAQ,QAClB,WAAW,OAAO,iBAAiB,MAAM,gBAAgB,iBAAiB,IAAI,OAAO,KAAK,CAC7F;EACA,aAAa,MAAM,QAChB,SAAS,KAAK,cAAc,SAAS,MAAM,YAAY,KAAK,iBAAiB,IAAI,KAAK,KAAK,CAC9F;CACF;AACF"}
|
package/docs/index.md
CHANGED
|
@@ -32,6 +32,10 @@ lifecycle recovery, and inter-Task communication.
|
|
|
32
32
|
|
|
33
33
|
## Behavioral contracts
|
|
34
34
|
|
|
35
|
+
For the fresh-data storage transition, see [indexed Postgres persistence](recipes/postgres-indexed-storage.md)
|
|
36
|
+
and [the indexed write engine](recipes/postgres-indexed-engine.md). The indexed engine exposes
|
|
37
|
+
bounded source queries and is not a drop-in replacement for the catalog-based reference runtime.
|
|
38
|
+
|
|
35
39
|
The installed declarations are the source of truth for operation semantics. In particular:
|
|
36
40
|
|
|
37
41
|
- `createStores` and `FactoryStores.tasks` document persistence, deduplication, transition fences,
|
|
@@ -172,17 +172,21 @@ async function verify() {
|
|
|
172
172
|
throw new Error("Expected the completed Task to survive another restart");
|
|
173
173
|
}
|
|
174
174
|
const output = greetingWorkflow.output.parse(task.workResult?.output);
|
|
175
|
-
const succeededReceiptsBefore = (
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
const succeededReceiptsBefore = (
|
|
176
|
+
await stores.receipts
|
|
177
|
+
.list({ limit: 100, match: { taskId: task.id } })
|
|
178
|
+
.then((page) => page.items)
|
|
179
|
+
).filter((receipt) => receipt.taskId === task.id && receipt.state === "succeeded").length;
|
|
178
180
|
const replayed = await stores.work.completeWorkflow({
|
|
179
181
|
task: { taskId: task.id, attempt: task.attempt },
|
|
180
182
|
workflow: greetingWorkflow.binding,
|
|
181
183
|
output,
|
|
182
184
|
});
|
|
183
|
-
const succeededReceiptsAfterReplay = (
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
const succeededReceiptsAfterReplay = (
|
|
186
|
+
await stores.receipts
|
|
187
|
+
.list({ limit: 100, match: { taskId: task.id } })
|
|
188
|
+
.then((page) => page.items)
|
|
189
|
+
).filter((receipt) => receipt.taskId === task.id && receipt.state === "succeeded").length;
|
|
186
190
|
|
|
187
191
|
let conflictingOutputRejected = false;
|
|
188
192
|
try {
|
|
@@ -194,9 +198,11 @@ async function verify() {
|
|
|
194
198
|
} catch {
|
|
195
199
|
conflictingOutputRejected = true;
|
|
196
200
|
}
|
|
197
|
-
const succeededReceiptCount = (
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
const succeededReceiptCount = (
|
|
202
|
+
await stores.receipts
|
|
203
|
+
.list({ limit: 100, match: { taskId: task.id } })
|
|
204
|
+
.then((page) => page.items)
|
|
205
|
+
).filter((receipt) => receipt.taskId === task.id && receipt.state === "succeeded").length;
|
|
200
206
|
if (
|
|
201
207
|
replayed.state !== "succeeded" ||
|
|
202
208
|
replayed.workResult?.completedAt !== task.workResult?.completedAt ||
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Indexed Factory writes
|
|
2
|
+
|
|
3
|
+
`createIndexedStores` connects Factory's validating engine to `IndexedStore`. Task admission,
|
|
4
|
+
transitions, receipts, sessions, Changes, transcripts, graph recovery, messages, outbox, and
|
|
5
|
+
workflow records use the same structured backend. Each canonical write commits its compact
|
|
6
|
+
source summary and membership counts atomically. The engine stops writing catalog shards and
|
|
7
|
+
Task-route snapshots. Image bytes use a separately supplied object store.
|
|
8
|
+
|
|
9
|
+
The reference application, scaffold, HTTP API, dispatch, and recovery readers use this engine.
|
|
10
|
+
Canonical `.list(query)` methods require a page size and return `{ items, nextCursor }`.
|
|
11
|
+
The optional `conversations` configuration supplies application routes, continuation agent,
|
|
12
|
+
and the workspace owner for automatic Task attachment.
|
|
13
|
+
There is no backfill, dual write, or Blob fallback in the indexed engine.
|
|
14
|
+
|
|
15
|
+
## Queries and counts
|
|
16
|
+
|
|
17
|
+
`indexes.query` accepts one typed scope and a required page size of 1–100. `indexes.count` reads
|
|
18
|
+
the maintained count for that same scope. Pages contain validated source summaries and versions;
|
|
19
|
+
they do not hydrate canonical records. Use the existing entity `get(id)` methods for point reads.
|
|
20
|
+
|
|
21
|
+
| Source | Additional scopes beyond all records and repository membership |
|
|
22
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
23
|
+
| Task | State (optionally repository), root graph, exact reply destination, source attention (optionally repository) |
|
|
24
|
+
| Session | Owner (optionally repository), Task binding, exact execution |
|
|
25
|
+
| External session | Owner (optionally repository), exact execution |
|
|
26
|
+
| Change | State (optionally repository), creating Task, source attention (optionally repository) |
|
|
27
|
+
| Receipt | Task |
|
|
28
|
+
| Transcript | Source attention; exact execution remains a point read |
|
|
29
|
+
|
|
30
|
+
Authentication and authorization belong to the application. These are trusted APIs, not raw HTTP
|
|
31
|
+
parameters. An owner-scoped session query uses the authenticated owner. Repository membership is
|
|
32
|
+
discovery, not an access grant: a caller must be allowed to read **every** repository on a record.
|
|
33
|
+
An `all` scope requires authority across that collection in the namespace. Cursors carry position,
|
|
34
|
+
not authorization. Unsupported filter combinations and search require indexed read models; do not
|
|
35
|
+
exhaust pages and filter the whole result in memory.
|
|
36
|
+
|
|
37
|
+
Attention here describes **source entities**: Tasks requiring approval/input or failed/stalled;
|
|
38
|
+
ready Changes; transcripts waiting for input or failed. A Task and its Change can both need action.
|
|
39
|
+
These separate counts must not be summed into the UI's conversation count. The integrated `stores.conversations` projection deduplicates conversations, preserves
|
|
40
|
+
workflow-question precedence, and supports bounded policy-filtered pages and maintained facet totals. A transcript's state alone is not an internal session's attention decision.
|
|
41
|
+
|
|
42
|
+
All entities retain the existing validating engine's receipt-first, graph-admission, retry, and
|
|
43
|
+
version-fencing rules. Each canonical write and its derived projections share one storage transaction; external provider
|
|
44
|
+
effects and separate engine operations are not one transaction. Each driver call has
|
|
45
|
+
a fresh operation identity so repeated inserts and stale CAS writes still raise conflicts; the
|
|
46
|
+
Postgres adapter retains that identity while reconciling an ambiguous commit response.
|
|
47
|
+
|
|
48
|
+
The foundation limits still apply: 256 KiB per canonical record, 8 KiB per summary, and 32
|
|
49
|
+
memberships. A Task consumes up to `5 + 3 × repositoryCount` memberships when it needs attention.
|
|
50
|
+
Session notifications, continuations, execution histories, and transcript blocks/chunks use
|
|
51
|
+
separate paged records. Oversized writes fail without committing that record or its memberships.
|
|
52
|
+
Only compact summaries truncate display titles; canonical messages remain intact. Migrations,
|
|
53
|
+
connection management, grants, and environment isolation follow the
|
|
54
|
+
[Postgres foundation guide](postgres-indexed-storage.md).
|
|
55
|
+
|
|
56
|
+
## Runnable local example
|
|
57
|
+
|
|
58
|
+
This example uses PGlite and local image storage, without provider credentials. Use a pooled
|
|
59
|
+
interactive Postgres connection and object storage for images in a deployed application.
|
|
60
|
+
|
|
61
|
+
<!-- runnable-example:start -->
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import assert from "node:assert/strict";
|
|
65
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
66
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
67
|
+
import { createIndexedStores, createInMemoryDriver } from "@vercel/factory/storage";
|
|
68
|
+
import {
|
|
69
|
+
applyFactoryMigrations,
|
|
70
|
+
createPostgresIndexedStore,
|
|
71
|
+
} from "@vercel/factory/storage/postgres";
|
|
72
|
+
import { taskWork } from "@vercel/factory/workflows";
|
|
73
|
+
|
|
74
|
+
const client = new PGlite();
|
|
75
|
+
try {
|
|
76
|
+
const db = drizzle(client);
|
|
77
|
+
await applyFactoryMigrations(db);
|
|
78
|
+
const store = createPostgresIndexedStore({ db, namespace: "indexed-engine-example-v1" });
|
|
79
|
+
const stores = createIndexedStores({ store, images: createInMemoryDriver() });
|
|
80
|
+
const input = {
|
|
81
|
+
repositoryIds: ["repo_example"] as const,
|
|
82
|
+
kind: "investigation",
|
|
83
|
+
origin: { operator: "alice" },
|
|
84
|
+
replyTo: { channel: "api", address: "ses_example" },
|
|
85
|
+
work: taskWork({ title: "Investigate board latency", input: { privateContext: "full input" } }),
|
|
86
|
+
dedupeKey: "example-admission",
|
|
87
|
+
};
|
|
88
|
+
const task = await stores.tasks.create(input);
|
|
89
|
+
assert.equal((await stores.tasks.create(input)).id, task.id);
|
|
90
|
+
await stores.tasks.transition(task.id, "running");
|
|
91
|
+
assert.equal((await stores.tasks.create(input)).state, "running");
|
|
92
|
+
const scope = { collection: "tasks", by: "state", state: "running" } as const;
|
|
93
|
+
const page = await stores.indexes.query({ scope, limit: 30 });
|
|
94
|
+
assert.equal(page.entries.length, 1);
|
|
95
|
+
assert.equal(page.entries[0]?.summary.id, task.id);
|
|
96
|
+
assert.equal(JSON.stringify(page).includes("privateContext"), false);
|
|
97
|
+
assert.equal(await stores.indexes.count(scope), 1);
|
|
98
|
+
assert.equal(
|
|
99
|
+
(await stores.tasks.list({ limit: 1, match: { state: "running" } })).items[0]?.id,
|
|
100
|
+
task.id,
|
|
101
|
+
);
|
|
102
|
+
assert.equal((await stores.tasks.get(task.id))?.work.title, "Investigate board latency");
|
|
103
|
+
} finally {
|
|
104
|
+
await client.close();
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
<!-- runnable-example:end -->
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Indexed Postgres storage
|
|
2
|
+
|
|
3
|
+
Use `createPostgresIndexedStore` for new consumers that need compact ordered pages and maintained
|
|
4
|
+
counts. It implements `IndexedStore`, the bounded contract first developed for the DynamoDB
|
|
5
|
+
foundation. This implementation uses PostgreSQL B-trees, row-version conditions, and transactions.
|
|
6
|
+
The reference runtime uses it through `createIndexedStores`; it never reads a legacy Blob catalog.
|
|
7
|
+
|
|
8
|
+
## Tables and queries
|
|
9
|
+
|
|
10
|
+
`applyFactoryMigrations` adds migration `0001_indexed_storage` with four tables:
|
|
11
|
+
|
|
12
|
+
| Table | Contents |
|
|
13
|
+
| ---------------------------- | ----------------------------------------------------------------- |
|
|
14
|
+
| `factory_indexed_records` | Full canonical JSON, version, namespace/collection/id primary key |
|
|
15
|
+
| `factory_indexed_entries` | Compact summaries, source versions, and ordered index memberships |
|
|
16
|
+
| `factory_indexed_counts` | Transactionally maintained total for each namespace/index |
|
|
17
|
+
| `factory_indexed_operations` | Durable operation fingerprints and committed versions for retries |
|
|
18
|
+
|
|
19
|
+
Entries have a B-tree primary key on `(namespace, index_key, sort_key, collection, id)`. A second
|
|
20
|
+
unique index on `(namespace, collection, id, index_key)` supports replacing one record's memberships.
|
|
21
|
+
Query keys use `C` collation. Queries use tuple cursor comparisons and a limit of 1–100, fetching
|
|
22
|
+
one additional row to determine whether another page exists. `query` returns summaries only. `queryRecords` applies bounded, parameterized JSON predicates
|
|
23
|
+
and ordered point reads; conversation cards are compact records rather than full Tasks.
|
|
24
|
+
Counts read a single counter row; they do not count or download the matching entries.
|
|
25
|
+
|
|
26
|
+
The application supplies each complete membership set, including authorized workspace/owner scope.
|
|
27
|
+
For example, a repository board, its state-filtered board, and its attention list are distinct query
|
|
28
|
+
scopes. The validating engine defines those memberships and the operator conversation schema. Map supported
|
|
29
|
+
filters to exact scopes or add indexed SQL projections during integration; never consume every
|
|
30
|
+
page and filter in application memory. Arbitrary filter combinations and search need their own
|
|
31
|
+
query/index design. Cursors are positions bound to namespace, index, and direction, not access
|
|
32
|
+
tokens. Pages and counts from separate calls do not share a snapshot; mutable entries can move.
|
|
33
|
+
|
|
34
|
+
Migration `0002_indexed_reads` adds count-group metadata and JSON/full-text indexes. Maintained
|
|
35
|
+
facet counts provide totals without hydrating Tasks. Sparse filters can still visit many ordered
|
|
36
|
+
memberships; exact search counts scale with matching compact records. The local benchmark in the
|
|
37
|
+
cutover guide records actual handler timings and SQL plans at 1,000 and 10,000 conversations.
|
|
38
|
+
|
|
39
|
+
## Connection and migration ownership
|
|
40
|
+
|
|
41
|
+
Use Drizzle with `pg.Pool` or Neon’s WebSocket `Pool`, connected to the primary. Interactive
|
|
42
|
+
transactions are required; `drizzle-orm/neon-http` is not supported. The caller owns bounded pool
|
|
43
|
+
size, connection/statement timeouts, TLS, and cleanup. Initialize connections lazily when a build
|
|
44
|
+
does not have database credentials. A pooled URL does not replace the need for interactive
|
|
45
|
+
transaction support. Place the database near the application functions and measure cold starts.
|
|
46
|
+
|
|
47
|
+
Run `applyFactoryMigrations(db)` explicitly with a migration role and serialize migration runners.
|
|
48
|
+
Runtime requests never perform DDL. The runtime role needs schema USAGE and only these grants:
|
|
49
|
+
|
|
50
|
+
```sql
|
|
51
|
+
GRANT SELECT, INSERT, UPDATE ON factory_indexed_records, factory_indexed_counts TO factory_runtime;
|
|
52
|
+
GRANT SELECT, INSERT, DELETE ON factory_indexed_entries TO factory_runtime;
|
|
53
|
+
GRANT SELECT, INSERT ON factory_indexed_operations TO factory_runtime;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Namespaces separate environments and fresh generations logically; they are not database access
|
|
57
|
+
controls. Use separate databases/credentials for Production and Preview. Start the fresh-data
|
|
58
|
+
cutover with a new namespace, retire old pinned work, and avoid backfill, dual writes, or Blob
|
|
59
|
+
fallback. Large files, images, diffs, and report artifacts remain in object storage. Growing transcripts/histories use paged records in the reference runtime. The
|
|
60
|
+
[cutover guide](https://github.com/vercel-labs/agent-factory/blob/code/postgres-migration/docs/postgres-cutover.md) describes preview setup and the promotion boundary.
|
|
61
|
+
|
|
62
|
+
## Write behavior
|
|
63
|
+
|
|
64
|
+
Interactive transactions use serializable isolation with bounded retries for serialization failures
|
|
65
|
+
and deadlocks. One transaction claims the operation ID, applies a version-fenced record write, replaces its
|
|
66
|
+
memberships, adjusts counts, and commits the operation receipt. An insert expects version zero;
|
|
67
|
+
an update expects the current positive version. Concurrent writers cannot both win the same
|
|
68
|
+
version. Identical retries return the original version even after later updates. A different
|
|
69
|
+
intent with the same operation ID raises `StoreOperationConflictError`. After an ambiguous commit
|
|
70
|
+
response, the adapter checks the receipt; if that check is unavailable, retry the identical intent.
|
|
71
|
+
Operation receipts are retained indefinitely. There is no generic delete or automatic cleanup.
|
|
72
|
+
|
|
73
|
+
The engine owns entity schemas, lifecycle rules, immutable records, summary contents, and access
|
|
74
|
+
policy. The adapter validates storage keys and finite JSON, but never infers Task attention.
|
|
75
|
+
Bounds are 256 KiB per record, 8 KiB per summary, 32 memberships, and JSON depth 32. Shared counter
|
|
76
|
+
rows serialize membership changes within a scope; benchmark write contention before activation.
|
|
77
|
+
These limits require splitting growing histories instead of storing an unlimited JSON document.
|
|
78
|
+
|
|
79
|
+
## Runnable local example
|
|
80
|
+
|
|
81
|
+
This example uses PGlite to exercise the published interface without credentials. Install
|
|
82
|
+
`@vercel/factory`, `@electric-sql/pglite`, `drizzle-orm`, `eve`, and `zod`, then run it with Node 24.
|
|
83
|
+
|
|
84
|
+
<!-- runnable-example:start -->
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import assert from "node:assert/strict";
|
|
88
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
89
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
90
|
+
import type { WriteIndexedRecordInput } from "@vercel/factory/storage";
|
|
91
|
+
import {
|
|
92
|
+
applyFactoryMigrations,
|
|
93
|
+
createPostgresIndexedStore,
|
|
94
|
+
} from "@vercel/factory/storage/postgres";
|
|
95
|
+
|
|
96
|
+
const client = new PGlite();
|
|
97
|
+
try {
|
|
98
|
+
const db = drizzle(client);
|
|
99
|
+
await applyFactoryMigrations(db);
|
|
100
|
+
const store = createPostgresIndexedStore({ db, namespace: "local-example-v1" });
|
|
101
|
+
// Values are opaque here; an application validates its full entity before calling storage.
|
|
102
|
+
const input: WriteIndexedRecordInput = {
|
|
103
|
+
collection: "sessions",
|
|
104
|
+
id: "ses_example",
|
|
105
|
+
operationId: "create-example",
|
|
106
|
+
expectedVersion: 0,
|
|
107
|
+
value: { title: "Example conversation", context: "Full data is read by ID" },
|
|
108
|
+
indexes: [
|
|
109
|
+
{
|
|
110
|
+
index: "workspace:example:conversations",
|
|
111
|
+
sortKey: "2026-09-22T12:00:00.000Z",
|
|
112
|
+
summary: { title: "Example conversation" },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
assert.deepEqual(await store.write(input), { version: 1 });
|
|
117
|
+
assert.deepEqual(await store.write(input), { version: 1 });
|
|
118
|
+
assert.equal(await store.count(input.indexes[0]!.index), 1);
|
|
119
|
+
const page = await store.query({ index: input.indexes[0]!.index, limit: 30 });
|
|
120
|
+
assert.equal(page.entries.length, 1);
|
|
121
|
+
assert.equal(page.nextCursor, null);
|
|
122
|
+
assert.deepEqual(page.entries[0]!.summary, { title: "Example conversation" });
|
|
123
|
+
assert.deepEqual((await store.get("sessions", input.id))?.value, input.value);
|
|
124
|
+
} finally {
|
|
125
|
+
await client.close();
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
<!-- runnable-example:end -->
|
|
130
|
+
|
|
131
|
+
## Verification
|
|
132
|
+
|
|
133
|
+
`pnpm check` runs the PGlite storage suite. The example is available for manual local use;
|
|
134
|
+
it is not an additional packed-scaffold or consumer-docs smoke test. CI also uses a pinned
|
|
135
|
+
PostgreSQL 17 service for concurrent transactions, retry/rollback behavior, least-privilege grants,
|
|
136
|
+
and a 10,000-entry `EXPLAIN (ANALYZE, BUFFERS)` index-seek check. To run that suite locally, use an
|
|
137
|
+
isolated PostgreSQL database named `factory_test` on loopback:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
FACTORY_POSTGRES_TEST_URL=postgresql://factory:factory-test@127.0.0.1:55432/factory_test \
|
|
141
|
+
pnpm --filter @vercel/factory exec vitest run src/postgres/indexed.test.ts
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
These checks establish storage correctness and query shape. Deployed board, attention, and chat
|
|
145
|
+
latency at realistic payload sizes and increasing record counts must be measured after integration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/factory",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Schema, state machines, and kernel primitives for Agent Factory.",
|
|
5
5
|
"homepage": "https://github.com/vercel-labs/agent-factory/tree/main/packages/factory",
|
|
6
6
|
"bugs": "https://github.com/vercel-labs/agent-factory/issues",
|
|
@@ -145,10 +145,12 @@
|
|
|
145
145
|
"@ai-sdk/harness-claude-code": "1.0.112",
|
|
146
146
|
"@ai-sdk/sandbox-vercel": "1.0.108",
|
|
147
147
|
"@electric-sql/pglite": "^0.5.7",
|
|
148
|
+
"@types/pg": "^8.23.1",
|
|
148
149
|
"@vercel/blob": "^2.8.0",
|
|
149
150
|
"@vercel/sandbox": "2.10.0-beta.0",
|
|
150
151
|
"drizzle-orm": "^0.45.2",
|
|
151
152
|
"eve": "^0.57.0",
|
|
153
|
+
"pg": "^8.23.0",
|
|
152
154
|
"tsdown": "^0.22.14",
|
|
153
155
|
"typescript": "5.9.3",
|
|
154
156
|
"typescript-current": "npm:typescript@7.0.2",
|