@shivaedev/sql 0.23.0 → 0.24.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 (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +51 -29
  3. package/dist/ChangeDeclarations.d.ts +5 -0
  4. package/dist/ChangeDeclarations.d.ts.map +1 -0
  5. package/dist/ChangeDeclarations.js +3 -0
  6. package/dist/ChangeDeclarations.js.map +1 -0
  7. package/dist/batchWrites.d.ts +1 -1
  8. package/dist/batchWrites.d.ts.map +1 -1
  9. package/dist/batchWrites.js +6 -3
  10. package/dist/batchWrites.js.map +1 -1
  11. package/dist/compositeRepository.d.ts +2 -2
  12. package/dist/compositeRepository.d.ts.map +1 -1
  13. package/dist/compositeRepository.js +1 -8
  14. package/dist/compositeRepository.js.map +1 -1
  15. package/dist/conditionalUpsert.d.ts +11 -4
  16. package/dist/conditionalUpsert.d.ts.map +1 -1
  17. package/dist/conditionalUpsert.js +7 -3
  18. package/dist/conditionalUpsert.js.map +1 -1
  19. package/dist/conflictWrites.d.ts +14 -1
  20. package/dist/conflictWrites.d.ts.map +1 -1
  21. package/dist/conflictWrites.js +5 -3
  22. package/dist/conflictWrites.js.map +1 -1
  23. package/dist/insertFromSelect.js +1 -1
  24. package/dist/insertFromSelect.js.map +1 -1
  25. package/dist/mapSqlError.d.ts +6 -0
  26. package/dist/mapSqlError.d.ts.map +1 -0
  27. package/dist/mapSqlError.js +22 -0
  28. package/dist/mapSqlError.js.map +1 -0
  29. package/dist/repository.js +1 -1
  30. package/dist/repository.js.map +1 -1
  31. package/dist/repositoryChanges.d.ts +4 -3
  32. package/dist/repositoryChanges.d.ts.map +1 -1
  33. package/dist/repositoryChanges.js +46 -13
  34. package/dist/repositoryChanges.js.map +1 -1
  35. package/dist/repositoryWrites.d.ts.map +1 -1
  36. package/dist/repositoryWrites.js +5 -5
  37. package/dist/repositoryWrites.js.map +1 -1
  38. package/dist/testing.d.ts +3 -2
  39. package/dist/testing.d.ts.map +1 -1
  40. package/dist/testing.js +25 -3
  41. package/dist/testing.js.map +1 -1
  42. package/dist/upsertResult.d.ts +13 -0
  43. package/dist/upsertResult.d.ts.map +1 -0
  44. package/dist/upsertResult.js +9 -0
  45. package/dist/upsertResult.js.map +1 -0
  46. package/dist/versionedRepository.js +6 -6
  47. package/dist/versionedRepository.js.map +1 -1
  48. package/dist/versionedStatements.d.ts +0 -1
  49. package/dist/versionedStatements.d.ts.map +1 -1
  50. package/dist/versionedStatements.js +1 -2
  51. package/dist/versionedStatements.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/ChangeDeclarations.ts +5 -0
  54. package/src/batchWrites.ts +6 -2
  55. package/src/compositeRepository.ts +3 -9
  56. package/src/conditionalUpsert.ts +11 -7
  57. package/src/conflictWrites.ts +5 -3
  58. package/src/insertFromSelect.ts +1 -1
  59. package/src/mapSqlError.ts +31 -0
  60. package/src/repository.ts +1 -1
  61. package/src/repositoryChanges.ts +56 -19
  62. package/src/repositoryWrites.ts +7 -5
  63. package/src/testing.ts +30 -5
  64. package/src/upsertResult.ts +18 -0
  65. package/src/versionedRepository.ts +6 -6
  66. package/src/versionedStatements.ts +1 -2
@@ -5,6 +5,7 @@ import type { SqlError } from "effect/sql/SqlError";
5
5
  import { makeConflictClause } from "#makeConflictClause.ts";
6
6
  import type { Comparisons, FindManyFilter } from "#repositoryFilter.ts";
7
7
  import type { RepositoryChanges } from "./repositoryChanges.ts";
8
+ import { makeUpsertDecoder, type UpsertRow, upsertReturning } from "./upsertResult.ts";
8
9
 
9
10
  type Fields<TModel extends Model.Any> = Schema.Struct.Type<TModel["fields"]>;
10
11
  interface Incoming<TModel extends Model.Any, TValue> {
@@ -19,8 +20,8 @@ export type UpsertFilter<TModel extends Model.Any> = {
19
20
  | Comparisons<Fields<TModel>[TKey], NonNullable<Fields<TModel>[TKey]> | Incoming<TModel, NonNullable<Fields<TModel>[TKey]>>>;
20
21
  };
21
22
  export type UpsertResult<TRow> =
22
- | { readonly _tag: "Inserted"; readonly row: TRow }
23
- | { readonly _tag: "Updated"; readonly row: TRow }
23
+ | { readonly _tag: "Inserted"; readonly row: TRow; readonly inserted: true }
24
+ | { readonly _tag: "Updated"; readonly row: TRow; readonly inserted: false }
24
25
  | { readonly _tag: "Unchanged" };
25
26
  export interface ConditionalUpsert<TModel extends Model.Any, TOptions, TServices> {
26
27
  (
@@ -34,7 +35,7 @@ export interface ConditionalUpsert<TModel extends Model.Any, TOptions, TServices
34
35
  (
35
36
  input: TModel["insert"]["Type"],
36
37
  options: TOptions & { readonly updateWhere?: never },
37
- ): Effect.Effect<TModel["Type"], SqlError | Schema.SchemaError, TServices>;
38
+ ): Effect.Effect<UpsertRow<TModel["Type"]>, SqlError | Schema.SchemaError, TServices>;
38
39
  }
39
40
  export interface ConditionalUpsertMany<TModel extends Model.Any, TOptions, TServices> {
40
41
  (
@@ -48,7 +49,7 @@ export interface ConditionalUpsertMany<TModel extends Model.Any, TOptions, TServ
48
49
  (
49
50
  inputs: readonly TModel["insert"]["Type"][],
50
51
  options: TOptions & { readonly updateWhere?: never },
51
- ): Effect.Effect<TModel["Type"][], SqlError | Schema.SchemaError, TServices>;
52
+ ): Effect.Effect<UpsertRow<TModel["Type"]>[], SqlError | Schema.SchemaError, TServices>;
52
53
  }
53
54
 
54
55
  export function makeConditionalUpsert(
@@ -65,7 +66,7 @@ export function makeConditionalUpsert(
65
66
  ) {
66
67
  const encode = Schema.encodeEffect(model.insert);
67
68
  const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
68
- const decode = Schema.decodeUnknownEffect(model);
69
+ const decode = makeUpsertDecoder(model);
69
70
  const clause = makeConflictClause(model, sql, options);
70
71
  return (
71
72
  inputs: readonly unknown[],
@@ -76,7 +77,7 @@ export function makeConditionalUpsert(
76
77
  Effect.gen(function* () {
77
78
  const row = yield* record(yield* encode(input));
78
79
  const suffix = yield* clause(row, settings.updateColumns, settings.updateWhere);
79
- return sql`insert into ${sql(options.tableName)} ${sql.insert(row)} ${suffix} returning *, (xmax = 0) as ${sql("effectSqlInserted")}`;
80
+ return sql`insert into ${sql(options.tableName)} ${sql.insert(row)} ${suffix} returning ${options.changes.returning(upsertReturning(sql))}`;
80
81
  }),
81
82
  );
82
83
  const changedRows: Readonly<Record<string, unknown>>[] = [];
@@ -88,7 +89,10 @@ export function makeConditionalUpsert(
88
89
  if (row === undefined) {
89
90
  return { _tag: "Unchanged" } as const;
90
91
  }
91
- return { _tag: row.effectSqlInserted ? "Inserted" : "Updated", row: yield* decode(row) } as const;
92
+ const result = yield* decode(row);
93
+ return result.inserted
94
+ ? ({ ...result, _tag: "Inserted", inserted: true } as const)
95
+ : ({ ...result, _tag: "Updated", inserted: false } as const);
92
96
  }),
93
97
  );
94
98
  yield* options.changes.record(changedRows);
@@ -7,6 +7,7 @@ import { type ConditionalUpsert, type ConditionalUpsertMany, makeConditionalUpse
7
7
  import { type InsertFromSelect, makeInsertFromSelect } from "#insertFromSelect.ts";
8
8
  import { makeConflictClause } from "#makeConflictClause.ts";
9
9
  import type { RepositoryChanges } from "./repositoryChanges.ts";
10
+ import { makeUpsertDecoder, upsertReturning } from "./upsertResult.ts";
10
11
 
11
12
  export type InsertColumn<TModel extends Model.Any> = Extract<keyof TModel["insert"]["Type"] & keyof TModel["fields"], string>;
12
13
  export type ConflictColumns<TModel extends Model.Any> = readonly [InsertColumn<TModel>, ...InsertColumn<TModel>[]];
@@ -53,6 +54,7 @@ export function makeConflictWrites(
53
54
  const { tableName, spanPrefix } = options;
54
55
  const encode = Schema.encodeEffect(model.insert);
55
56
  const decode = Schema.decodeUnknownEffect(model);
57
+ const decodeUpsert = makeUpsertDecoder(model);
56
58
  const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
57
59
  const conditional = makeConditionalUpsert(model, sql, options);
58
60
  const clause = makeConflictClause(model, sql, options);
@@ -60,7 +62,7 @@ export function makeConflictWrites(
60
62
  return Effect.gen(function* () {
61
63
  const encoded = yield* record(yield* encode(input));
62
64
  const suffix = yield* clause(encoded, updateColumns);
63
- return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix} returning *`;
65
+ return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix} returning ${options.changes.returning(updateColumns === undefined ? sql`*` : upsertReturning(sql))}`;
64
66
  });
65
67
  }
66
68
  return {
@@ -79,7 +81,7 @@ export function makeConflictWrites(
79
81
  ? Effect.gen(function* () {
80
82
  const rows = yield* yield* statement(input, settings.updateColumns);
81
83
  yield* options.changes.record(rows);
82
- return yield* decode(rows[0]);
84
+ return yield* decodeUpsert(rows[0]);
83
85
  }).pipe(options.changes.write, Effect.withSpan(`${spanPrefix}.upsert`))
84
86
  : conditional([input], { ...settings, updateWhere: settings.updateWhere }).pipe(Effect.map((results) => results[0])),
85
87
  upsertMany: (
@@ -87,7 +89,7 @@ export function makeConflictWrites(
87
89
  settings: { readonly updateColumns: readonly string[]; readonly updateWhere?: Readonly<Record<string, unknown>> },
88
90
  ) =>
89
91
  settings.updateWhere === undefined
90
- ? makeInsertMany(model, sql, options, "upsertMany", (row) => clause(row, settings.updateColumns))(inputs)
92
+ ? makeInsertMany(model, sql, options, "upsertMany", (row) => clause(row, settings.updateColumns), true)(inputs)
91
93
  : conditional(inputs, { ...settings, updateWhere: settings.updateWhere }),
92
94
  };
93
95
  }
@@ -40,7 +40,7 @@ export function makeInsertFromSelect<TServices = never>(
40
40
  const suffix = yield* prepare(names);
41
41
  const statement = yield* source.statement;
42
42
  const rows =
43
- yield* sql`insert into ${sql(options.tableName)} (${target}) select ${selected} from (${statement}) as ${sql("source")} where true ${suffix} returning *`;
43
+ yield* sql`insert into ${sql(options.tableName)} (${target}) select ${selected} from (${statement}) as ${sql("source")} where true ${suffix} returning ${options.changes.returning()}`;
44
44
  yield* options.changes.record(rows);
45
45
  return yield* decode(rows);
46
46
  }).pipe(options.changes.write, Effect.withSpan(`${options.spanPrefix}.${operation}`));
@@ -0,0 +1,31 @@
1
+ import type { SqlError } from "effect/sql/SqlError";
2
+
3
+ export function mapSqlError<TConstraints extends Readonly<Record<string, (error: SqlError) => unknown>>, TFallback>(options: {
4
+ readonly constraints: TConstraints;
5
+ readonly fallback: (error: SqlError) => TFallback;
6
+ }): (error: SqlError) => ReturnType<TConstraints[keyof TConstraints]> | TFallback;
7
+ export function mapSqlError(options: {
8
+ readonly constraints: Readonly<Record<string, (error: SqlError) => unknown>>;
9
+ readonly fallback: (error: SqlError) => unknown;
10
+ }) {
11
+ return (error: SqlError) => {
12
+ const reason = error.reason;
13
+ let constraint: string | undefined;
14
+ if (reason._tag === "UniqueViolation") {
15
+ constraint = reason.constraint;
16
+ } else if (
17
+ reason._tag === "ConstraintError"
18
+ && typeof reason.cause === "object"
19
+ && reason.cause !== null
20
+ && "code" in reason.cause
21
+ && (reason.cause.code === "23503" || reason.cause.code === "23514")
22
+ && "constraint" in reason.cause
23
+ && typeof reason.cause.constraint === "string"
24
+ ) {
25
+ constraint = reason.cause.constraint;
26
+ }
27
+ return constraint !== undefined && Object.hasOwn(options.constraints, constraint)
28
+ ? (options.constraints[constraint] ?? options.fallback)(error)
29
+ : options.fallback(error);
30
+ };
31
+ }
package/src/repository.ts CHANGED
@@ -119,7 +119,7 @@ export function makeRepository<TModel extends Model.Any, TId extends IdColumn<TM
119
119
  const sql = yield* SqlClient.SqlClient;
120
120
  const composite = typeof options.idColumn === "object";
121
121
  const idColumns = (composite ? options.idColumn : [options.idColumn]).map(String);
122
- const changes = yield* repositoryChanges(sql, options.tableName, idColumns);
122
+ const changes = yield* repositoryChanges(sql, options.tableName, idColumns, model.fields);
123
123
  const crud = makeCompositeCrud(model, sql, { ...options, columns: idColumns }, changes);
124
124
  const queries = makeQueries(model, sql, options);
125
125
  return options.versionColumn === undefined
@@ -1,17 +1,49 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Hash, Option, type Schema } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { SqlClient } from "effect/sql";
4
+ import { declaredBuckets } from "@shivaedev/changes/buckets.ts";
5
+ import { invalidationKeys, type Key } from "@shivaedev/changes/keys.ts";
6
+ import { ChangeDeclarations } from "#ChangeDeclarations.ts";
4
7
  import { invalidateOnCommit, transact } from "#transact.ts";
5
8
 
6
- const ITEM_KEY_LIMIT = 1000;
7
-
8
9
  function encodeKeyValue(_key: string, value: unknown) {
9
10
  return typeof value === "bigint" ? String(value) : value;
10
11
  }
11
12
 
13
+ function recordId(value: unknown) {
14
+ return typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean"
15
+ ? String(value)
16
+ : Hash.hash(value);
17
+ }
18
+
12
19
  export type RepositoryChanges = Effect.Success<ReturnType<typeof repositoryChanges>>;
13
20
 
14
- export const repositoryChanges = Effect.fnUntraced(function* (sql: SqlClient.SqlClient, table: string, columns: readonly string[]) {
21
+ export const repositoryChanges = Effect.fnUntraced(function* (
22
+ sql: SqlClient.SqlClient,
23
+ table: string,
24
+ columns: readonly string[],
25
+ fields: Schema.Struct.Fields = {},
26
+ ) {
27
+ const buckets = declaredBuckets(fields);
28
+ const bucketKeys = Effect.fnUntraced(function* (row: Readonly<Record<string, unknown>>) {
29
+ const keys: Key[] = [];
30
+ for (const [index, { field, decode }] of buckets.entries()) {
31
+ for (const value of [row[field], row[`shivaeOldBucket${index}`]]) {
32
+ if (value === null || value === undefined) {
33
+ continue;
34
+ }
35
+ const decoded = yield* decode(value);
36
+ if (decoded === null || decoded === undefined) {
37
+ continue;
38
+ }
39
+ keys.push({ _tag: "Bucket", collection: table, field, value: decoded });
40
+ }
41
+ }
42
+ return invalidationKeys(keys);
43
+ });
44
+ function returning(base = sql`*`) {
45
+ return sql.csv([base, ...buckets.map(({ field }, index) => sql`old.${sql(field)} as ${sql(`shivaeOldBucket${index}`)}`)]);
46
+ }
15
47
  const initial = yield* Effect.serviceOption(Reactivity.Reactivity);
16
48
  const fallback = Option.isSome(initial) ? initial.value : yield* Reactivity.make;
17
49
  function provide<A, E, R>(effect: Effect.Effect<A, E, R | Reactivity.Reactivity | SqlClient.SqlClient>) {
@@ -25,25 +57,30 @@ export const repositoryChanges = Effect.fnUntraced(function* (sql: SqlClient.Sql
25
57
  ),
26
58
  );
27
59
  }
28
- function record(rows: readonly Readonly<Record<string, unknown>>[]) {
60
+ const record = Effect.fnUntraced(function* (rows: readonly Readonly<Record<string, unknown>>[]) {
29
61
  if (rows.length === 0) {
30
- return Effect.void;
62
+ return;
31
63
  }
32
- if (rows.length > ITEM_KEY_LIMIT) {
33
- return provide(invalidateOnCommit([table, `${table}:*`]));
64
+ const keys = new Set<string>([table]);
65
+ for (const row of rows) {
66
+ const id =
67
+ columns.length === 1
68
+ ? row[columns[0] ?? ""]
69
+ : JSON.stringify(
70
+ columns.map((column) => row[column]),
71
+ encodeKeyValue,
72
+ );
73
+ keys.add(`${table}:${recordId(id)}`);
74
+ for (const key of yield* bucketKeys(row)) {
75
+ keys.add(key);
76
+ }
34
77
  }
35
- const ids = rows.map((row) =>
36
- columns.length === 1
37
- ? row[columns[0] ?? ""]
38
- : JSON.stringify(
39
- columns.map((column) => row[column]),
40
- encodeKeyValue,
41
- ),
42
- );
43
- return provide(invalidateOnCommit({ [table]: ids }));
44
- }
78
+ const declarations = yield* ChangeDeclarations;
79
+ yield* declarations.declare(table, [...keys], rows.length);
80
+ yield* provide(invalidateOnCommit([...keys]));
81
+ });
45
82
  function write<A, E, R>(effect: Effect.Effect<A, E, R>) {
46
83
  return provide(transact(effect, { onSqlError: (error) => error }));
47
84
  }
48
- return { record, write };
85
+ return { record, returning, write };
49
86
  });
@@ -20,14 +20,16 @@ export function repositoryWrites(
20
20
  function insert(input: unknown) {
21
21
  return Effect.flatMap(record(input), (row) =>
22
22
  (Object.keys(row).length === 0
23
- ? sql`insert into ${sql(tableName)} default values returning *`
24
- : sql`insert into ${sql(tableName)} ${sql.insert(row)} returning *`
23
+ ? sql`insert into ${sql(tableName)} default values returning ${changes.returning()}`
24
+ : sql`insert into ${sql(tableName)} ${sql.insert(row)} returning ${changes.returning()}`
25
25
  ).pipe(Effect.tap(changes.record)),
26
26
  );
27
27
  }
28
28
  function update(input: unknown) {
29
29
  return Effect.flatMap(record(input), (row) =>
30
- sql`update ${sql(tableName)} set ${sql.update(row, idColumns)} where ${predicate(row)} returning *`.pipe(Effect.tap(changes.record)),
30
+ sql`update ${sql(tableName)} set ${sql.update(row, idColumns)} where ${predicate(row)} returning ${changes.returning()}`.pipe(
31
+ Effect.tap(changes.record),
32
+ ),
31
33
  );
32
34
  }
33
35
  const insertRow = SqlSchema.findOne({ execute: insert, Request: model.insert, Result: model });
@@ -43,7 +45,7 @@ export function repositoryWrites(
43
45
  const removeByKey = SqlSchema.void({
44
46
  execute: (id) =>
45
47
  Effect.flatMap(record({ [idColumn]: id }), (encoded) =>
46
- sql`delete from ${sql(tableName)} where ${predicate(encoded)} returning *`.pipe(Effect.tap(changes.record)),
48
+ sql`delete from ${sql(tableName)} where ${predicate(encoded)} returning ${changes.returning()}`.pipe(Effect.tap(changes.record)),
47
49
  ),
48
50
  Request: key,
49
51
  });
@@ -52,7 +54,7 @@ export function repositoryWrites(
52
54
  ? removeByKey(input)
53
55
  : Effect.flatMap(filterRecord(input), (filter) =>
54
56
  Effect.flatMap(where(filter, true), (clause) =>
55
- sql`delete from ${sql(tableName)} where ${clause} returning *`.pipe(Effect.tap(changes.record), Effect.asVoid),
57
+ sql`delete from ${sql(tableName)} where ${clause} returning ${changes.returning()}`.pipe(Effect.tap(changes.record), Effect.asVoid),
56
58
  ),
57
59
  );
58
60
  }
package/src/testing.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { Data, Effect } from "effect";
2
2
  import { SqlClient } from "effect/sql";
3
- import { ChangeObserver, transact } from "./transact.ts";
3
+ import { type BucketKey, type ItemKey, invalidationKeys } from "@shivaedev/changes/keys.ts";
4
+ import { ChangeDeclarations } from "#ChangeDeclarations.ts";
5
+ import { ChangeObserver, transact } from "#transact.ts";
4
6
 
5
7
  export class UnannouncedWrites extends Data.TaggedError("UnannouncedWrites")<{ readonly tables: readonly string[] }> {}
6
8
 
7
9
  export interface ChangeCoverage {
8
- readonly covers: Readonly<Record<string, readonly unknown[]>>;
10
+ readonly covers: Readonly<Record<string, readonly (ItemKey | BucketKey)[]>>;
9
11
  }
10
12
 
11
13
  interface TableWrites {
@@ -21,13 +23,22 @@ export const withChangeCoverage = Effect.fn("Changes.withChangeCoverage")(functi
21
23
  const observer = yield* ChangeObserver;
22
24
  const sql = yield* SqlClient.SqlClient;
23
25
  const named = new Set<unknown>();
26
+ const declarations = new Map<string, { keys: Set<string>; rows: number }>();
27
+ const outer = yield* ChangeDeclarations;
24
28
  const written = sql<TableWrites>`select relid, relname, (n_tup_ins + n_tup_upd + n_tup_del)::int as writes from pg_stat_xact_user_tables`;
25
29
  const checked = Effect.gen(function* () {
26
30
  const before = new Map((yield* written).map((row) => [row.relid, row.writes]));
27
31
  const result = yield* body;
28
- const missing = (yield* written).filter(
29
- (row) => row.writes > (before.get(row.relid) ?? 0) && !named.has(row.relname) && !options?.covers[row.relname]?.some((key) => named.has(key)),
30
- );
32
+ const missing = (yield* written).filter((row) => {
33
+ const count = row.writes - (before.get(row.relid) ?? 0);
34
+ if (count <= 0) {
35
+ return false;
36
+ }
37
+ const automatic = declarations.get(row.relname);
38
+ const explicit = options?.covers[row.relname] ?? [];
39
+ const expected = [...(automatic?.keys ?? []), ...invalidationKeys(explicit)];
40
+ return (count > (automatic?.rows ?? 0) && explicit.length === 0) || expected.some((key) => !named.has(key));
41
+ });
31
42
  if (missing.length > 0) {
32
43
  return yield* Effect.fail(new UnannouncedWrites({ tables: missing.map((row) => row.relname).sort() }));
33
44
  }
@@ -35,6 +46,20 @@ export const withChangeCoverage = Effect.fn("Changes.withChangeCoverage")(functi
35
46
  });
36
47
  return yield* checked.pipe(
37
48
  transact({ onSqlError: (error) => error }),
49
+ Effect.provideService(ChangeDeclarations, {
50
+ declare: (table, keys, rows) =>
51
+ Effect.andThen(
52
+ Effect.sync(() => {
53
+ const entry = declarations.get(table) ?? { keys: new Set<string>(), rows: 0 };
54
+ for (const key of keys) {
55
+ entry.keys.add(key);
56
+ }
57
+ entry.rows += rows;
58
+ declarations.set(table, entry);
59
+ }),
60
+ outer.declare(table, keys, rows),
61
+ ),
62
+ }),
38
63
  Effect.provideService(ChangeObserver, (event) => {
39
64
  if (event._tag === "Recorded") {
40
65
  for (const key of event.changes) {
@@ -0,0 +1,18 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import type { SqlClient } from "effect/sql";
4
+
5
+ export interface UpsertRow<TRow> {
6
+ readonly inserted: boolean;
7
+ readonly row: TRow;
8
+ }
9
+
10
+ export function upsertReturning(sql: SqlClient.SqlClient) {
11
+ return sql`*, (xmax = 0) as ${sql("effectSqlInserted")}`;
12
+ }
13
+
14
+ export function makeUpsertDecoder(model: Model.Any) {
15
+ const decode = Schema.decodeUnknownEffect(model);
16
+ return (row: Readonly<Record<string, unknown>> | undefined) =>
17
+ Effect.map(decode(row), (decoded) => ({ inserted: row?.effectSqlInserted === true, row: decoded }));
18
+ }
@@ -53,7 +53,7 @@ export function makeVersionedWrites(
53
53
  function insert(input: unknown) {
54
54
  return Effect.gen(function* () {
55
55
  const statement = yield* statements.insert(input);
56
- const rows = yield* sql`${statement} returning *`;
56
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
57
57
  yield* changes.record(rows);
58
58
  return yield* decodeRow(rows[0]);
59
59
  }).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insert`));
@@ -61,14 +61,14 @@ export function makeVersionedWrites(
61
61
  function insertVoid(input: unknown) {
62
62
  return Effect.gen(function* () {
63
63
  const statement = yield* statements.insert(input);
64
- const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
64
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
65
65
  yield* changes.record(rows);
66
66
  }).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insertVoid`));
67
67
  }
68
68
  function insertOrIgnore(input: unknown) {
69
69
  return Effect.gen(function* () {
70
70
  const statement = yield* statements.insert(input, true);
71
- const rows = yield* sql`${statement} returning *`;
71
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
72
72
  yield* changes.record(rows);
73
73
  return rows.length === 0 ? Option.none() : Option.some(yield* decodeRow(rows[0]));
74
74
  }).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insertOrIgnore`));
@@ -76,7 +76,7 @@ export function makeVersionedWrites(
76
76
  function update(input: Readonly<Record<string, unknown>>) {
77
77
  return Effect.gen(function* () {
78
78
  const { statement, classify } = yield* statements.update(input);
79
- const rows = yield* sql`${statement} returning *`;
79
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
80
80
  if (rows.length === 0) {
81
81
  return yield* classify();
82
82
  }
@@ -87,7 +87,7 @@ export function makeVersionedWrites(
87
87
  function updateVoid(input: Readonly<Record<string, unknown>>) {
88
88
  return Effect.gen(function* () {
89
89
  const { statement, classify } = yield* statements.update(input);
90
- const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
90
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
91
91
  if (rows.length === 0) {
92
92
  return yield* classify();
93
93
  }
@@ -97,7 +97,7 @@ export function makeVersionedWrites(
97
97
  function deleteRow(id: unknown, expected: number) {
98
98
  return Effect.gen(function* () {
99
99
  const { statement, classify } = yield* statements.remove(id, expected);
100
- const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
100
+ const rows = yield* sql`${statement} returning ${changes.returning()}`;
101
101
  if (rows.length === 0) {
102
102
  return yield* classify();
103
103
  }
@@ -37,7 +37,6 @@ export function versionedStatements(model: Model.Any, sql: SqlClient.SqlClient,
37
37
  const validateVersion = Schema.decodeUnknownEffect(Version);
38
38
  const clause =
39
39
  options.conflictColumns === undefined ? undefined : makeConflictClause(model, sql, { ...options, conflictColumns: options.conflictColumns });
40
- const returningKeys = sql.csv(idColumns.map((column) => sql`${sql(column)}`));
41
40
  function key(input: unknown) {
42
41
  return idColumns.length === 1 ? { [idColumns[0] ?? ""]: input } : input;
43
42
  }
@@ -86,5 +85,5 @@ export function versionedStatements(model: Model.Any, sql: SqlClient.SqlClient,
86
85
  };
87
86
  });
88
87
  }
89
- return { insert, remove, returningKeys, update };
88
+ return { insert, remove, update };
90
89
  }