@shivaedev/sql 0.31.0 → 0.33.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +58 -19
  3. package/dist/conditionalUpsert.d.ts +4 -3
  4. package/dist/conditionalUpsert.d.ts.map +1 -1
  5. package/dist/conditionalUpsert.js +1 -1
  6. package/dist/conditionalUpsert.js.map +1 -1
  7. package/dist/conflictWrites.d.ts +6 -5
  8. package/dist/conflictWrites.d.ts.map +1 -1
  9. package/dist/conflictWrites.js +1 -1
  10. package/dist/conflictWrites.js.map +1 -1
  11. package/dist/coordinateWorkerRelease.d.ts.map +1 -1
  12. package/dist/coordinateWorkerRelease.js +10 -3
  13. package/dist/coordinateWorkerRelease.js.map +1 -1
  14. package/dist/inValues.d.ts +5 -0
  15. package/dist/inValues.d.ts.map +1 -0
  16. package/dist/inValues.js +10 -0
  17. package/dist/inValues.js.map +1 -0
  18. package/dist/int4Array.d.ts +5 -0
  19. package/dist/int4Array.d.ts.map +1 -0
  20. package/dist/int4Array.js +10 -0
  21. package/dist/int4Array.js.map +1 -0
  22. package/dist/int8.d.ts +5 -0
  23. package/dist/int8.d.ts.map +1 -0
  24. package/dist/int8.js +9 -0
  25. package/dist/int8.js.map +1 -0
  26. package/dist/internal/MigrationStartedAt.d.ts +3 -0
  27. package/dist/internal/MigrationStartedAt.d.ts.map +1 -0
  28. package/dist/internal/MigrationStartedAt.js +5 -0
  29. package/dist/internal/MigrationStartedAt.js.map +1 -0
  30. package/dist/internal/migrationLock.d.ts +4 -0
  31. package/dist/internal/migrationLock.d.ts.map +1 -0
  32. package/dist/internal/migrationLock.js +17 -0
  33. package/dist/internal/migrationLock.js.map +1 -0
  34. package/dist/listenForChanges.js +2 -2
  35. package/dist/listenForChanges.js.map +1 -1
  36. package/dist/makeConflictClause.d.ts +2 -1
  37. package/dist/makeConflictClause.d.ts.map +1 -1
  38. package/dist/makeConflictClause.js +12 -4
  39. package/dist/makeConflictClause.js.map +1 -1
  40. package/dist/migrations.d.ts +1 -1
  41. package/dist/migrations.d.ts.map +1 -1
  42. package/dist/migrations.js +17 -6
  43. package/dist/migrations.js.map +1 -1
  44. package/dist/postgresRelease.d.ts +1 -1
  45. package/dist/postgresRelease.d.ts.map +1 -1
  46. package/dist/postgresRelease.js +7 -2
  47. package/dist/postgresRelease.js.map +1 -1
  48. package/dist/repositoryCollection.d.ts +1 -0
  49. package/dist/repositoryCollection.d.ts.map +1 -1
  50. package/dist/repositoryCollection.js +19 -6
  51. package/dist/repositoryCollection.js.map +1 -1
  52. package/dist/repositoryFilter.d.ts.map +1 -1
  53. package/dist/repositoryFilter.js +9 -2
  54. package/dist/repositoryFilter.js.map +1 -1
  55. package/dist/transact.d.ts.map +1 -1
  56. package/dist/transact.js +4 -1
  57. package/dist/transact.js.map +1 -1
  58. package/dist/upsertColumns.d.ts +16 -0
  59. package/dist/upsertColumns.d.ts.map +1 -0
  60. package/dist/upsertColumns.js +2 -0
  61. package/dist/upsertColumns.js.map +1 -0
  62. package/package.json +7 -6
  63. package/src/conditionalUpsert.ts +4 -3
  64. package/src/conflictWrites.ts +11 -7
  65. package/src/coordinateWorkerRelease.ts +13 -3
  66. package/src/inValues.ts +11 -0
  67. package/src/int4Array.ts +14 -0
  68. package/src/int8.ts +14 -0
  69. package/src/internal/MigrationStartedAt.ts +5 -0
  70. package/src/internal/migrationLock.ts +20 -0
  71. package/src/listenForChanges.ts +2 -2
  72. package/src/makeConflictClause.ts +22 -6
  73. package/src/migrations.ts +20 -6
  74. package/src/postgresRelease.ts +9 -2
  75. package/src/repositoryCollection.ts +20 -6
  76. package/src/repositoryFilter.ts +11 -2
  77. package/src/transact.ts +6 -1
  78. package/src/upsertColumns.ts +16 -0
