@shivaedev/sql 0.23.0 → 0.25.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 (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +185 -170
  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/transact.js +1 -1
  43. package/dist/transact.js.map +1 -1
  44. package/dist/upsertResult.d.ts +13 -0
  45. package/dist/upsertResult.d.ts.map +1 -0
  46. package/dist/upsertResult.js +9 -0
  47. package/dist/upsertResult.js.map +1 -0
  48. package/dist/versionedRepository.js +6 -6
  49. package/dist/versionedRepository.js.map +1 -1
  50. package/dist/versionedStatements.d.ts +0 -1
  51. package/dist/versionedStatements.d.ts.map +1 -1
  52. package/dist/versionedStatements.js +1 -2
  53. package/dist/versionedStatements.js.map +1 -1
  54. package/package.json +12 -11
  55. package/src/ChangeDeclarations.ts +5 -0
  56. package/src/batchWrites.ts +6 -2
  57. package/src/compositeRepository.ts +3 -9
  58. package/src/conditionalUpsert.ts +11 -7
  59. package/src/conflictWrites.ts +5 -3
  60. package/src/insertFromSelect.ts +1 -1
  61. package/src/mapSqlError.ts +31 -0
  62. package/src/repository.ts +1 -1
  63. package/src/repositoryChanges.ts +56 -19
  64. package/src/repositoryWrites.ts +7 -5
  65. package/src/testing.ts +30 -5
  66. package/src/transact.ts +1 -1
  67. package/src/upsertResult.ts +18 -0
  68. package/src/versionedRepository.ts +6 -6
  69. package/src/versionedStatements.ts +1 -2
@@ -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) {
package/src/transact.ts CHANGED
@@ -35,7 +35,7 @@ const guard = Effect.fn("Transact.guard")(function* () {
35
35
  const invalidations = makeChannel<unknown, SqlClient.SqlClient | Reactivity.Reactivity, SqlError>({
36
36
  name: "@shivaedev/sql/transact",
37
37
  owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
38
- publish: (keys) => Reactivity.invalidate(keys),
38
+ publish: (keys) => Reactivity.Reactivity.use((reactivity) => Effect.sync(() => reactivity.invalidateUnsafe(keys))),
39
39
  unowned: guard(),
40
40
  });
41
41
 
@@ -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
  }