@vendoai/store 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.js ADDED
@@ -0,0 +1,114 @@
1
+ import { pgSchema, text, integer, boolean, jsonb, timestamp, primaryKey, uniqueIndex, index, bigserial } from "drizzle-orm/pg-core";
2
+ export const vendo = pgSchema("vendo");
3
+ export const automations = vendo.table("automations", {
4
+ id: text("id").primaryKey(),
5
+ tenantId: text("tenant_id").notNull(),
6
+ subject: text("subject").notNull(),
7
+ name: text("name").notNull(),
8
+ status: text("status").notNull(),
9
+ disabledReason: text("disabled_reason"),
10
+ spec: jsonb("spec").notNull(),
11
+ currentVersion: integer("current_version").notNull(),
12
+ triggerKind: text("trigger_kind").notNull(),
13
+ triggerKey: text("trigger_key"),
14
+ counters: jsonb("counters").notNull(),
15
+ createdFromThreadId: text("created_from_thread_id"),
16
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).notNull(),
17
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" }).notNull(),
18
+ }, (t) => [index("automations_scope_idx").on(t.tenantId, t.subject), index("automations_trigger_idx").on(t.triggerKind, t.triggerKey)]);
19
+ export const automationVersions = vendo.table("automation_versions", {
20
+ automationId: text("automation_id").notNull(),
21
+ version: integer("version").notNull(),
22
+ spec: jsonb("spec").notNull(),
23
+ dslVersion: integer("dsl_version").notNull(),
24
+ manifestHash: text("manifest_hash"),
25
+ grants: jsonb("grants").notNull(),
26
+ createdBy: text("created_by").notNull(),
27
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).notNull(),
28
+ }, (t) => [primaryKey({ columns: [t.automationId, t.version] })]);
29
+ export const automationRuns = vendo.table("automation_runs", {
30
+ id: text("id").primaryKey(), // firingRunId — DB-level double-fire dedup
31
+ automationId: text("automation_id").notNull(),
32
+ tenantId: text("tenant_id").notNull(),
33
+ subject: text("subject").notNull(),
34
+ version: integer("version").notNull(),
35
+ manifestHash: text("manifest_hash"),
36
+ status: text("status").notNull(),
37
+ outcome: text("outcome"),
38
+ trigger: jsonb("trigger").notNull(),
39
+ steps: jsonb("steps").notNull(),
40
+ pendingApproval: jsonb("pending_approval"),
41
+ error: text("error"),
42
+ isTest: boolean("is_test").notNull(),
43
+ /** ENG-193 §4.6 — cumulative count of parked actions this run created. */
44
+ parkedCount: integer("parked_count").notNull().default(0),
45
+ startedAt: timestamp("started_at", { withTimezone: true, mode: "string" }).notNull(),
46
+ finishedAt: timestamp("finished_at", { withTimezone: true, mode: "string" }),
47
+ }, (t) => [index("runs_automation_idx").on(t.automationId, t.tenantId, t.subject)]);
48
+ /** ENG-193 §4.6 — parked automation drafts: an action a for_each iteration or
49
+ * agent step couldn't run unattended, resolved standalone later. Indexed
50
+ * columns cover the list filters; the full ParkedAction payload (frozen
51
+ * input, guard bindings, tier, hashes, resolution detail) lives in `record`
52
+ * jsonb, mirrored the way `saved_vendos.record` already is. */
53
+ export const parkedActions = vendo.table("parked_actions", {
54
+ id: text("id").primaryKey(),
55
+ tenantId: text("tenant_id").notNull(),
56
+ subject: text("subject").notNull(),
57
+ automationId: text("automation_id").notNull(),
58
+ runId: text("run_id").notNull(),
59
+ resolution: text("resolution"),
60
+ requestedAt: timestamp("requested_at", { withTimezone: true, mode: "string" }).notNull(),
61
+ record: jsonb("record").notNull(),
62
+ }, (t) => [index("parked_actions_scope_idx").on(t.tenantId, t.subject, t.automationId, t.runId)]);
63
+ export const decisions = vendo.table("decisions", {
64
+ tenantId: text("tenant_id").notNull(),
65
+ subject: text("subject").notNull(),
66
+ canonicalKey: text("canonical_key").notNull(),
67
+ decision: jsonb("decision").notNull(),
68
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).notNull(),
69
+ }, (t) => [primaryKey({ columns: [t.tenantId, t.subject, t.canonicalKey] })]);
70
+ export const threads = vendo.table("threads", {
71
+ id: text("id").notNull(),
72
+ tenantId: text("tenant_id").notNull(),
73
+ subject: text("subject").notNull(),
74
+ title: text("title"),
75
+ nextSeq: integer("next_seq").notNull().default(0),
76
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).notNull(),
77
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" }).notNull(),
78
+ }, (t) => [primaryKey({ columns: [t.tenantId, t.subject, t.id] })]);
79
+ export const threadMessages = vendo.table("thread_messages", {
80
+ rowId: bigserial("row_id", { mode: "number" }).primaryKey(),
81
+ tenantId: text("tenant_id").notNull(),
82
+ subject: text("subject").notNull(),
83
+ threadId: text("thread_id").notNull(),
84
+ messageId: text("message_id").notNull(),
85
+ seq: integer("seq").notNull(),
86
+ message: jsonb("message").notNull(),
87
+ }, (t) => [
88
+ uniqueIndex("thread_messages_id_uq").on(t.tenantId, t.subject, t.threadId, t.messageId),
89
+ uniqueIndex("thread_messages_seq_uq").on(t.tenantId, t.subject, t.threadId, t.seq),
90
+ ]);
91
+ export const savedVendos = vendo.table("saved_vendos", {
92
+ id: text("id").notNull(),
93
+ tenantId: text("tenant_id").notNull(),
94
+ subject: text("subject").notNull(),
95
+ record: jsonb("record").notNull(),
96
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" }).notNull(),
97
+ }, (t) => [primaryKey({ columns: [t.tenantId, t.subject, t.id] })]);
98
+ export const connections = vendo.table("connections", {
99
+ toolkit: text("toolkit").notNull(),
100
+ tenantId: text("tenant_id").notNull(),
101
+ subject: text("subject").notNull(),
102
+ connectedAccountId: text("connected_account_id"),
103
+ status: text("status").notNull(),
104
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }).notNull(),
105
+ }, (t) => [
106
+ primaryKey({ columns: [t.tenantId, t.subject, t.toolkit] }),
107
+ index("connections_account_idx").on(t.connectedAccountId),
108
+ ]);
109
+ /** Tiny operational KV (scheduler heartbeat, future flags). NOT for domain data. */
110
+ export const meta = vendo.table("meta", {
111
+ key: text("key").primaryKey(),
112
+ value: jsonb("value").notNull(),
113
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" }).notNull(),
114
+ });
@@ -0,0 +1,5 @@
1
+ import type { ThreadStore } from "@vendoai/core";
2
+ import type { VendoDb } from "./db.js";
3
+ export declare function createDrizzleThreadStore(handle: VendoDb, opts?: {
4
+ now?: () => string;
5
+ }): ThreadStore;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * DrizzleThreadStore — durable port of the core `ThreadStore` seam
3
+ * (packages/vendo-core/src/seams/store.ts). Behavioral spec:
4
+ * InMemoryThreadStore (packages/vendo-runtime/src/embedded/in-memory-store.ts).
5
+ *
6
+ * Seq allocation is race-safe: `threads.nextSeq` is reserved with an atomic
7
+ * `UPDATE ... SET next_seq = next_seq + n RETURNING next_seq` inside a
8
+ * transaction, never `MAX(seq)+1` (which double-allocates under concurrency —
9
+ * see the Task 9 plan note). `upsertMessages` reserves seqs ONLY for messages
10
+ * that are genuinely new; pre-existing message ids are updated in place
11
+ * (message column only — their seq, and therefore position, never moves).
12
+ * Existing-vs-new is resolved inside the same transaction as the reservation,
13
+ * so two concurrent upserts of disjoint message ids always get disjoint seq
14
+ * ranges without ever touching the `thread_messages_seq_uq` index twice for
15
+ * the same value (a single INSERT ... ON CONFLICT covering both new and
16
+ * pre-existing rows would risk exactly that: passing an existing row's own
17
+ * seq back through the VALUES list can collide with the seq-uniqueness index,
18
+ * which isn't the ON CONFLICT arbiter and so isn't suppressed — Postgres still
19
+ * raises 23505 for a violation on a non-arbiter unique index. Splitting the
20
+ * write into "plain UPDATE for existing ids" + "plain INSERT of freshly
21
+ * reserved seqs for new ids" avoids that path entirely.)
22
+ *
23
+ * Remaining race: two CONCURRENT txns can both run their existing-ids SELECT
24
+ * before either commits and both classify the SAME message id as "new" —
25
+ * each reserves its own seq block and both attempt to INSERT that message
26
+ * id. The new-row INSERT below carries an `ON CONFLICT (tenant_id, subject,
27
+ * thread_id, message_id) DO UPDATE` naming that exact unique index as its
28
+ * arbiter, so the loser's insert becomes an UPDATE of the winner's row
29
+ * (message content only) instead of a 23505 unique-violation. The loser's
30
+ * reserved seq is simply never used — a benign gap in the sequence, not a
31
+ * correctness issue (seq only needs to be strictly ordered, not contiguous).
32
+ */
33
+ import { randomUUID } from "node:crypto";
34
+ import { and, eq, inArray, sql } from "drizzle-orm";
35
+ import { threadMessages, threads } from "./schema.js";
36
+ import { toIso } from "./automation-store.js";
37
+ function rowToThread(row) {
38
+ const record = {
39
+ id: row.id,
40
+ tenantId: row.tenantId,
41
+ subject: row.subject,
42
+ createdAt: toIso(row.createdAt),
43
+ updatedAt: toIso(row.updatedAt),
44
+ };
45
+ if (row.title != null)
46
+ record.title = row.title;
47
+ return record;
48
+ }
49
+ export function createDrizzleThreadStore(handle, opts = {}) {
50
+ const db = handle.db;
51
+ const now = opts.now ?? (() => new Date().toISOString());
52
+ function withTransaction(fn) {
53
+ return handle.db.transaction((tx) => fn(tx));
54
+ }
55
+ async function selectThreadRow(tx, scope, threadId) {
56
+ const rows = await tx
57
+ .select()
58
+ .from(threads)
59
+ .where(and(eq(threads.tenantId, scope.tenantId), eq(threads.subject, scope.subject), eq(threads.id, threadId)));
60
+ return rows[0];
61
+ }
62
+ /** Atomically reserves `n` seqs on the thread row and returns the first one
63
+ * in the reserved block. Raw SQL (like `claimPendingApproval` in
64
+ * automation-store.ts) because drizzle's `.returning()` doesn't type-check
65
+ * cleanly against the pglite/node-postgres union `Db` bridge type. */
66
+ async function reserveSeqs(tx, scope, threadId, n, at) {
67
+ const result = await tx.execute(sql `
68
+ UPDATE vendo.threads
69
+ SET next_seq = next_seq + ${n}, updated_at = ${at}
70
+ WHERE tenant_id = ${scope.tenantId} AND subject = ${scope.subject} AND id = ${threadId}
71
+ RETURNING next_seq
72
+ `);
73
+ const rows = result.rows;
74
+ const reserved = rows[0]?.next_seq ?? n;
75
+ return reserved - n;
76
+ }
77
+ return {
78
+ async create(scope, init = {}) {
79
+ const id = randomUUID();
80
+ const createdAt = now();
81
+ await db.insert(threads).values({
82
+ id,
83
+ tenantId: scope.tenantId,
84
+ subject: scope.subject,
85
+ title: init.title ?? null,
86
+ nextSeq: 0,
87
+ createdAt,
88
+ updatedAt: createdAt,
89
+ });
90
+ const record = {
91
+ id,
92
+ tenantId: scope.tenantId,
93
+ subject: scope.subject,
94
+ createdAt,
95
+ updatedAt: createdAt,
96
+ };
97
+ if (init.title !== undefined)
98
+ record.title = init.title;
99
+ return record;
100
+ },
101
+ async get(scope, threadId) {
102
+ const row = await selectThreadRow(db, scope, threadId);
103
+ return row ? rowToThread(row) : undefined;
104
+ },
105
+ async list(scope) {
106
+ const rows = await db
107
+ .select()
108
+ .from(threads)
109
+ .where(and(eq(threads.tenantId, scope.tenantId), eq(threads.subject, scope.subject)));
110
+ return rows.map(rowToThread);
111
+ },
112
+ async appendMessages(scope, threadId, messages) {
113
+ const owned = await selectThreadRow(db, scope, threadId);
114
+ if (!owned) {
115
+ throw new Error(`unknown thread "${threadId}" for scope ${scope.tenantId}/${scope.subject}`);
116
+ }
117
+ if (messages.length === 0)
118
+ return;
119
+ await withTransaction(async (tx) => {
120
+ const at = now();
121
+ const startSeq = await reserveSeqs(tx, scope, threadId, messages.length, at);
122
+ await tx.insert(threadMessages).values(messages.map((m, i) => ({
123
+ tenantId: scope.tenantId,
124
+ subject: scope.subject,
125
+ threadId,
126
+ messageId: m.id,
127
+ seq: startSeq + i,
128
+ message: m,
129
+ })));
130
+ });
131
+ },
132
+ async getMessages(scope, threadId) {
133
+ const rows = await db
134
+ .select()
135
+ .from(threadMessages)
136
+ .where(and(eq(threadMessages.tenantId, scope.tenantId), eq(threadMessages.subject, scope.subject), eq(threadMessages.threadId, threadId)))
137
+ .orderBy(threadMessages.seq);
138
+ return rows.map((row) => row.message);
139
+ },
140
+ async upsertMessages(scope, threadId, messages) {
141
+ if (messages.length === 0)
142
+ return;
143
+ await withTransaction(async (tx) => {
144
+ const at = now();
145
+ const existingThread = await selectThreadRow(tx, scope, threadId);
146
+ if (!existingThread) {
147
+ // The client owns thread ids for upsert (ai-SDK resume writes before
148
+ // any explicit create() call) — an unrecognized threadId is a
149
+ // first-write, not an error. `onConflictDoNothing` guards the (rare)
150
+ // race of two concurrent first-writes to the same unknown id.
151
+ await tx
152
+ .insert(threads)
153
+ .values({
154
+ id: threadId,
155
+ tenantId: scope.tenantId,
156
+ subject: scope.subject,
157
+ nextSeq: 0,
158
+ createdAt: at,
159
+ updatedAt: at,
160
+ })
161
+ .onConflictDoNothing();
162
+ }
163
+ const ids = messages.map((m) => m.id);
164
+ const existingRows = await tx
165
+ .select({ messageId: threadMessages.messageId })
166
+ .from(threadMessages)
167
+ .where(and(eq(threadMessages.tenantId, scope.tenantId), eq(threadMessages.subject, scope.subject), eq(threadMessages.threadId, threadId), inArray(threadMessages.messageId, ids)));
168
+ const existingIds = new Set(existingRows.map((r) => r.messageId));
169
+ const existingMessages = messages.filter((m) => existingIds.has(m.id));
170
+ const newMessages = messages.filter((m) => !existingIds.has(m.id));
171
+ // Update pre-existing rows in place: message column only, seq (and
172
+ // therefore position) is never touched.
173
+ for (const m of existingMessages) {
174
+ await tx
175
+ .update(threadMessages)
176
+ .set({ message: m })
177
+ .where(and(eq(threadMessages.tenantId, scope.tenantId), eq(threadMessages.subject, scope.subject), eq(threadMessages.threadId, threadId), eq(threadMessages.messageId, m.id)));
178
+ }
179
+ if (newMessages.length > 0) {
180
+ const startSeq = await reserveSeqs(tx, scope, threadId, newMessages.length, at);
181
+ await tx
182
+ .insert(threadMessages)
183
+ .values(newMessages.map((m, i) => ({
184
+ tenantId: scope.tenantId,
185
+ subject: scope.subject,
186
+ threadId,
187
+ messageId: m.id,
188
+ seq: startSeq + i,
189
+ message: m,
190
+ })))
191
+ // Concurrent-same-new-message race: see the module doc. The
192
+ // arbiter is the message-id unique index, not the seq one, so
193
+ // this can never suppress a genuine seq collision.
194
+ .onConflictDoUpdate({
195
+ target: [threadMessages.tenantId, threadMessages.subject, threadMessages.threadId, threadMessages.messageId],
196
+ set: { message: sql `excluded.message` },
197
+ });
198
+ }
199
+ else {
200
+ // Still bump updatedAt even when nothing new was reserved.
201
+ await tx
202
+ .update(threads)
203
+ .set({ updatedAt: at })
204
+ .where(and(eq(threads.tenantId, scope.tenantId), eq(threads.subject, scope.subject), eq(threads.id, threadId)));
205
+ }
206
+ });
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,5 @@
1
+ import type { SavedVendoStore } from "@vendoai/core";
2
+ import type { VendoDb } from "./db.js";
3
+ export declare function createDrizzleSavedVendoStore(handle: VendoDb, opts?: {
4
+ now?: () => string;
5
+ }): SavedVendoStore;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * DrizzleSavedVendoStore — durable port of the core `SavedVendoStore`
3
+ * seam (packages/vendo-core/src/seams/store.ts). Behavioral spec:
4
+ * InMemorySavedVendoStore (packages/vendo-runtime/src/embedded/in-memory-store.ts).
5
+ *
6
+ * The whole `SavedVendo` record (uiTree, query, originatingPrompt, etc.)
7
+ * lives verbatim in the `record` jsonb column; `updatedAt` is denormalized
8
+ * onto its own column purely so `list()` can order by it without unpacking
9
+ * jsonb per row.
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { and, desc, eq } from "drizzle-orm";
13
+ import { savedVendos } from "./schema.js";
14
+ export function createDrizzleSavedVendoStore(handle, opts = {}) {
15
+ const db = handle.db;
16
+ const now = opts.now ?? (() => new Date().toISOString());
17
+ return {
18
+ async save(scope, vendo) {
19
+ const id = randomUUID();
20
+ const createdAt = now();
21
+ const record = { ...vendo, id, createdAt, updatedAt: createdAt };
22
+ await db.insert(savedVendos).values({
23
+ id,
24
+ tenantId: scope.tenantId,
25
+ subject: scope.subject,
26
+ record,
27
+ updatedAt: createdAt,
28
+ });
29
+ return record;
30
+ },
31
+ async get(scope, id) {
32
+ const rows = await db
33
+ .select()
34
+ .from(savedVendos)
35
+ .where(and(eq(savedVendos.tenantId, scope.tenantId), eq(savedVendos.subject, scope.subject), eq(savedVendos.id, id)));
36
+ return rows[0] ? rows[0].record : undefined;
37
+ },
38
+ async list(scope) {
39
+ const rows = await db
40
+ .select()
41
+ .from(savedVendos)
42
+ .where(and(eq(savedVendos.tenantId, scope.tenantId), eq(savedVendos.subject, scope.subject)))
43
+ .orderBy(desc(savedVendos.updatedAt));
44
+ return rows.map((row) => row.record);
45
+ },
46
+ async delete(scope, id) {
47
+ await db
48
+ .delete(savedVendos)
49
+ .where(and(eq(savedVendos.tenantId, scope.tenantId), eq(savedVendos.subject, scope.subject), eq(savedVendos.id, id)));
50
+ },
51
+ };
52
+ }
File without changes
@@ -0,0 +1,110 @@
1
+ CREATE SCHEMA IF NOT EXISTS "vendo";
2
+ --> statement-breakpoint
3
+ CREATE TABLE IF NOT EXISTS "vendo"."automation_runs" (
4
+ "id" text PRIMARY KEY NOT NULL,
5
+ "automation_id" text NOT NULL,
6
+ "tenant_id" text NOT NULL,
7
+ "subject" text NOT NULL,
8
+ "version" integer NOT NULL,
9
+ "manifest_hash" text,
10
+ "status" text NOT NULL,
11
+ "outcome" text,
12
+ "trigger" jsonb NOT NULL,
13
+ "steps" jsonb NOT NULL,
14
+ "pending_approval" jsonb,
15
+ "error" text,
16
+ "is_test" boolean NOT NULL,
17
+ "started_at" timestamp with time zone NOT NULL,
18
+ "finished_at" timestamp with time zone
19
+ );
20
+ --> statement-breakpoint
21
+ CREATE TABLE IF NOT EXISTS "vendo"."automation_versions" (
22
+ "automation_id" text NOT NULL,
23
+ "version" integer NOT NULL,
24
+ "spec" jsonb NOT NULL,
25
+ "dsl_version" integer NOT NULL,
26
+ "manifest_hash" text,
27
+ "grants" jsonb NOT NULL,
28
+ "created_by" text NOT NULL,
29
+ "created_at" timestamp with time zone NOT NULL,
30
+ CONSTRAINT "automation_versions_automation_id_version_pk" PRIMARY KEY("automation_id","version")
31
+ );
32
+ --> statement-breakpoint
33
+ CREATE TABLE IF NOT EXISTS "vendo"."automations" (
34
+ "id" text PRIMARY KEY NOT NULL,
35
+ "tenant_id" text NOT NULL,
36
+ "subject" text NOT NULL,
37
+ "name" text NOT NULL,
38
+ "status" text NOT NULL,
39
+ "disabled_reason" text,
40
+ "spec" jsonb NOT NULL,
41
+ "current_version" integer NOT NULL,
42
+ "trigger_kind" text NOT NULL,
43
+ "trigger_key" text,
44
+ "counters" jsonb NOT NULL,
45
+ "created_from_thread_id" text,
46
+ "created_at" timestamp with time zone NOT NULL,
47
+ "updated_at" timestamp with time zone NOT NULL
48
+ );
49
+ --> statement-breakpoint
50
+ CREATE TABLE IF NOT EXISTS "vendo"."connections" (
51
+ "toolkit" text NOT NULL,
52
+ "tenant_id" text NOT NULL,
53
+ "subject" text NOT NULL,
54
+ "connected_account_id" text,
55
+ "status" text NOT NULL,
56
+ "created_at" timestamp with time zone NOT NULL,
57
+ CONSTRAINT "connections_tenant_id_subject_toolkit_pk" PRIMARY KEY("tenant_id","subject","toolkit")
58
+ );
59
+ --> statement-breakpoint
60
+ CREATE TABLE IF NOT EXISTS "vendo"."decisions" (
61
+ "tenant_id" text NOT NULL,
62
+ "subject" text NOT NULL,
63
+ "canonical_key" text NOT NULL,
64
+ "decision" jsonb NOT NULL,
65
+ "created_at" timestamp with time zone NOT NULL,
66
+ CONSTRAINT "decisions_tenant_id_subject_canonical_key_pk" PRIMARY KEY("tenant_id","subject","canonical_key")
67
+ );
68
+ --> statement-breakpoint
69
+ CREATE TABLE IF NOT EXISTS "vendo"."meta" (
70
+ "key" text PRIMARY KEY NOT NULL,
71
+ "value" jsonb NOT NULL,
72
+ "updated_at" timestamp with time zone NOT NULL
73
+ );
74
+ --> statement-breakpoint
75
+ CREATE TABLE IF NOT EXISTS "vendo"."saved_vendos" (
76
+ "id" text NOT NULL,
77
+ "tenant_id" text NOT NULL,
78
+ "subject" text NOT NULL,
79
+ "record" jsonb NOT NULL,
80
+ "updated_at" timestamp with time zone NOT NULL,
81
+ CONSTRAINT "saved_vendos_tenant_id_subject_id_pk" PRIMARY KEY("tenant_id","subject","id")
82
+ );
83
+ --> statement-breakpoint
84
+ CREATE TABLE IF NOT EXISTS "vendo"."thread_messages" (
85
+ "row_id" bigserial PRIMARY KEY NOT NULL,
86
+ "tenant_id" text NOT NULL,
87
+ "subject" text NOT NULL,
88
+ "thread_id" text NOT NULL,
89
+ "message_id" text NOT NULL,
90
+ "seq" integer NOT NULL,
91
+ "message" jsonb NOT NULL
92
+ );
93
+ --> statement-breakpoint
94
+ CREATE TABLE IF NOT EXISTS "vendo"."threads" (
95
+ "id" text NOT NULL,
96
+ "tenant_id" text NOT NULL,
97
+ "subject" text NOT NULL,
98
+ "title" text,
99
+ "next_seq" integer DEFAULT 0 NOT NULL,
100
+ "created_at" timestamp with time zone NOT NULL,
101
+ "updated_at" timestamp with time zone NOT NULL,
102
+ CONSTRAINT "threads_tenant_id_subject_id_pk" PRIMARY KEY("tenant_id","subject","id")
103
+ );
104
+ --> statement-breakpoint
105
+ CREATE INDEX IF NOT EXISTS "runs_automation_idx" ON "vendo"."automation_runs" USING btree ("automation_id","tenant_id","subject");--> statement-breakpoint
106
+ CREATE INDEX IF NOT EXISTS "automations_scope_idx" ON "vendo"."automations" USING btree ("tenant_id","subject");--> statement-breakpoint
107
+ CREATE INDEX IF NOT EXISTS "automations_trigger_idx" ON "vendo"."automations" USING btree ("trigger_kind","trigger_key");--> statement-breakpoint
108
+ CREATE INDEX IF NOT EXISTS "connections_account_idx" ON "vendo"."connections" USING btree ("connected_account_id");--> statement-breakpoint
109
+ CREATE UNIQUE INDEX IF NOT EXISTS "thread_messages_id_uq" ON "vendo"."thread_messages" USING btree ("tenant_id","subject","thread_id","message_id");--> statement-breakpoint
110
+ CREATE UNIQUE INDEX IF NOT EXISTS "thread_messages_seq_uq" ON "vendo"."thread_messages" USING btree ("tenant_id","subject","thread_id","seq");
@@ -0,0 +1,13 @@
1
+ CREATE TABLE IF NOT EXISTS "vendo"."parked_actions" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "tenant_id" text NOT NULL,
4
+ "subject" text NOT NULL,
5
+ "automation_id" text NOT NULL,
6
+ "run_id" text NOT NULL,
7
+ "resolution" text,
8
+ "requested_at" timestamp with time zone NOT NULL,
9
+ "record" jsonb NOT NULL
10
+ );
11
+ --> statement-breakpoint
12
+ ALTER TABLE "vendo"."automation_runs" ADD COLUMN "parked_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
13
+ CREATE INDEX IF NOT EXISTS "parked_actions_scope_idx" ON "vendo"."parked_actions" USING btree ("tenant_id","subject","automation_id","run_id");