@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.
- package/CHANGELOG.md +29 -0
- package/README.md +185 -170
- package/dist/ChangeDeclarations.d.ts +5 -0
- package/dist/ChangeDeclarations.d.ts.map +1 -0
- package/dist/ChangeDeclarations.js +3 -0
- package/dist/ChangeDeclarations.js.map +1 -0
- package/dist/batchWrites.d.ts +1 -1
- package/dist/batchWrites.d.ts.map +1 -1
- package/dist/batchWrites.js +6 -3
- package/dist/batchWrites.js.map +1 -1
- package/dist/compositeRepository.d.ts +2 -2
- package/dist/compositeRepository.d.ts.map +1 -1
- package/dist/compositeRepository.js +1 -8
- package/dist/compositeRepository.js.map +1 -1
- package/dist/conditionalUpsert.d.ts +11 -4
- package/dist/conditionalUpsert.d.ts.map +1 -1
- package/dist/conditionalUpsert.js +7 -3
- package/dist/conditionalUpsert.js.map +1 -1
- package/dist/conflictWrites.d.ts +14 -1
- package/dist/conflictWrites.d.ts.map +1 -1
- package/dist/conflictWrites.js +5 -3
- package/dist/conflictWrites.js.map +1 -1
- package/dist/insertFromSelect.js +1 -1
- package/dist/insertFromSelect.js.map +1 -1
- package/dist/mapSqlError.d.ts +6 -0
- package/dist/mapSqlError.d.ts.map +1 -0
- package/dist/mapSqlError.js +22 -0
- package/dist/mapSqlError.js.map +1 -0
- package/dist/repository.js +1 -1
- package/dist/repository.js.map +1 -1
- package/dist/repositoryChanges.d.ts +4 -3
- package/dist/repositoryChanges.d.ts.map +1 -1
- package/dist/repositoryChanges.js +46 -13
- package/dist/repositoryChanges.js.map +1 -1
- package/dist/repositoryWrites.d.ts.map +1 -1
- package/dist/repositoryWrites.js +5 -5
- package/dist/repositoryWrites.js.map +1 -1
- package/dist/testing.d.ts +3 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +25 -3
- package/dist/testing.js.map +1 -1
- package/dist/transact.js +1 -1
- package/dist/transact.js.map +1 -1
- package/dist/upsertResult.d.ts +13 -0
- package/dist/upsertResult.d.ts.map +1 -0
- package/dist/upsertResult.js +9 -0
- package/dist/upsertResult.js.map +1 -0
- package/dist/versionedRepository.js +6 -6
- package/dist/versionedRepository.js.map +1 -1
- package/dist/versionedStatements.d.ts +0 -1
- package/dist/versionedStatements.d.ts.map +1 -1
- package/dist/versionedStatements.js +1 -2
- package/dist/versionedStatements.js.map +1 -1
- package/package.json +12 -11
- package/src/ChangeDeclarations.ts +5 -0
- package/src/batchWrites.ts +6 -2
- package/src/compositeRepository.ts +3 -9
- package/src/conditionalUpsert.ts +11 -7
- package/src/conflictWrites.ts +5 -3
- package/src/insertFromSelect.ts +1 -1
- package/src/mapSqlError.ts +31 -0
- package/src/repository.ts +1 -1
- package/src/repositoryChanges.ts +56 -19
- package/src/repositoryWrites.ts +7 -5
- package/src/testing.ts +30 -5
- package/src/transact.ts +1 -1
- package/src/upsertResult.ts +18 -0
- package/src/versionedRepository.ts +6 -6
- package/src/versionedStatements.ts +1 -2
package/src/repositoryChanges.ts
CHANGED
|
@@ -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* (
|
|
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
|
-
|
|
60
|
+
const record = Effect.fnUntraced(function* (rows: readonly Readonly<Record<string, unknown>>[]) {
|
|
29
61
|
if (rows.length === 0) {
|
|
30
|
-
return
|
|
62
|
+
return;
|
|
31
63
|
}
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
});
|
package/src/repositoryWrites.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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,
|
|
88
|
+
return { insert, remove, update };
|
|
90
89
|
}
|