@@ -1,16 +1,24 @@
1
- import { Duration, Effect, Exit, Schedule, Scope } from "effect";
1
+ import { Duration, Effect, Exit, Predicate, Schedule, Scope } from "effect";
2
2
  import { isSqlError } from "effect/sql/SqlError";
3
3
  import { readPostgresRelease } from "./postgresRelease.ts";
4
4
  import { releaseWorkers } from "./releaseWorkers.ts";
5
5
  import { makeWorkerRelease } from "./WorkerRelease.ts";
6
6
 
7
+ function isCloseable(scope: Scope.Scope): scope is Scope.Closeable {
8
+ return Predicate.hasProperty(scope, "~effect/Scope/Closeable") && scope["~effect/Scope/Closeable"] === "~effect/Scope/Closeable";
9
+ }
10
+
7
11
  export const coordinateWorkerRelease = Effect.fn("EffectSql.coordinateWorkerRelease")(function* (
8
12
  release: string,
9
13
  pollInterval: Duration.Input = "1 second",
10
14
  table = "effect_sql_migrations",
11
15
  ) {
12
16
  const gate = yield* makeWorkerRelease();
13
- const scope = yield* Scope.fork(yield* Effect.scope);
17
+ const owningScope = yield* Effect.scope;
18
+ if (!isCloseable(owningScope)) {
19
+ return yield* Effect.die(new Error("Worker release coordination requires a closeable owning scope"));
20
+ }
21
+ const scope = owningScope;
14
22
  const retries = Schedule.spaced(Duration.toMillis(pollInterval) / 4).pipe(Schedule.upTo({ times: 3 }));
15
23
  const rows = yield* releaseWorkers(release, Duration.toMillis(pollInterval) * 5, table);
16
24
  let claimed = false;
@@ -46,7 +54,9 @@ export const coordinateWorkerRelease = Effect.fn("EffectSql.coordinateWorkerRele
46
54
  }
47
55
  yield* Scope.addFinalizer(
48
56
  scope,
49
- rows.remove.pipe(
57
+ gate.setActive(false).pipe(
58
+ Effect.orDie,
59
+ Effect.andThen(rows.remove),
50
60
  Effect.tapCause((cause) => Effect.logError("Worker release cleanup failed", cause)),
51
61
  Effect.orDie,
52
62
  ),
@@ -0,0 +1,11 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { Fragment } from "effect/sql/Statement";
4
+
5
+ export const inValues = Effect.fnUntraced(function* (column: string | Fragment, values: readonly unknown[]) {
6
+ const sql = yield* SqlClient.SqlClient;
7
+ if (values.length === 0) {
8
+ return sql`1 = 0`;
9
+ }
10
+ return sql`${typeof column === "string" ? sql(column) : column} in ${sql.in(values)}`;
11
+ });
@@ -0,0 +1,14 @@
1
+ import * as PgTypes from "@effect/sql-pg/PgTypes";
2
+ import { Result, Schema, SchemaGetter, SchemaParser } from "effect";
3
+
4
+ const Integers = Schema.Array(Schema.Int);
5
+
6
+ export function int4Array<TField extends Schema.Top & { readonly Encoded: readonly number[] }>(field: TField) {
7
+ return Schema.Unknown.pipe(
8
+ Schema.decodeTo(Integers, {
9
+ decode: SchemaGetter.transformEffect((value) => SchemaParser.decodeUnknownEffect(Integers)(PgTypes.isParameter(value) ? value.value : value)),
10
+ encode: SchemaGetter.transform((values) => Result.getOrThrow(PgTypes.array(values, PgTypes.OID.int4))),
11
+ }),
12
+ Schema.decodeTo(field),
13
+ );
14
+ }
package/src/int8.ts ADDED
@@ -0,0 +1,14 @@
1
+ import * as PgTypes from "@effect/sql-pg/PgTypes";
2
+ import { Schema, SchemaGetter, SchemaParser } from "effect";
3
+
4
+ export function int8<TField extends Schema.Top & { readonly Encoded: bigint }>(field: TField) {
5
+ return Schema.Unknown.pipe(
6
+ Schema.decodeTo(Schema.BigInt, {
7
+ decode: SchemaGetter.transformEffect((value) =>
8
+ SchemaParser.decodeUnknownEffect(Schema.BigInt)(PgTypes.isParameter(value) ? value.value : value),
9
+ ),
10
+ encode: SchemaGetter.transform(PgTypes.int8),
11
+ }),
12
+ Schema.decodeTo(field),
13
+ );
14
+ }
@@ -0,0 +1,5 @@
1
+ import { Context } from "effect";
2
+
3
+ export const MigrationStartedAt = Context.Reference<string | undefined>("@shivaedev/sql/internal/MigrationStartedAt", {
4
+ defaultValue: () => undefined,
5
+ });
@@ -0,0 +1,20 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { Statement } from "effect/sql/Statement";
4
+
5
+ export const migrationLock = Effect.fnUntraced(function* (table: string, timeout: string) {
6
+ const sql = yield* SqlClient.SqlClient;
7
+ const connection = yield* sql.reserve;
8
+ function execute(statement: Statement<Readonly<Record<string, unknown>>>) {
9
+ const [text, parameters] = statement.compile();
10
+ return connection.execute(text, parameters, undefined);
11
+ }
12
+ const [previous] = yield* execute(sql`select current_setting('lock_timeout') as timeout`);
13
+ yield* Effect.acquireRelease(execute(sql`select set_config('lock_timeout', ${timeout}, false)`), () =>
14
+ execute(sql`select set_config('lock_timeout', ${previous?.timeout}, false)`).pipe(Effect.orDie),
15
+ );
16
+ const key = `effect_sql_migrations:${table}`;
17
+ // Register cleanup before waiting so interruption also releases a lock granted just before cancellation.
18
+ yield* Effect.addFinalizer(() => execute(sql`select pg_advisory_unlock(hashtextextended(${key}, 0))`).pipe(Effect.orDie));
19
+ yield* execute(sql`select pg_advisory_lock(hashtextextended(${key}, 0))`);
20
+ });
@@ -89,11 +89,11 @@ export const listenForChanges = Effect.fn("PostgresChanges.listenForChanges")(fu
89
89
  return;
90
90
  }
91
91
  if (lostAt !== undefined) {
92
- yield* Effect.logDebug("PostgreSQL change listener reconnect failed; retrying", cause);
92
+ yield* Effect.logDebug("PostgreSQL change listener reconnect failed; retrying");
93
93
  return;
94
94
  }
95
95
  lostAt = yield* Clock.currentTimeMillis;
96
- yield* Effect.logWarning("PostgreSQL change listener lost; resynchronizing live streams", cause);
96
+ yield* Effect.logWarning("PostgreSQL change listener lost; resynchronizing live streams");
97
97
  }).pipe(Effect.andThen(Effect.sleep(options.retryDelay ?? "1 second"))),
98
98
  ),
99
99
  ),
