Tools are materialized per connection (the tool rows are keyed by owner/integration/connection); an integration has no tools, it has the config that derives them. So the integration-side staleness marker is about the config that changed, not the tools — name it for the cause. The connection keeps tools_synced_at, which is accurate (connections do have tools). The comparison now reads tools_synced_at < config_revised_at: 'this connection's tools predate the current config'. Pre-release column, so the cloud migration + snapshot are edited in place; the libSQL/D1 hosts pick up the new name via boot schema-ensure.
Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint | 1 | ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint |
| 2 | ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint | 2 | ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint |
| 3 | ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint | 3 | ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint |
| 4 | ALTER TABLE "integration" ADD COLUMN "tools_revised_at" bigint; | 4 | ALTER TABLE "integration" ADD COLUMN "config_revised_at" bigint; |
| @@ -497,8 +497,8 @@ · 496 unmodified lines | |||
| 497 | "primaryKey": false, | 497 | "primaryKey": false, |
| 498 | "notNull": true | 498 | "notNull": true |
| 499 | }, | 499 | }, |
| 500 | "tools_revised_at": { | 500 | "config_revised_at": { |
| 501 | "name": "tools_revised_at", | 501 | "name": "config_revised_at", |
| 502 | "type": "bigint", | 502 | "type": "bigint", |
| 503 | "primaryKey": false, | 503 | "primaryKey": false, |
| 504 | "notNull": false | 504 | "notNull": false |
| @@ -17,7 +17,7 @@ export const integration = pgTable( · 16 unmodified lines | |||
| 17 | plugin_id: text("plugin_id").notNull(), | 17 | plugin_id: text("plugin_id").notNull(), |
| 18 | name: text("name"), | 18 | name: text("name"), |
| 19 | description: text("description").notNull(), | 19 | description: text("description").notNull(), |
| 20 | tools_revised_at: bigint("tools_revised_at", { mode: "bigint" }), | 20 | config_revised_at: bigint("config_revised_at", { mode: "bigint" }), |
| 21 | config: json("config"), | 21 | config: json("config"), |
| 22 | can_remove: boolean("can_remove").notNull().default(true), | 22 | can_remove: boolean("can_remove").notNull().default(true), |
| 23 | can_refresh: boolean("can_refresh").notNull().default(false), | 23 | can_refresh: boolean("can_refresh").notNull().default(false), |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | |||
| 3 | // the new tool catalog on that member's next read — not just the editor's. | 3 | // the new tool catalog on that member's next read — not just the editor's. |
| 4 | // | 4 | // |
| 5 | // This is the lazy-convergence design. Tools are stored per connection. The | 5 | // This is the lazy-convergence design. Tools are stored per connection. The |
| 6 | // editor stamps the integration's `tools_revised_at`, but the owner policy lets | 6 | // editor stamps the integration's `config_revised_at`, but the owner policy lets |
| 7 | // them rebuild catalogs only in their own partition — they cannot write a | 7 | // them rebuild catalogs only in their own partition — they cannot write a |
| 8 | // co-worker's personal connection rows. So each member's connection carries a | 8 | // co-worker's personal connection rows. So each member's connection carries a |
| 9 | // `tools_synced_at` and rebuilds itself the next time THAT member lists tools. | 9 | // `tools_synced_at` and rebuilds itself the next time THAT member lists tools. |
| @@ -23,13 +23,13 @@ const v1Tables = { · 22 unmodified lines | |||
| 23 | }; | 23 | }; |
| 24 | 24 | ||
| 25 | // The running schema adds two nullable columns the old file predates — the | 25 | // The running schema adds two nullable columns the old file predates — the |
| 26 | // shape of the integration-descriptions feature (name + tools_revised_at). | 26 | // shape of the integration-descriptions feature (name + config_revised_at). |
| 27 | const v2Tables = { | 27 | const v2Tables = { |
| 28 | integration: table("integration", { | 28 | integration: table("integration", { |
| 29 | id: idColumn("id", "varchar(255)"), | 29 | id: idColumn("id", "varchar(255)"), |
| 30 | slug: column("slug", "varchar(255)"), | 30 | slug: column("slug", "varchar(255)"), |
| 31 | name: column("name", "string").nullable(), | 31 | name: column("name", "string").nullable(), |
| 32 | tools_revised_at: column("tools_revised_at", "bigint").nullable(), | 32 | config_revised_at: column("config_revised_at", "bigint").nullable(), |
| 33 | }), | 33 | }), |
| 34 | }; | 34 | }; |
| 35 | 35 | ||
| @@ -69,7 +69,7 @@ test("ensure evolves an existing table with the schema's new nullable columns", · 33 unmodified lines | |||
| 69 | }); | 69 | }); |
| 70 | const evolved = await columnNames(client); | 70 | const evolved = await columnNames(client); |
| 71 | expect(evolved).toContain("name"); | 71 | expect(evolved).toContain("name"); |
| 72 | expect(evolved).toContain("tools_revised_at"); | 72 | expect(evolved).toContain("config_revised_at"); |
| 73 | 73 | ||
| 74 | // Idempotent: a second boot tolerates the now-duplicate columns. | 74 | // Idempotent: a second boot tolerates the now-duplicate columns. |
| 75 | await ensureDrizzleRuntimeSchemaFromTables(db, { | 75 | await ensureDrizzleRuntimeSchemaFromTables(db, { |
| @@ -129,7 +129,7 @@ export const coreTables = defineTables({ · 128 unmodified lines | |||
| 129 | // template edit). Compared against each connection's `tools_synced_at` | 129 | // template edit). Compared against each connection's `tools_synced_at` |
| 130 | // so OTHER subjects' connections — whose tool rows the updater cannot | 130 | // so OTHER subjects' connections — whose tool rows the updater cannot |
| 131 | // write under the owner policy — lazily rebuild on their next read. | 131 | // write under the owner policy — lazily rebuild on their next read. |
| 132 | tools_revised_at: nullableBigintColumn("tools_revised_at"), | 132 | config_revised_at: nullableBigintColumn("config_revised_at"), |
| 133 | can_remove: boolColumn("can_remove", true), | 133 | can_remove: boolColumn("can_remove", true), |
| 134 | can_refresh: boolColumn("can_refresh", false), | 134 | can_refresh: boolColumn("can_refresh", false), |
| 135 | created_at: dateColumn("created_at"), | 135 | created_at: dateColumn("created_at"), |
| @@ -156,7 +156,7 @@ export const coreTables = defineTables({ · 20 unmodified lines | |||
| 156 | // create, editable after; never reset by OAuth re-mints. | 156 | // create, editable after; never reset by OAuth re-mints. |
| 157 | description: nullableTextColumn("description"), | 157 | description: nullableTextColumn("description"), |
| 158 | // Epoch ms of the last tool (re)production for this connection. Stale | 158 | // Epoch ms of the last tool (re)production for this connection. Stale |
| 159 | // vs the integration's `tools_revised_at` → re-produced on next read. | 159 | // vs the integration's `config_revised_at` → re-produced on next read. |
| 160 | tools_synced_at: nullableBigintColumn("tools_synced_at"), | 160 | tools_synced_at: nullableBigintColumn("tools_synced_at"), |
| 161 | oauth_client: nullableTextColumn("oauth_client"), | 161 | oauth_client: nullableTextColumn("oauth_client"), |
| 162 | // The OWNER of `oauth_client` (a Personal connection may be minted through | 162 | // The OWNER of `oauth_client` (a Personal connection may be minted through |
| @@ -1759,7 +1759,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1758 unmodified lines | |||
| 1759 | // rebuild catalogs in its own partition (owner policy), so revise | 1759 | // rebuild catalogs in its own partition (owner policy), so revise |
| 1760 | // the integration: other subjects' connections compare this stamp | 1760 | // the integration: other subjects' connections compare this stamp |
| 1761 | // against their `tools_synced_at` and lazily rebuild on next read. | 1761 | // against their `tools_synced_at` and lazily rebuild on next read. |
| 1762 | set.tools_revised_at = now.getTime(); | 1762 | set.config_revised_at = now.getTime(); |
| 1763 | } | 1763 | } |
| 1764 | yield* core.updateMany("integration", { | 1764 | yield* core.updateMany("integration", { |
| 1765 | where: (b: AnyCb) => b("slug", "=", String(slug)), | 1765 | where: (b: AnyCb) => b("slug", "=", String(slug)), |
| @@ -1837,7 +1837,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 71 unmodified lines | |||
| 1837 | b("connection", "=", String(ref.name)), | 1837 | b("connection", "=", String(ref.name)), |
| 1838 | ); | 1838 | ); |
| 1839 | // Every exit stamps the sync time — including the cleanup paths that | 1839 | // Every exit stamps the sync time — including the cleanup paths that |
| 1840 | // produce zero tools — so the stale-catalog check (`tools_revised_at` | 1840 | // produce zero tools — so the stale-catalog check (`config_revised_at` |
| 1841 | // vs `tools_synced_at`) doesn't re-attempt this connection per read. | 1841 | // vs `tools_synced_at`) doesn't re-attempt this connection per read. |
| 1842 | const stampSynced = core.updateMany("connection", { | 1842 | const stampSynced = core.updateMany("connection", { |
| 1843 | where: (b: AnyCb) => | 1843 | where: (b: AnyCb) => |
| @@ -2403,11 +2403,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 559 unmodified lines | |||
| 2403 | // catalog in place and retries on the next read. | 2403 | // catalog in place and retries on the next read. |
| 2404 | const syncStaleConnectionTools = Effect.gen(function* () { | 2404 | const syncStaleConnectionTools = Effect.gen(function* () { |
| 2405 | const revised = yield* core.findMany("integration", { | 2405 | const revised = yield* core.findMany("integration", { |
| 2406 | where: (b: AnyCb) => b.isNotNull("tools_revised_at"), | 2406 | where: (b: AnyCb) => b.isNotNull("config_revised_at"), |
| 2407 | }); | 2407 | }); |
| 2408 | if (revised.length === 0) return; | 2408 | if (revised.length === 0) return; |
| 2409 | const revisedAt = new Map( | 2409 | const revisedAt = new Map( |
| 2410 | revised.map((row) => [row.slug, Number(row.tools_revised_at)] as const), | 2410 | revised.map((row) => [row.slug, Number(row.config_revised_at)] as const), |
| 2411 | ); | 2411 | ); |
| 2412 | const connections = yield* core.findMany("connection", { | 2412 | const connections = yield* core.findMany("connection", { |
| 2413 | where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))), | 2413 | where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))), |
| @@ -1 +1 @@ | |||
| 1 | Subproject commit 894206f09681d77f1d9d708a8d49f1c7dc72fa1f | 1 | Subproject commit 36638da8c104e92cb3522e0dca336618cdfd8205 |