diff --git a/.changeset/safe-conflict-tenant.md b/.changeset/safe-conflict-tenant.md new file mode 100644 index 0000000..b4daf32 --- /dev/null +++ b/.changeset/safe-conflict-tenant.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": patch +--- + +An upsert is single-tenant only when its conflict-update path also proves the existing row's tenant. A tenant-inclusive column target or an explicit tenant predicate can prove it; otherwise `requireTenantScope` refuses the statement before execution. This prevents an insert for one tenant from updating another tenant through a shared conflicting ID. diff --git a/.changeset/safe-sqlite-rebuild.md b/.changeset/safe-sqlite-rebuild.md new file mode 100644 index 0000000..7e43caf --- /dev/null +++ b/.changeset/safe-sqlite-rebuild.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": patch +--- + +Generated SQLite rebuilds check live inbound foreign keys before copying or dropping the old table. A referenced table is refused instead of letting a transaction-wrapped DROP fire CASCADE or SET NULL actions. The guard works in a D1 batch and rolls back with the migration; ordinary rebuilds still run. Older generated SQL must be reviewed or regenerated before applying it. diff --git a/docs/inserts.md b/docs/inserts.md index 407ba91..f60b88c 100644 --- a/docs/inserts.md +++ b/docs/inserts.md @@ -219,7 +219,12 @@ Calling it again replaces them. Postgres has no insert settings, so compiling on An insert has a `tenantScope` like a query, worked out the same way. On a table with a `tenantColumn`, it is `"single-tenant"` when every row gives that column the same value or the same param, and `"cross-tenant"` when rows differ or a row uses another expression. An -`onConflictDoUpdate` that sets the tenant column counts as one more row. +`onConflictDoUpdate` that sets the tenant column counts as one more row. It must also prove +that the existing conflict row has that tenant: include the tenant column in the conflict +target, or pin it in the update's `where`, for example +`where: (existing, excluded) => existing.orgId.eq(excluded.orgId)`. A constraint name alone +does not prove its columns. Without this evidence the upsert is `"cross-tenant"`, even if +the inserted values name one tenant. OR conditions do not supply tenant evidence. An `INSERT ... SELECT` into a tenant table is `"single-tenant"` when the SELECT is, and each row takes its tenant from a tenant column of the source or from the same param that pins the SELECT. diff --git a/docs/migrations.md b/docs/migrations.md index f661350..ddbef84 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -450,7 +450,13 @@ What each change becomes: | New or changed index | `DROP INDEX`, `CREATE [UNIQUE] INDEX` | | Renamed table or column | `ALTER TABLE ... RENAME`, only with a `confirm_rename` hint | -A rebuild follows [SQLite's procedure](https://www.sqlite.org/lang_altertable.html#otheralter) +A rebuild first checks the live catalog for tables that reference it through a foreign key, +including keys created by another tool and self-references. Any inbound key refuses the rebuild +before rows are copied or the old table is dropped, even if its child table is empty. The error +names `foreign_key_references_prevent_rebuild`. Use a custom migration with a tested plan that +preserves those dependencies instead. + +With no inbound keys, the rebuild follows [SQLite's procedure](https://www.sqlite.org/lang_altertable.html#otheralter) the way drizzle-kit writes it: `PRAGMA foreign_keys=OFF`, `CREATE TABLE "__new_"` with the new definition, `INSERT INTO "__new_
" (...) SELECT ...` for every column both tables have, `DROP TABLE`, `ALTER TABLE "__new_
" RENAME TO "
"`, `PRAGMA foreign_keys=ON`, @@ -462,12 +468,17 @@ a new UNIQUE constraint over duplicate values. A column a rebuild leaves out nee > [!WARNING] > `PRAGMA foreign_keys=OFF` does nothing inside a transaction, and D1 and most migrators run each -> migration in one. There, the rebuild's `DROP TABLE` deletes every row of the old table first, so -> a table that references it with `ON DELETE CASCADE` loses its rows, and `ON DELETE SET NULL` -> clears them. effect-orm declares no foreign keys, but a database may have them from another -> tool. Before applying a rebuild of a referenced table, check for such keys -> (`SELECT * FROM pragma_foreign_key_list('')`) and, if there are any, run the rebuild -> outside a transaction with foreign keys off. +> migration in one. Without the guard, the rebuild's `DROP TABLE` would fire `ON DELETE CASCADE` +> or `ON DELETE SET NULL` in referencing tables. Running outside a transaction is not an option +> for a D1 batch, and `defer_foreign_keys` does not prevent those actions. + +Apply a migration's statements as one transaction (one `batch` on D1), and roll back on failure. +The guard uses a short-lived `__effect_orm_rebuild_guard_
` table with a NOT NULL assertion, +so it still works with `ignore_check_constraints` enabled. It drops the guard before a safe +rebuild; a refused transaction rolls it back. A pre-existing table with that name causes an +error without reusing or dropping it. This guard is in newly generated SQL: review or regenerate +unapplied rebuilds generated by older versions. It checks inbound foreign keys; it does not +preserve unmodelled triggers, views or other constraints. A rebuild renumbers rowids: the copy may read the old rows in any order. A table whose rows are referenced by rowid (rather than by a declared key) should declare an `integer` primary key, which diff --git a/src/ch/compile.ts b/src/ch/compile.ts index cf4ca5b..6055f06 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -1725,6 +1725,7 @@ const onConflictClause = ( insert: CHInsert, cell: (column: string, value: unknown, context: string) => string, wrote: (column: string, value: unknown, sql: string) => void, + checked: (predicates: ReadonlyArray) => void, ): string => { const { table, conflict } = insert._state if (conflict === undefined) return "" @@ -1763,11 +1764,12 @@ const onConflictClause = ( } if (conflict.action === "nothing") return `\nON CONFLICT${target} DO NOTHING` - const existing = createQualifiedColumnAccessor(table.name, undefined, table.columns) - const excluded = createQualifiedColumnAccessor("excluded", undefined, table.columns) + const existing = createQualifiedColumnAccessor(table.name, table.tenantColumn, table.columns) + const excluded = createQualifiedColumnAccessor("excluded", table.tenantColumn, table.columns) const set = typeof conflict.set === "function" ? conflict.set(existing, excluded) : conflict.set const assignments = setAssignments(table, set as Record, cell, wrote, where, "onConflictDoUpdate") const condition = conflict.where?.(existing, excluded) + checked(condition === undefined ? [] : tenantPredicatesOf(condition)) return `\nON CONFLICT${target} DO UPDATE SET ${assignments.join(", ")}${ condition === undefined ? "" : ` WHERE ${compileSqlFragment(condition.toFragment())}` }` @@ -1976,9 +1978,8 @@ function compileInsert(insert: CHInsert, params: Record() let pinned = tenant !== undefined @@ -1990,6 +1991,7 @@ function compileInsert(insert: CHInsert, params: Record = [] let readBound: string | undefined + let conflictPredicates: ReadonlyArray = [] const [columns, source, conflictSql, readScope] = withSubqueryCompiler( recordingSubqueries(values, reads), () => { @@ -2034,9 +2036,10 @@ function compileInsert(insert: CHInsert, params: Record { - if (column === tenant) pin(value, sql) - }) + const conflictSql = onConflictClause(insert, cell, + (column, value, sql) => { if (column === tenant) pin(value, sql) }, + (predicates) => { conflictPredicates = predicates }, + ) return [columns, source, conflictSql, readScope] as const }, ) @@ -2047,12 +2050,28 @@ function compileInsert(insert: CHInsert, params: Record inlineParams(compileSqlFragment(value), values)) + // SQLite resolves identifiers without case; a table named excluded can + // make the apparent existing/excluded equality a tautology. + if (table.name.split(".").at(-1)?.toLowerCase() === "excluded" || updateScope.scope !== "single-tenant" || updateScope.bound !== bound) tenantScope = "cross-tenant" + } const own = withReads( { scope: tenantScope, diff --git a/src/ch/insert-tenant.test.ts b/src/ch/insert-tenant.test.ts new file mode 100644 index 0000000..0d7dcb4 --- /dev/null +++ b/src/ch/insert-tenant.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from "vitest" +import * as PG from "../postgres" + +const Rows = PG.table("rows", { + columns: { id: PG.text, org: PG.text, value: PG.text }, + primaryKey: ["id"], + tenantColumn: "org", +}) +const insert = PG.insertInto(Rows).values({ id: "same", org: PG.param.string("org"), value: "new" }) +const scope = (query: typeof insert) => PG.compileUnsafe(query, { org: "a" }).tenantScope + +it("does not infer an existing conflict row's tenant from the inserted values or SET", () => { + expect(scope(insert.onConflictDoUpdate({ target: ["id"], set: { value: "new" } }))).toBe("cross-tenant") + expect(scope(insert.onConflictDoUpdate({ target: ["id"], set: { org: "a" } }))).toBe("cross-tenant") + expect(scope(insert.onConflictDoUpdate({ target: { constraint: "rows_pkey" }, set: { value: "new" } }))).toBe("cross-tenant") +}) + +it("proves the conflict row's tenant through the target or an ANDed tenant predicate", () => { + expect(scope(insert.onConflictDoUpdate({ target: ["org", "id"], set: { value: "new" } }))).toBe("single-tenant") + expect(scope(insert.onConflictDoUpdate({ + target: ["id"], set: { value: "new" }, + where: (existing, excluded) => existing.org.eq(excluded.org).and(existing.value.neq("locked")), + }))).toBe("single-tenant") + expect(scope(insert.onConflictDoUpdate({ + target: ["id"], set: { value: "new" }, where: (existing) => existing.org.eq(PG.param.string("org")), + }))).toBe("single-tenant") +}) + +it("does not accept OR, a different tenant, self-equality or a tenant-moving SET as proof", () => { + for (const query of [ + insert.onConflictDoUpdate({ target: ["id"], set: { value: "new" }, where: (existing, excluded) => existing.org.eq(excluded.org).or(existing.value.eq("open")) }), + insert.onConflictDoUpdate({ target: ["id"], set: { value: "new" }, where: (existing) => existing.org.eq("b") }), + insert.onConflictDoUpdate({ target: ["id"], set: { value: "new" }, where: (existing) => existing.org.eq(existing.org) }), + insert.onConflictDoUpdate({ target: ["org", "id"], set: { org: "b" } }), + ]) expect(scope(query)).toBe("cross-tenant") + expect(scope(insert.onConflictDoNothing())).toBe("single-tenant") + const declared = PG.compileUnsafe(insert.onConflictDoUpdate({ target: ["id"], set: { value: "new" } }).crossTenant(), { org: "a" }) + expect(declared.tenantScope).toBe("cross-tenant") + expect(declared.crossTenantDeclared).toBe(true) +}) + +it.each(["excluded", "Excluded", "EXCLUDED", "app.excluded"])("does not confuse table %s with the excluded pseudo-table", (name) => { + const named = PG.table(name, { columns: { id: PG.text, org: PG.text, value: PG.text }, primaryKey: ["id"], tenantColumn: "org" }) + const compiled = PG.compileUnsafe(PG.insertInto(named).values({ id: "same", org: "a", value: "new" }).onConflictDoUpdate({ + target: ["id"], set: { value: "new" }, where: (existing, excluded) => existing.org.eq(excluded.org), + })) + expect(compiled.tenantScope).toBe("cross-tenant") +}) diff --git a/src/ch/insert.test.ts b/src/ch/insert.test.ts index 7017ed1..076409e 100644 --- a/src/ch/insert.test.ts +++ b/src/ch/insert.test.ts @@ -187,10 +187,10 @@ describe("insertInto", () => { expect(compiled.parameters).toEqual(["o", "k", 1, false, true, false]) }) - it("a SET that writes another tenant makes the insert cross-tenant", () => { + it("a SET that writes another tenant makes a tenant-keyed upsert cross-tenant", () => { const insert = CH.insertInto(Counters).values(row) const scope = (org: string) => - PG.compileUnsafe(insert.onConflictDoUpdate({ target: ["key"], set: { org } })).tenantScope + PG.compileUnsafe(insert.onConflictDoUpdate({ target: ["org", "key"], set: { org } })).tenantScope expect(scope("o")).toBe("single-tenant") expect(scope("p")).toBe("cross-tenant") }) diff --git a/src/kit/sqlite-generate.test.ts b/src/kit/sqlite-generate.test.ts index 5806acb..4cdce20 100644 --- a/src/kit/sqlite-generate.test.ts +++ b/src/kit/sqlite-generate.test.ts @@ -124,6 +124,9 @@ it.each(rebuilds)("rebuilds the table for %s, keeps its rows and recreates its i const second = await generateFrom(v2, { hints }) expect(second.plan.map((p) => p.label)).toEqual(["rebuild", "metadata"]) expect(second.plan[0]!.sql).toEqual([ + expect.stringMatching(/^CREATE TABLE "__effect_orm_rebuild_guard_links"/), + expect.stringContaining("pragma_foreign_key_list"), + 'DROP TABLE "__effect_orm_rebuild_guard_links"', "PRAGMA foreign_keys=OFF", expect.stringMatching(/^CREATE TABLE "__new_links" \(/), // A NOT NULL column with a default copies through COALESCE, so old NULLs take the default. @@ -186,7 +189,7 @@ it("asks to confirm a column a rebuild leaves behind", async () => { ], }) expect(second.plan.map((p) => p.label)).toEqual(["rebuild"]) - expect(second.plan[0]!.sql[2]).toBe('INSERT INTO "__new_links"("id", "url") SELECT "id", "url" FROM "links"') + expect(second.plan[0]!.sql).toContain('INSERT INTO "__new_links"("id", "url") SELECT "id", "url" FROM "links"') await expectMigratedToEqualFresh(v2) }) diff --git a/src/schema/sqlite-ops.ts b/src/schema/sqlite-ops.ts index 7a396e3..dafe04a 100644 --- a/src/schema/sqlite-ops.ts +++ b/src/schema/sqlite-ops.ts @@ -9,6 +9,7 @@ // indexes again (dropping the table dropped them). import { Schema } from "effect" +import { sqliteDialect } from "../sqlite/dialect" import { SqliteColumnEntity, SqliteIndexEntity, SqliteTableEntity, SqliteUniqueEntity } from "./sqlite-entities" import { renderSqliteColumnDefinition, renderSqliteCreateIndex, renderSqliteCreateTable, sqliteIdent } from "./sqlite-render" @@ -67,6 +68,24 @@ const copied = (column: SqliteColumnEntity | undefined, name: string): string => ? `COALESCE(${sqliteIdent(name)}, ${column.default})` : sqliteIdent(name) +/** + * An offline schema cannot see foreign keys created by another tool. Check + * the live catalog before the rebuild can drop their parent. NOT NULL still + * applies with ignore_check_constraints enabled, unlike a CHECK assertion. + * No IF NOT EXISTS: a name collision must fail without touching that table. + */ +const rebuildGuard = (table: string): ReadonlyArray => { + const guard = sqliteIdent(`__effect_orm_rebuild_guard_${table}`) + const column = sqliteIdent("foreign_key_references_prevent_rebuild") + return [ + `CREATE TABLE ${guard} (${column} integer NOT NULL)`, + // Every FK declaration contains REFERENCES. Besides saving catalog work, + // this avoids inspecting D1's protected, FK-free _cf_METADATA table. + `INSERT INTO ${guard} (${column}) SELECT NULL FROM sqlite_schema AS s, pragma_foreign_key_list(s.name) AS fk WHERE s.type = 'table' AND instr(upper(s.sql), 'REFERENCES') > 0 AND fk."table" = ${sqliteDialect.quoteString(table)} COLLATE NOCASE LIMIT 1`, + `DROP TABLE ${guard}`, + ] +} + /** The statements one op runs, in order. */ export const renderSqliteOp = (op: SqliteMigrationOp): ReadonlyArray => { switch (op.op) { @@ -89,6 +108,7 @@ export const renderSqliteOp = (op: SqliteMigrationOp): ReadonlyArray => const temporary = rebuildName(op.table.name) const created = renderSqliteCreateTable({ ...op.table, name: temporary }, op.columns.map((c) => ({ ...c, table: temporary })), op.uniques, { ifNotExists: false }) return [ + ...rebuildGuard(op.table.name), "PRAGMA foreign_keys=OFF", created, // With no column in common the rows still carry over, every column taking its default: diff --git a/src/schema/sqlite-rebuild-safety.test.ts b/src/schema/sqlite-rebuild-safety.test.ts new file mode 100644 index 0000000..cc56659 --- /dev/null +++ b/src/schema/sqlite-rebuild-safety.test.ts @@ -0,0 +1,89 @@ +import { DatabaseSync } from "node:sqlite" +import { Effect } from "effect" +import { Miniflare } from "miniflare" +import { expect, it } from "vitest" +import * as SQLite from "../sqlite" +import { diffSqliteSchemas, renderSqliteOp, sqliteEntitiesOf } from "../schema" + +const rebuild = () => { + const before = SQLite.table("Parent", { columns: { id: SQLite.text, value: SQLite.text }, primaryKey: ["id"] }) + const after = SQLite.table("Parent", { columns: { id: SQLite.text, value: SQLite.column(SQLite.text, { default: "new" }) }, primaryKey: ["id"] }) + return diffSqliteSchemas(Effect.runSync(sqliteEntitiesOf([before])), Effect.runSync(sqliteEntitiesOf([after]))).ops.flatMap(renderSqliteOp) +} +const seed = (action: string) => [ + "CREATE TABLE Parent (id TEXT PRIMARY KEY, value TEXT NOT NULL)", + `CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT REFERENCES pArEnT(id) ON DELETE ${action})`, + "INSERT INTO Parent VALUES ('p', 'old')", + "INSERT INTO child VALUES ('c', 'p')", +] +const inTransaction = (db: DatabaseSync, statements: ReadonlyArray) => { + db.exec("BEGIN") + try { + for (const sql of statements) db.exec(sql) + db.exec("COMMIT") + } catch (error) { + db.exec("ROLLBACK") + throw error + } +} + +it.each(["CASCADE", "SET NULL", "NO ACTION"])("refuses a referenced parent's rebuild before ON DELETE %s can affect child rows", (action) => { + const db = new DatabaseSync(":memory:") + try { + db.exec("PRAGMA foreign_keys=ON; PRAGMA ignore_check_constraints=ON") + for (const sql of seed(action)) db.exec(sql) + expect(() => inTransaction(db, rebuild())).toThrow(/foreign_key_references_prevent_rebuild/) + expect(db.prepare("SELECT * FROM Parent").all()).toEqual([{ id: "p", value: "old" }]) + expect(db.prepare("SELECT * FROM child").all()).toEqual([{ id: "c", parent_id: "p" }]) + expect(db.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").all()).toEqual([{ name: "Parent" }, { name: "child" }]) + } finally { + db.close() + } +}) + +it("fails closed on a pre-existing guard table without reusing or dropping it", () => { + const db = new DatabaseSync(":memory:") + try { + db.exec("CREATE TABLE Parent (id TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT INTO Parent VALUES ('p', 'old')") + db.exec("CREATE TABLE __effect_orm_rebuild_guard_Parent (value TEXT); INSERT INTO __effect_orm_rebuild_guard_Parent VALUES ('keep')") + expect(() => inTransaction(db, rebuild())).toThrow(/already exists/) + expect(db.prepare("SELECT * FROM __effect_orm_rebuild_guard_Parent").all()).toEqual([{ value: "keep" }]) + expect(db.prepare("SELECT * FROM Parent").all()).toEqual([{ id: "p", value: "old" }]) + } finally { + db.close() + } +}) + +it("checks internal-looking user table names and case-sensitive LIKE settings too", () => { + const db = new DatabaseSync(":memory:") + try { + db.exec("PRAGMA foreign_keys=ON; PRAGMA case_sensitive_like=ON") + db.exec("CREATE TABLE Parent (id TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT INTO Parent VALUES ('p', 'old')") + db.exec("CREATE TABLE _cf_user_child (parent_id TEXT references Parent(id) ON DELETE CASCADE); INSERT INTO _cf_user_child VALUES ('p')") + expect(() => inTransaction(db, rebuild())).toThrow(/foreign_key_references_prevent_rebuild/) + expect(db.prepare("SELECT * FROM _cf_user_child").all()).toEqual([{ parent_id: "p" }]) + } finally { + db.close() + } +}) + +it.each(["CASCADE", "SET NULL"])("refuses a real D1 batch with ON DELETE %s, rolls back the guard, and permits a later safe rebuild", async (action) => { + const mf = new Miniflare({ modules: true, script: "export default { fetch() { return new Response('') } }", compatibilityDate: "2026-07-01", d1Databases: ["DB"] }) + try { + const db = await mf.getD1Database("DB") + await db.batch(seed(action).map((sql) => db.prepare(sql))) + expect((await db.prepare("SELECT name FROM sqlite_schema WHERE name='_cf_METADATA'").all()).results).toEqual([{ name: "_cf_METADATA" }]) + await db.prepare("PRAGMA ignore_check_constraints=ON").run() + await expect(db.batch(rebuild().map((sql) => db.prepare(sql)))).rejects.toThrow(/foreign_key_references_prevent_rebuild/) + expect((await db.prepare("SELECT * FROM Parent").all()).results).toEqual([{ id: "p", value: "old" }]) + expect((await db.prepare("SELECT * FROM child").all()).results).toEqual([{ id: "c", parent_id: "p" }]) + expect((await db.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name LIKE '__effect_orm_%'").all()).results).toEqual([]) + await db.prepare("DROP TABLE child").run() + await db.batch(rebuild().map((sql) => db.prepare(sql))) + expect((await db.prepare("SELECT * FROM Parent").all()).results).toEqual([{ id: "p", value: "old" }]) + expect((await db.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name LIKE '__effect_orm_%'").all()).results).toEqual([]) + expect((await db.prepare("SELECT dflt_value FROM pragma_table_info('Parent') WHERE name='value'").all()).results).toEqual([{ dflt_value: "'new'" }]) + } finally { + await mf.dispose() + } +}, 30_000) diff --git a/src/sqlite/d1.test.ts b/src/sqlite/d1.test.ts index c35df5a..5869119 100644 --- a/src/sqlite/d1.test.ts +++ b/src/sqlite/d1.test.ts @@ -72,6 +72,42 @@ it("refuses an unscoped query, too many bindings and interactive transactions be expect(seen.statements).toEqual([]) }, 30_000) +it("refuses a cross-org UPSERT before dispatch, while an explicit tenant predicate preserves the other org", async () => { + const seen = await withD1((db, statements) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Ack).values(first)) + const insert = SQLite.insertInto(Ack).values({ ...first, org_id: "org_2" }) + statements.length = 0 + const refused = yield* Effect.exit(db.run(insert.onConflictDoUpdate({ target: ["id"], set: { entry_id: "changed" } }))) + const dispatched = [...statements] + const safe = yield* db.run(insert.onConflictDoUpdate({ + target: ["id"], set: { entry_id: "changed" }, where: (existing, excluded) => existing.org_id.eq(excluded.org_id), + }).returning()) + const kept = yield* db.run(SQLite.from(Ack).select().where(($) => [$.org_id.eq("org_1")])) + const own = yield* db.run(SQLite.insertInto(Ack).values(first).onConflictDoUpdate({ + target: ["id"], set: { entry_id: "same_org" }, where: (existing, excluded) => existing.org_id.eq(excluded.org_id), + }).returning("entry_id")) + const declared = yield* db.run(insert.onConflictDoUpdate({ target: ["id"], set: { entry_id: "explicit_cross_org" } }).crossTenant().returning("entry_id")) + const Excluded = SQLite.table("Excluded", { id: SQLite.text, org: SQLite.text, value: SQLite.text }, { tenantColumn: "org" }) + yield* db.execute({ sql: "CREATE TABLE Excluded (id TEXT PRIMARY KEY, org TEXT, value TEXT)" }) + yield* db.run(SQLite.insertInto(Excluded).values({ id: "same", org: "b", value: "original" })) + const ambiguous = yield* Effect.exit(db.run(SQLite.insertInto(Excluded).values({ id: "same", org: "a", value: "new" }).onConflictDoUpdate({ + target: ["id"], set: { value: "changed" }, where: (existing, excluded) => existing.org.eq(excluded.org), + }))) + const ambiguousKept = yield* db.run(SQLite.from(Excluded).select().where(($) => [$.org.eq("b")])) + return { refused, dispatched, safe, kept, own, declared, ambiguous, ambiguousKept } + })) + if (!Exit.isFailure(seen.refused)) throw new Error("a cross-org UPSERT was accepted") + expect(Cause.findDefect(seen.refused.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TenantScopeViolation" } }) + expect(seen.dispatched).toEqual([]) + expect(seen.safe).toEqual([]) + expect(seen.kept).toEqual([first]) + expect(seen.own).toEqual([{ entry_id: "same_org" }]) + expect(seen.declared).toEqual([{ entry_id: "explicit_cross_org" }]) + if (!Exit.isFailure(seen.ambiguous)) throw new Error("an ambiguous excluded reference was accepted") + expect(Cause.findDefect(seen.ambiguous.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TenantScopeViolation" } }) + expect(seen.ambiguousKept).toEqual([{ id: "same", org: "b", value: "original" }]) +}, 30_000) + it("decodes D1 JSON and blob wire values and returns typed errors for malformed rows", async () => { const Payloads = SQLite.table("payloads", { id: SQLite.integer, payload: SQLite.json(Schema.Struct({ ok: Schema.Boolean })), bytes: SQLite.blob }) const seen = await withD1((db) => Effect.gen(function* () {