@@ -2,6 +2,7 @@ import { Effect, Schema, SchemaAST, SchemaIssue } from "effect";
2
2
  import type { Model } from "effect/schema";
3
3
  import type { SqlClient } from "effect/sql";
4
4
  import { makeFilter } from "#repositoryFilter.ts";
5
+ import type { UpsertAssignment } from "#upsertColumns.ts";
5
6
 
6
7
  export function makeConflictClause(
7
8
  model: Model.Any,
@@ -33,18 +34,33 @@ export function makeConflictClause(
33
34
  }
34
35
  });
35
36
  }
36
- function clause(encoded: Readonly<Record<string, unknown>>, updateColumns?: readonly string[], updateWhere?: Readonly<Record<string, unknown>>) {
37
+ function assignment(entry: UpsertAssignment) {
38
+ const column = typeof entry === "string" ? entry : entry.column;
39
+ const incoming = sql`excluded.${sql(column)}`;
40
+ const stored = sql`${sql(options.tableName)}.${sql(column)}`;
41
+ let value = incoming;
42
+ if (typeof entry !== "string") {
43
+ value = entry.mode === "add" ? sql`${stored} + ${incoming}` : sql`coalesce(${incoming}, ${stored})`;
44
+ }
45
+ return sql`${sql(column)} = ${value}`;
46
+ }
47
+ function clause(
48
+ encoded: Readonly<Record<string, unknown>>,
49
+ updateColumns?: readonly UpsertAssignment[],
50
+ updateWhere?: Readonly<Record<string, unknown>>,
51
+ ) {
37
52
  return Effect.gen(function* () {
38
53
  yield* validateColumns(encoded, conflictColumns);
39
54
  if (updateColumns !== undefined) {
40
- yield* validateColumns(encoded, updateColumns, [...idColumns, ...conflictColumns, ...immutable]);
55
+ yield* validateColumns(
56
+ encoded,
57
+ updateColumns.map((entry) => (typeof entry === "string" ? entry : entry.column)),
58
+ [...idColumns, ...conflictColumns, ...immutable],
59
+ );
41
60
  }
42
61
  const conflict = sql.csv(conflictColumns.map((column) => sql`${sql(column)}`));
43
62
  const where = options.conflictWhere === undefined ? sql`` : sql`where ${yield* filter.where(options.conflictWhere)}`;
44
- const action =
45
- updateColumns === undefined
46
- ? sql`do nothing`
47
- : sql`do update set ${sql.csv(updateColumns.map((column) => sql`${sql(column)} = excluded.${sql(column)}`))}`;
63
+ const action = updateColumns === undefined ? sql`do nothing` : sql`do update set ${sql.csv(updateColumns.map(assignment))}`;
48
64
  const condition = updateWhere === undefined ? sql`` : sql`where ${yield* updateFilter.where(updateWhere, true, true)}`;
49
65
  return sql`on conflict (${conflict}) ${where} ${action} ${condition}`;
50
66
  });
package/src/migrations.ts CHANGED
@@ -2,6 +2,8 @@ import { Duration, Effect, Option, Schema } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { Migrator, SqlClient } from "effect/sql";
4
4
  import type { SqlError } from "effect/sql/SqlError";
5
+ import { MigrationStartedAt } from "#internal/MigrationStartedAt.ts";
6
+ import { migrationLock } from "#internal/migrationLock.ts";
5
7
  import { advisoryXactLock } from "./advisoryXactLock.ts";
6
8
  import { postgresRelease } from "./postgresRelease.ts";
7
9
  import { runSchemaComponent, type SchemaComponent } from "./SchemaComponent.ts";
@@ -25,19 +27,31 @@ export const migratePostgres = Effect.fn("EffectSql.migratePostgres")(function*
25
27
  release,
26
28
  }: PostgresMigrationOptions<R, E>) {
27
29
  const sql = yield* SqlClient.SqlClient;
28
- const available = yield* Effect.serviceOption(Reactivity.Reactivity);
29
- const reactivity = Option.isSome(available) ? available.value : yield* Reactivity.make;
30
+ const [invocation] =
31
+ release === undefined
32
+ ? []
33
+ : yield* sql`select clock_timestamp()::text as "startedAt"`.withoutTransform.pipe(
34
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ startedAt: Schema.String })))),
35
+ );
36
+ const current = [...(yield* loader)].sort(([left], [right]) => left - right);
37
+ const extendedLock = components.length > 0 || release !== undefined;
30
38
  const millis = Duration.toMillis(lockTimeout);
31
39
  const timeout = millis === Number.POSITIVE_INFINITY ? "0" : `${Math.max(1, Math.ceil(millis))}ms`;
40
+ if (extendedLock) {
41
+ yield* migrationLock(table, timeout);
42
+ }
43
+ const available = yield* Effect.serviceOption(Reactivity.Reactivity);
44
+ const reactivity = Option.isSome(available) ? available.value : yield* Reactivity.make;
32
45
  const appliedMigrations = yield* Effect.gen(function* () {
33
46
  yield* sql`select set_config('lock_timeout', ${timeout}, true)`;
34
- yield* advisoryXactLock(`effect_sql_migrations:${table}`).pipe(Effect.catchTag("AdvisoryLockOutsideTransaction", Effect.die));
47
+ if (!extendedLock) {
48
+ yield* advisoryXactLock(`effect_sql_migrations:${table}`).pipe(Effect.catchTag("AdvisoryLockOutsideTransaction", Effect.die));
49
+ }
35
50
  yield* sql`create table if not exists ${sql(table)} (
36
51
  migration_id integer primary key,
37
52
  created_at timestamp with time zone not null default now(),
38
53
  name text not null
39
54
  )`;
40
- const current = [...(yield* loader)].sort(([left], [right]) => left - right);
41
55
  const applied = yield* sql`select migration_id as id from ${sql(table)}`.withoutTransform.pipe(
42
56
  Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.Int })))),
43
57
  );
@@ -59,7 +73,7 @@ export const migratePostgres = Effect.fn("EffectSql.migratePostgres")(function*
59
73
  ...(release === undefined ? [] : [postgresRelease(release, table)]),
60
74
  ];
61
75
  for (const component of schemaComponents) {
62
- yield* runSchemaComponent(component.name, component.apply);
76
+ yield* runSchemaComponent(component.name, component.apply).pipe(Effect.provideService(MigrationStartedAt, invocation?.startedAt));
63
77
  }
64
78
  return appliedMigrations;
65
- });
79
+ }, Effect.scoped);
@@ -1,5 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { SqlClient } from "effect/sql";
3
+ import { MigrationStartedAt } from "#internal/MigrationStartedAt.ts";
3
4
  import type { SchemaComponent } from "./SchemaComponent.ts";
4
5
 
5
6
  export const readPostgresRelease = Effect.fn("EffectSql.readPostgresRelease")(function* (table = "effect_sql_migrations") {
@@ -22,6 +23,11 @@ export function postgresRelease(release: string, table = "effect_sql_migrations"
22
23
  return {
23
24
  apply: Effect.gen(function* () {
24
25
  const sql = yield* SqlClient.SqlClient;
26
+ const startedAt = yield* MigrationStartedAt;
27
+ const timestamp = startedAt === undefined ? sql`clock_timestamp()` : sql`${startedAt}::timestamptz`;
28
+ const [invocation] = yield* sql`select ${timestamp}::text as at`.withoutTransform.pipe(
29
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ at: Schema.String })))),
30
+ );
25
31
  yield* sql`create table if not exists ${sql(`${table}_release`)} (
26
32
  id boolean primary key default true check (id),
27
33
  release text not null,
@@ -33,8 +39,9 @@ export function postgresRelease(release: string, table = "effect_sql_migrations"
33
39
  heartbeat timestamptz not null
34
40
  )`;
35
41
  yield* sql`insert into ${sql(`${table}_release`)} (id, release, recorded_at)
36
- values (true, ${release}, clock_timestamp())
37
- on conflict (id) do update set release = excluded.release, recorded_at = excluded.recorded_at`;
42
+ values (true, ${release}, ${invocation?.at}::timestamptz)
43
+ on conflict (id) do update set release = excluded.release, recorded_at = excluded.recorded_at
44
+ where ${sql(`${table}_release`)}.recorded_at < excluded.recorded_at`;
38
45
  }),
39
46
  name: "database release",
40
47
  pending: Effect.gen(function* () {
@@ -18,8 +18,12 @@ export class BucketRegistrationError extends Data.TaggedError("BucketRegistratio
18
18
  readonly collectionName: string | undefined;
19
19
  readonly modelFields: readonly string[];
20
20
  readonly collectionFields: readonly string[];
21
+ readonly invalidFields: readonly string[];
21
22
  }> {
22
23
  override get message() {
24
+ if (this.invalidFields.length > 0) {
25
+ return `Bucket registration for ${this.tableName}: fields ${JSON.stringify(this.invalidFields)} must decode to strings, numbers or absent values`;
26
+ }
23
27
  return `Bucket registration for ${this.tableName}: model fields ${JSON.stringify(this.modelFields)} disagree with collection ${this.collectionName ?? "(missing)"} fields ${JSON.stringify(this.collectionFields)}`;
24
28
  }
25
29
  }
@@ -28,19 +32,29 @@ export const validateRepositoryCollection = Effect.fnUntraced(function* (
28
32
  fields: Schema.Struct.Fields,
29
33
  options: { readonly tableName: string; readonly collection?: RepositoryCollection },
30
34
  ) {
31
- const modelFields = declaredBuckets(fields)
35
+ const buckets = declaredBuckets(fields);
36
+ const invalidFields = buckets
37
+ .filter(({ supported }) => !supported)
32
38
  .map(({ field }) => field)
33
39
  .sort(Order.String);
40
+ const modelFields = buckets.map(({ field }) => field).sort(Order.String);
34
41
  const collectionFields = Object.keys(options.collection?.bucket ?? {}).sort(Order.String);
35
42
  if (
36
- (options.collection === undefined && modelFields.length === 0)
37
- || (options.collection?.name === options.tableName
38
- && modelFields.length === collectionFields.length
39
- && modelFields.every((field, index) => field === collectionFields[index]))
43
+ invalidFields.length === 0
44
+ && ((options.collection === undefined && modelFields.length === 0)
45
+ || (options.collection?.name === options.tableName
46
+ && modelFields.length === collectionFields.length
47
+ && modelFields.every((field, index) => field === collectionFields[index])))
40
48
  ) {
41
49
  return;
42
50
  }
43
51
  return yield* Effect.fail(
44
- new BucketRegistrationError({ collectionFields, collectionName: options.collection?.name, modelFields, tableName: options.tableName }),
52
+ new BucketRegistrationError({
53
+ collectionFields,
54
+ collectionName: options.collection?.name,
55
+ invalidFields,
56
+ modelFields,
57
+ tableName: options.tableName,
58
+ }),
45
59
  );
46
60
  });
@@ -70,11 +70,20 @@ export function makeFilter<TModel extends Model.Any>(model: TModel, sql: SqlClie
70
70
  }
71
71
  function predicate(key: string, value: unknown, allowIn: boolean, allowComparisons: boolean) {
72
72
  return Effect.flatMap(fieldAt(key), (field) => {
73
+ const encode = Schema.encodeEffect(field);
74
+ function equality() {
75
+ return Effect.map(encode(value), (encoded) =>
76
+ value === null || encoded === null ? sql`${column(key)} is null` : sql`${column(key)} = ${encoded}`,
77
+ );
78
+ }
79
+ const acceptsValue: (input: unknown) => boolean = Schema.is(field);
80
+ if (acceptsValue(value)) {
81
+ return equality();
82
+ }
73
83
  const incoming = reference(value);
74
84
  if (incoming !== undefined) {
75
85
  return Effect.as(fieldAt(incoming), sql`${column(key)} = excluded.${sql(incoming)}`);
76
86
  }
77
- const encode = Schema.encodeEffect(field);
78
87
  if (allowComparisons && typeof value === "object" && value !== null && Object.keys(value).some(isOperator)) {
79
88
  return Effect.map(
80
89
  Effect.forEach(Object.entries(value), ([operator, operand]) => comparison(key, field, operator, operand)),
@@ -86,7 +95,7 @@ export function makeFilter<TModel extends Model.Any>(model: TModel, sql: SqlClie
86
95
  Effect.forEach(value.in, (entry) => encode(entry)),
87
96
  (encoded) => inPredicate(key, encoded),
88
97
  )
89
- : Effect.map(encode(value), (encoded) => (encoded === null ? sql`${column(key)} is null` : sql`${column(key)} = ${encoded}`));
98
+ : equality();
90
99
  });
91
100
  }
92
101
  function where(input: Readonly<Record<string, unknown>> & Partial<TupleFilter<Model.Any>> = {}, allowIn = false, allowComparisons = false) {
package/src/transact.ts CHANGED
@@ -58,8 +58,13 @@ export const transact: {
58
58
  2,
59
59
  Effect.fn("Transact.transact")(function* <A, E, R, TError>(effect: Effect.Effect<A, E, R>, options: TransactOptions<TError>) {
60
60
  const sql = yield* SqlClient.SqlClient;
61
+ const native = yield* Effect.serviceOption(sql.transactionService);
61
62
  return yield* invalidations
62
- .within(sql.withTransaction)(effect)
63
+ .within((body, boundary) =>
64
+ native._tag === "Some"
65
+ ? sql.withTransaction(body)
66
+ : boundary.restore(sql.withTransaction(Effect.tap(body, () => sql.afterCommit(boundary.committed)))),
67
+ )(effect)
63
68
  .pipe(Effect.catchIf(isSqlError, (error) => Effect.fail(options.onSqlError(error))));
64
69
  }),
65
70
  );
@@ -0,0 +1,16 @@
1
+ import type { BigDecimal } from "effect";
2
+ import type { Model } from "effect/schema";
3
+
4
+ export type UpsertColumn<TModel extends Model.Any, TColumn extends keyof TModel["fields"]> =
5
+ | TColumn
6
+ | {
7
+ readonly column: TColumn;
8
+ readonly mode: "keepOnNull";
9
+ }
10
+ | {
11
+ [TKey in TColumn]: NonNullable<TModel["fields"][TKey]["Type"]> extends number | bigint | BigDecimal.BigDecimal
12
+ ? { readonly column: TKey; readonly mode: "add" }
13
+ : never;
14
+ }[TColumn];
15
+
16
+ export type UpsertAssignment = string | { readonly column: string; readonly mode: "add" | "keepOnNull" };