@shivaedev/sql 0.0.0 → 0.22.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 +275 -0
- package/LICENSE +21 -0
- package/README.md +613 -1
- package/dist/SchemaComponent.d.ts +9 -0
- package/dist/SchemaComponent.d.ts.map +1 -0
- package/dist/SchemaComponent.js +6 -0
- package/dist/SchemaComponent.js.map +1 -0
- package/dist/WorkerRelease.d.ts +14 -0
- package/dist/WorkerRelease.d.ts.map +1 -0
- package/dist/WorkerRelease.js +30 -0
- package/dist/WorkerRelease.js.map +1 -0
- package/dist/advisoryXactLock.d.ts +10 -0
- package/dist/advisoryXactLock.d.ts.map +1 -0
- package/dist/advisoryXactLock.js +18 -0
- package/dist/advisoryXactLock.js.map +1 -0
- package/dist/aggregateColumn.d.ts +8 -0
- package/dist/aggregateColumn.d.ts.map +1 -0
- package/dist/aggregateColumn.js +44 -0
- package/dist/aggregateColumn.js.map +1 -0
- package/dist/aggregateQuery.d.ts +51 -0
- package/dist/aggregateQuery.d.ts.map +1 -0
- package/dist/aggregateQuery.js +2 -0
- package/dist/aggregateQuery.js.map +1 -0
- package/dist/assertPostgresMigrations.d.ts +7 -0
- package/dist/assertPostgresMigrations.d.ts.map +1 -0
- package/dist/assertPostgresMigrations.js +20 -0
- package/dist/assertPostgresMigrations.js.map +1 -0
- package/dist/batchWrites.d.ts +16 -0
- package/dist/batchWrites.d.ts.map +1 -0
- package/dist/batchWrites.js +49 -0
- package/dist/batchWrites.js.map +1 -0
- package/dist/clientOptions.d.ts +8 -0
- package/dist/clientOptions.d.ts.map +1 -0
- package/dist/clientOptions.js +8 -0
- package/dist/clientOptions.js.map +1 -0
- package/dist/compositeRepository.d.ts +41 -0
- package/dist/compositeRepository.d.ts.map +1 -0
- package/dist/compositeRepository.js +38 -0
- package/dist/compositeRepository.js.map +1 -0
- package/dist/conditionalUpsert.d.ts +59 -0
- package/dist/conditionalUpsert.d.ts.map +1 -0
- package/dist/conditionalUpsert.js +28 -0
- package/dist/conditionalUpsert.js.map +1 -0
- package/dist/conflictWrites.d.ts +45 -0
- package/dist/conflictWrites.d.ts.map +1 -0
- package/dist/conflictWrites.js +40 -0
- package/dist/conflictWrites.js.map +1 -0
- package/dist/coordinateWorkerRelease.d.ts +5 -0
- package/dist/coordinateWorkerRelease.d.ts.map +1 -0
- package/dist/coordinateWorkerRelease.js +30 -0
- package/dist/coordinateWorkerRelease.js.map +1 -0
- package/dist/insertFromSelect.d.ts +24 -0
- package/dist/insertFromSelect.d.ts.map +1 -0
- package/dist/insertFromSelect.js +15 -0
- package/dist/insertFromSelect.js.map +1 -0
- package/dist/latestPerGroup.d.ts +11 -0
- package/dist/latestPerGroup.d.ts.map +1 -0
- package/dist/latestPerGroup.js +51 -0
- package/dist/latestPerGroup.js.map +1 -0
- package/dist/listenForChanges.d.ts +5 -0
- package/dist/listenForChanges.d.ts.map +1 -0
- package/dist/listenForChanges.js +75 -0
- package/dist/listenForChanges.js.map +1 -0
- package/dist/makeAggregate.d.ts +8 -0
- package/dist/makeAggregate.d.ts.map +1 -0
- package/dist/makeAggregate.js +38 -0
- package/dist/makeAggregate.js.map +1 -0
- package/dist/makeConflictClause.d.ts +10 -0
- package/dist/makeConflictClause.d.ts.map +1 -0
- package/dist/makeConflictClause.js +41 -0
- package/dist/makeConflictClause.js.map +1 -0
- package/dist/makeQueryClauses.d.ts +14 -0
- package/dist/makeQueryClauses.d.ts.map +1 -0
- package/dist/makeQueryClauses.js +20 -0
- package/dist/makeQueryClauses.js.map +1 -0
- package/dist/migrationConvention.d.ts +3 -0
- package/dist/migrationConvention.d.ts.map +1 -0
- package/dist/migrationConvention.js +3 -0
- package/dist/migrationConvention.js.map +1 -0
- package/dist/migrations.d.ts +12 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +41 -0
- package/dist/migrations.js.map +1 -0
- package/dist/migrationsFromDirectory.d.ts +4 -0
- package/dist/migrationsFromDirectory.d.ts.map +1 -0
- package/dist/migrationsFromDirectory.js +7 -0
- package/dist/migrationsFromDirectory.js.map +1 -0
- package/dist/notifyPayload.d.ts +7 -0
- package/dist/notifyPayload.d.ts.map +1 -0
- package/dist/notifyPayload.js +31 -0
- package/dist/notifyPayload.js.map +1 -0
- package/dist/pendingPostgresMigrations.d.ts +24 -0
- package/dist/pendingPostgresMigrations.d.ts.map +1 -0
- package/dist/pendingPostgresMigrations.js +30 -0
- package/dist/pendingPostgresMigrations.js.map +1 -0
- package/dist/pinnedGroups.d.ts +22 -0
- package/dist/pinnedGroups.d.ts.map +1 -0
- package/dist/pinnedGroups.js +35 -0
- package/dist/pinnedGroups.js.map +1 -0
- package/dist/postgresChanges.d.ts +20 -0
- package/dist/postgresChanges.d.ts.map +1 -0
- package/dist/postgresChanges.js +26 -0
- package/dist/postgresChanges.js.map +1 -0
- package/dist/postgresRelease.d.ts +9 -0
- package/dist/postgresRelease.d.ts.map +1 -0
- package/dist/postgresRelease.js +39 -0
- package/dist/postgresRelease.js.map +1 -0
- package/dist/releaseWorkers.d.ts +8 -0
- package/dist/releaseWorkers.d.ts.map +1 -0
- package/dist/releaseWorkers.js +26 -0
- package/dist/releaseWorkers.js.map +1 -0
- package/dist/repository.d.ts +66 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +43 -0
- package/dist/repository.js.map +1 -0
- package/dist/repositoryChanges.d.ts +9 -0
- package/dist/repositoryChanges.d.ts.map +1 -0
- package/dist/repositoryChanges.js +32 -0
- package/dist/repositoryChanges.js.map +1 -0
- package/dist/repositoryFilter.d.ts +43 -0
- package/dist/repositoryFilter.d.ts.map +1 -0
- package/dist/repositoryFilter.js +69 -0
- package/dist/repositoryFilter.js.map +1 -0
- package/dist/repositoryQueries.d.ts +26 -0
- package/dist/repositoryQueries.d.ts.map +1 -0
- package/dist/repositoryQueries.js +49 -0
- package/dist/repositoryQueries.js.map +1 -0
- package/dist/repositoryWrites.d.ts +18 -0
- package/dist/repositoryWrites.d.ts.map +1 -0
- package/dist/repositoryWrites.js +49 -0
- package/dist/repositoryWrites.js.map +1 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +29 -0
- package/dist/testing.js.map +1 -0
- package/dist/transact.d.ts +18 -0
- package/dist/transact.d.ts.map +1 -0
- package/dist/transact.js +43 -0
- package/dist/transact.js.map +1 -0
- package/dist/tupleIn.d.ts +19 -0
- package/dist/tupleIn.d.ts.map +1 -0
- package/dist/tupleIn.js +20 -0
- package/dist/tupleIn.js.map +1 -0
- package/dist/versionedRepository.d.ts +37 -0
- package/dist/versionedRepository.d.ts.map +1 -0
- package/dist/versionedRepository.js +63 -0
- package/dist/versionedRepository.js.map +1 -0
- package/dist/versionedStatements.d.ts +25 -0
- package/dist/versionedStatements.d.ts.map +1 -0
- package/dist/versionedStatements.js +75 -0
- package/dist/versionedStatements.js.map +1 -0
- package/package.json +71 -4
- package/src/SchemaComponent.ts +12 -0
- package/src/WorkerRelease.ts +38 -0
- package/src/advisoryXactLock.ts +17 -0
- package/src/aggregateColumn.ts +49 -0
- package/src/aggregateQuery.ts +57 -0
- package/src/assertPostgresMigrations.ts +24 -0
- package/src/batchWrites.ts +69 -0
- package/src/clientOptions.ts +16 -0
- package/src/compositeRepository.ts +101 -0
- package/src/conditionalUpsert.ts +97 -0
- package/src/conflictWrites.ts +93 -0
- package/src/coordinateWorkerRelease.ts +33 -0
- package/src/insertFromSelect.ts +47 -0
- package/src/latestPerGroup.ts +58 -0
- package/src/listenForChanges.ts +97 -0
- package/src/makeAggregate.ts +64 -0
- package/src/makeConflictClause.ts +53 -0
- package/src/makeQueryClauses.ts +36 -0
- package/src/migrationConvention.ts +2 -0
- package/src/migrations.ts +58 -0
- package/src/migrationsFromDirectory.ts +7 -0
- package/src/notifyPayload.ts +34 -0
- package/src/pendingPostgresMigrations.ts +42 -0
- package/src/pinnedGroups.ts +46 -0
- package/src/postgresChanges.ts +48 -0
- package/src/postgresRelease.ts +41 -0
- package/src/releaseWorkers.ts +32 -0
- package/src/repository.ts +147 -0
- package/src/repositoryChanges.ts +49 -0
- package/src/repositoryFilter.ts +108 -0
- package/src/repositoryQueries.ts +107 -0
- package/src/repositoryWrites.ts +70 -0
- package/src/testing.ts +38 -0
- package/src/transact.ts +70 -0
- package/src/tupleIn.ts +44 -0
- package/src/versionedRepository.ts +108 -0
- package/src/versionedStatements.ts +90 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Model } from "effect/schema";
|
|
3
|
+
import type { SqlClient } from "effect/sql";
|
|
4
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
5
|
+
import type { Fragment } from "effect/sql/Statement";
|
|
6
|
+
import type { Aggregate } from "#aggregateQuery.ts";
|
|
7
|
+
import { latestPerGroup as makeLatestPerGroup } from "#latestPerGroup.ts";
|
|
8
|
+
import { makeAggregate } from "#makeAggregate.ts";
|
|
9
|
+
import { makeQueryClauses } from "#makeQueryClauses.ts";
|
|
10
|
+
import type { FindMany, LatestPerGroup } from "#repository.ts";
|
|
11
|
+
import { makeFilter } from "#repositoryFilter.ts";
|
|
12
|
+
|
|
13
|
+
type Key<TModel extends Model.Any> = Extract<keyof TModel["fields"] & keyof Row<TModel>, string>;
|
|
14
|
+
type Row<TModel extends Model.Any> = Schema.Struct.Type<TModel["fields"]>;
|
|
15
|
+
|
|
16
|
+
const CountResult = Schema.Tuple([Schema.Struct({ count: Schema.NumberFromString })]);
|
|
17
|
+
|
|
18
|
+
export interface RepositorySelect<TFields extends Schema.Struct.Fields, TServices = never> {
|
|
19
|
+
readonly fields: TFields;
|
|
20
|
+
readonly statement: Effect.Effect<Fragment, Schema.SchemaError, TServices>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Queries<TModel extends Model.Any> {
|
|
24
|
+
aggregate: Aggregate<TModel>;
|
|
25
|
+
count: (
|
|
26
|
+
query?: Pick<FindMany<TModel, Key<TModel>>, "where">,
|
|
27
|
+
) => Effect.Effect<number, SqlError | Schema.SchemaError, TModel["fields"][Key<TModel>]["EncodingServices"]>;
|
|
28
|
+
findMany: <TKey extends Key<TModel> = Key<TModel>>(
|
|
29
|
+
query?: FindMany<TModel, TKey>,
|
|
30
|
+
) => Effect.Effect<
|
|
31
|
+
Pick<Row<TModel>, TKey>[],
|
|
32
|
+
SqlError | Schema.SchemaError,
|
|
33
|
+
TModel["fields"][Key<TModel>]["EncodingServices"] | TModel["fields"][TKey]["DecodingServices"]
|
|
34
|
+
>;
|
|
35
|
+
latestPerGroup: (
|
|
36
|
+
query: LatestPerGroup<TModel>,
|
|
37
|
+
) => Effect.Effect<
|
|
38
|
+
Row<TModel>[],
|
|
39
|
+
SqlError | Schema.SchemaError,
|
|
40
|
+
TModel["fields"][Key<TModel>]["EncodingServices"] | TModel["fields"][Key<TModel>]["DecodingServices"]
|
|
41
|
+
>;
|
|
42
|
+
select: <TKey extends Key<TModel> = Key<TModel>>(
|
|
43
|
+
query?: FindMany<TModel, TKey>,
|
|
44
|
+
) => RepositorySelect<Pick<TModel["fields"], TKey>, TModel["fields"][Key<TModel>]["EncodingServices"]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function makeQueries<TModel extends Model.Any>(
|
|
48
|
+
model: TModel,
|
|
49
|
+
sql: SqlClient.SqlClient,
|
|
50
|
+
options: {
|
|
51
|
+
readonly tableName: string;
|
|
52
|
+
readonly spanPrefix: string;
|
|
53
|
+
},
|
|
54
|
+
): Queries<TModel> {
|
|
55
|
+
const { fieldAt, where } = makeFilter(model, sql);
|
|
56
|
+
const readLatest = makeLatestPerGroup(model, sql, options.tableName);
|
|
57
|
+
const { decodeLimit, orderClause, limitClause } = makeQueryClauses(sql, fieldAt);
|
|
58
|
+
const aggregate = makeAggregate(model, sql, options);
|
|
59
|
+
function count(query?: Pick<FindMany<TModel, Key<TModel>>, "where">): ReturnType<Queries<TModel>["count"]>;
|
|
60
|
+
function count(query: Pick<FindMany<TModel, Key<TModel>>, "where"> = {}): Effect.Effect<number, SqlError | Schema.SchemaError, unknown> {
|
|
61
|
+
return Effect.gen(function* () {
|
|
62
|
+
const predicate = yield* where(query.where, true, true);
|
|
63
|
+
const rows = yield* sql`select cast(count(*) as text) as count from ${sql(options.tableName)} where ${predicate}`;
|
|
64
|
+
const [result] = yield* Schema.decodeUnknownEffect(CountResult)(rows);
|
|
65
|
+
return result.count;
|
|
66
|
+
}).pipe(Effect.withSpan(`${options.spanPrefix}.count`));
|
|
67
|
+
}
|
|
68
|
+
function select<TKey extends Key<TModel> = Key<TModel>>(
|
|
69
|
+
query?: FindMany<TModel, TKey>,
|
|
70
|
+
): RepositorySelect<Pick<TModel["fields"], TKey>, TModel["fields"][Key<TModel>]["EncodingServices"]>;
|
|
71
|
+
function select(query: FindMany<TModel, Key<TModel>> = {}): RepositorySelect<Schema.Struct.Fields, unknown> {
|
|
72
|
+
const keys = query.select ?? Object.keys(model.fields);
|
|
73
|
+
const fields = Object.fromEntries(keys.map((key) => [key, model.fields[key]]));
|
|
74
|
+
const statement = Effect.gen(function* () {
|
|
75
|
+
yield* Effect.forEach(keys, fieldAt);
|
|
76
|
+
const predicate = yield* where(query.where, true, true);
|
|
77
|
+
const order = yield* orderClause(query.orderBy);
|
|
78
|
+
const limit = yield* limitClause(query.limit);
|
|
79
|
+
return sql`select ${sql.csv(keys.map((key) => sql`${sql(key)}`))} from ${sql(options.tableName)} where ${predicate} ${order} ${limit}`;
|
|
80
|
+
});
|
|
81
|
+
return { fields, statement };
|
|
82
|
+
}
|
|
83
|
+
function findMany<TKey extends Key<TModel> = Key<TModel>>(
|
|
84
|
+
query?: FindMany<TModel, TKey>,
|
|
85
|
+
): Effect.Effect<
|
|
86
|
+
Pick<Row<TModel>, TKey>[],
|
|
87
|
+
SqlError | Schema.SchemaError,
|
|
88
|
+
TModel["fields"][Key<TModel>]["EncodingServices"] | TModel["fields"][TKey]["DecodingServices"]
|
|
89
|
+
>;
|
|
90
|
+
// Object.fromEntries erases the selected key-to-schema correspondence, so the typed overload restates it.
|
|
91
|
+
function findMany(query: FindMany<TModel, Key<TModel>> = {}): Effect.Effect<unknown, SqlError | Schema.SchemaError, unknown> {
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const selected = select(query);
|
|
94
|
+
const rows = yield* sql`${yield* selected.statement}`;
|
|
95
|
+
return yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct(selected.fields)))(rows);
|
|
96
|
+
}).pipe(Effect.withSpan(`${options.spanPrefix}.findMany`));
|
|
97
|
+
}
|
|
98
|
+
function latestPerGroup(query: LatestPerGroup<TModel>): ReturnType<Queries<TModel>["latestPerGroup"]>;
|
|
99
|
+
function latestPerGroup(query: LatestPerGroup<TModel>): Effect.Effect<unknown, SqlError | Schema.SchemaError, unknown> {
|
|
100
|
+
return Effect.gen(function* () {
|
|
101
|
+
const order = yield* orderClause(query.orderBy);
|
|
102
|
+
const rowLimit = yield* decodeLimit(query.limit);
|
|
103
|
+
return yield* readLatest(query, order, rowLimit);
|
|
104
|
+
}).pipe(Effect.withSpan(`${options.spanPrefix}.latestPerGroup`));
|
|
105
|
+
}
|
|
106
|
+
return { aggregate, count, findMany, latestPerGroup, select };
|
|
107
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Model } from "effect/schema";
|
|
3
|
+
import { type SqlClient, SqlSchema } from "effect/sql";
|
|
4
|
+
import type { RepositoryChanges } from "./repositoryChanges.ts";
|
|
5
|
+
import { makeFilter } from "./repositoryFilter.ts";
|
|
6
|
+
|
|
7
|
+
export function repositoryWrites(
|
|
8
|
+
model: Model.Any,
|
|
9
|
+
sql: SqlClient.SqlClient,
|
|
10
|
+
options: { readonly idColumns: readonly string[]; readonly tableName: string; readonly spanPrefix: string },
|
|
11
|
+
changes: RepositoryChanges,
|
|
12
|
+
) {
|
|
13
|
+
const { idColumns, tableName, spanPrefix } = options;
|
|
14
|
+
const { where } = makeFilter(model, sql);
|
|
15
|
+
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
16
|
+
const filterRecord = Schema.decodeUnknownEffect(Schema.Record(Schema.PropertyKey, Schema.Unknown));
|
|
17
|
+
function predicate(input: Readonly<Record<string, unknown>>) {
|
|
18
|
+
return sql.and(idColumns.map((column) => sql`${sql(column)} = ${input[column]}`));
|
|
19
|
+
}
|
|
20
|
+
function insert(input: unknown) {
|
|
21
|
+
return Effect.flatMap(record(input), (row) =>
|
|
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 *`
|
|
25
|
+
).pipe(Effect.tap(changes.record)),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
function update(input: unknown) {
|
|
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)),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const insertRow = SqlSchema.findOne({ execute: insert, Request: model.insert, Result: model });
|
|
34
|
+
const insertVoid = SqlSchema.void({ execute: insert, Request: model.insert });
|
|
35
|
+
const updateRow = SqlSchema.findOne({ execute: update, Request: model.update, Result: model });
|
|
36
|
+
const updateVoid = SqlSchema.void({ execute: update, Request: model.update });
|
|
37
|
+
const idColumn = idColumns[0] ?? "";
|
|
38
|
+
const key = model.fields[idColumn];
|
|
39
|
+
if (key === undefined) {
|
|
40
|
+
throw new Error("Unknown repository ID column");
|
|
41
|
+
}
|
|
42
|
+
const isKey = Schema.is(key);
|
|
43
|
+
const removeByKey = SqlSchema.void({
|
|
44
|
+
execute: (id) =>
|
|
45
|
+
Effect.flatMap(record({ [idColumn]: id }), (encoded) =>
|
|
46
|
+
sql`delete from ${sql(tableName)} where ${predicate(encoded)} returning *`.pipe(Effect.tap(changes.record)),
|
|
47
|
+
),
|
|
48
|
+
Request: key,
|
|
49
|
+
});
|
|
50
|
+
function remove(input: unknown) {
|
|
51
|
+
return idColumns.length === 1 && isKey(input)
|
|
52
|
+
? removeByKey(input)
|
|
53
|
+
: Effect.flatMap(filterRecord(input), (filter) =>
|
|
54
|
+
Effect.flatMap(where(filter, true), (clause) =>
|
|
55
|
+
sql`delete from ${sql(tableName)} where ${clause} returning *`.pipe(Effect.tap(changes.record), Effect.asVoid),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
delete: (id: unknown) => changes.write(remove(id)).pipe(Effect.withSpan(`${spanPrefix}.delete`)),
|
|
61
|
+
insert: (input: unknown) =>
|
|
62
|
+
changes.write(insertRow(input).pipe(Effect.catchTag("NoSuchElementError", Effect.die))).pipe(Effect.withSpan(`${spanPrefix}.insert`)),
|
|
63
|
+
insertVoid: (input: unknown) => changes.write(insertVoid(input)).pipe(Effect.withSpan(`${spanPrefix}.insertVoid`)),
|
|
64
|
+
update: (input: unknown, settings?: { readonly onMissing: "fail" }) =>
|
|
65
|
+
changes
|
|
66
|
+
.write(settings?.onMissing === "fail" ? updateRow(input) : updateRow(input).pipe(Effect.catchTag("NoSuchElementError", Effect.die)))
|
|
67
|
+
.pipe(Effect.withSpan(`${spanPrefix}.update`)),
|
|
68
|
+
updateVoid: (input: unknown) => changes.write(updateVoid(input)).pipe(Effect.withSpan(`${spanPrefix}.updateVoid`)),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/sql";
|
|
3
|
+
import { ChangeObserver, transact } from "./transact.ts";
|
|
4
|
+
|
|
5
|
+
export class UnannouncedWrites extends Data.TaggedError("UnannouncedWrites")<{ readonly tables: readonly string[] }> {}
|
|
6
|
+
|
|
7
|
+
interface TableWrites {
|
|
8
|
+
readonly relid: number;
|
|
9
|
+
readonly relname: string;
|
|
10
|
+
readonly writes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const withChangeCoverage = Effect.fn("Changes.withChangeCoverage")(function* <A, E, R>(body: Effect.Effect<A, E, R>) {
|
|
14
|
+
const observer = yield* ChangeObserver;
|
|
15
|
+
const sql = yield* SqlClient.SqlClient;
|
|
16
|
+
const named = new Set<unknown>();
|
|
17
|
+
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`;
|
|
18
|
+
const checked = Effect.gen(function* () {
|
|
19
|
+
const before = new Map((yield* written).map((row) => [row.relid, row.writes]));
|
|
20
|
+
const result = yield* body;
|
|
21
|
+
const missing = (yield* written).filter((row) => row.writes > (before.get(row.relid) ?? 0) && !named.has(row.relname));
|
|
22
|
+
if (missing.length > 0) {
|
|
23
|
+
return yield* Effect.fail(new UnannouncedWrites({ tables: missing.map((row) => row.relname).sort() }));
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
});
|
|
27
|
+
return yield* checked.pipe(
|
|
28
|
+
transact({ onSqlError: (error) => error }),
|
|
29
|
+
Effect.provideService(ChangeObserver, (event) => {
|
|
30
|
+
if (event._tag === "Recorded") {
|
|
31
|
+
for (const key of event.changes) {
|
|
32
|
+
named.add(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return observer(event);
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
package/src/transact.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, Hash } from "effect";
|
|
2
|
+
import { dual } from "effect/Function";
|
|
3
|
+
import type { ReadonlyRecord } from "effect/Record";
|
|
4
|
+
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
5
|
+
import { SqlClient } from "effect/sql";
|
|
6
|
+
import { isSqlError, type SqlError } from "effect/sql/SqlError";
|
|
7
|
+
import { makeChannel } from "@shivaedev/changes/channel.ts";
|
|
8
|
+
|
|
9
|
+
export type InvalidationKeys = readonly unknown[] | ReadonlyRecord<string, readonly unknown[]>;
|
|
10
|
+
|
|
11
|
+
function hashOf(value: unknown): unknown {
|
|
12
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean"
|
|
13
|
+
? String(value)
|
|
14
|
+
: Hash.hash(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isList(keys: InvalidationKeys): keys is readonly unknown[] {
|
|
18
|
+
return Array.isArray(keys);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function flatten(keys: InvalidationKeys): readonly unknown[] {
|
|
22
|
+
return isList(keys) ? keys : Object.entries(keys).flatMap(([name, ids]) => [name, ...ids.map((id) => `${name}:${hashOf(id)}`)]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const guard = Effect.fn("Transact.guard")(function* () {
|
|
26
|
+
const sql = yield* SqlClient.SqlClient;
|
|
27
|
+
const native = yield* Effect.serviceOption(sql.transactionService);
|
|
28
|
+
if (native._tag === "Some") {
|
|
29
|
+
return yield* Effect.die(
|
|
30
|
+
new Error("transact cannot announce changes after a transaction it does not own; begin the outer transaction with transact"),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const invalidations = makeChannel<unknown, SqlClient.SqlClient | Reactivity.Reactivity, SqlError>({
|
|
36
|
+
name: "@shivaedev/sql/transact",
|
|
37
|
+
owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
|
|
38
|
+
publish: (keys) => Reactivity.invalidate(keys),
|
|
39
|
+
unowned: guard(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const ChangeObserver = invalidations.Observer;
|
|
43
|
+
export const BeforeCommit = invalidations.BeforeCommit;
|
|
44
|
+
export const CommittedSink = invalidations.CommittedSink;
|
|
45
|
+
|
|
46
|
+
export const invalidateOnCommit = Effect.fn("Transact.invalidateOnCommit")(function* (keys: InvalidationKeys) {
|
|
47
|
+
yield* invalidations.record(flatten(keys));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export interface TransactOptions<TError> {
|
|
51
|
+
readonly onSqlError: (error: SqlError) => TError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const transact: {
|
|
55
|
+
<TError>(
|
|
56
|
+
options: TransactOptions<TError>,
|
|
57
|
+
): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, Exclude<E, SqlError> | TError, R | SqlClient.SqlClient | Reactivity.Reactivity>;
|
|
58
|
+
<A, E, R, TError>(
|
|
59
|
+
effect: Effect.Effect<A, E, R>,
|
|
60
|
+
options: TransactOptions<TError>,
|
|
61
|
+
): Effect.Effect<A, Exclude<E, SqlError> | TError, R | SqlClient.SqlClient | Reactivity.Reactivity>;
|
|
62
|
+
} = dual(
|
|
63
|
+
2,
|
|
64
|
+
Effect.fn("Transact.transact")(function* <A, E, R, TError>(effect: Effect.Effect<A, E, R>, options: TransactOptions<TError>) {
|
|
65
|
+
const sql = yield* SqlClient.SqlClient;
|
|
66
|
+
return yield* invalidations
|
|
67
|
+
.within(sql.withTransaction)(effect)
|
|
68
|
+
.pipe(Effect.catchIf(isSqlError, (error) => Effect.fail(options.onSqlError(error))));
|
|
69
|
+
}),
|
|
70
|
+
);
|
package/src/tupleIn.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Model } from "effect/schema";
|
|
3
|
+
import type { SqlClient } from "effect/sql";
|
|
4
|
+
|
|
5
|
+
export const tupleFilter = Symbol("sql/tupleIn");
|
|
6
|
+
type Row<TModel extends Model.Any> = Schema.Struct.Type<TModel["fields"]>;
|
|
7
|
+
type Key<TModel extends Model.Any> = Extract<keyof Row<TModel>, string>;
|
|
8
|
+
|
|
9
|
+
export interface TupleFilter<TModel extends Model.Any> {
|
|
10
|
+
readonly [tupleFilter]: {
|
|
11
|
+
readonly model: TModel;
|
|
12
|
+
readonly fields: readonly Key<TModel>[];
|
|
13
|
+
readonly values: readonly (readonly unknown[])[];
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tupleIn<TModel extends Model.Any, const TFields extends readonly [Key<TModel>, Key<TModel>, ...Key<TModel>[]]>(
|
|
18
|
+
model: TModel,
|
|
19
|
+
fields: TFields,
|
|
20
|
+
values: readonly { readonly [TIndex in keyof TFields]: Row<TModel>[TFields[TIndex]] }[],
|
|
21
|
+
): TupleFilter<TModel> {
|
|
22
|
+
return { [tupleFilter]: { fields, model, values } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function tuplePredicate(
|
|
26
|
+
input: Partial<TupleFilter<Model.Any>>,
|
|
27
|
+
sql: SqlClient.SqlClient,
|
|
28
|
+
fieldAt: (key: string) => Effect.Effect<Schema.Constraint, Schema.SchemaError>,
|
|
29
|
+
) {
|
|
30
|
+
const tuple = input[tupleFilter];
|
|
31
|
+
if (tuple === undefined) {
|
|
32
|
+
return Effect.succeed(undefined);
|
|
33
|
+
}
|
|
34
|
+
return Effect.gen(function* () {
|
|
35
|
+
const fields = yield* Effect.forEach(tuple.fields, fieldAt);
|
|
36
|
+
const encode = Schema.encodeUnknownEffect(Schema.Tuple(fields));
|
|
37
|
+
const columns = sql.csv(tuple.fields.map((field) => sql`${sql(field)}`));
|
|
38
|
+
// Row comparisons retain column type inference for string-encoded numerics without PostgreSQL's deeply nested row-IN rewrite.
|
|
39
|
+
const rows = yield* Effect.forEach(tuple.values, (value) =>
|
|
40
|
+
Effect.map(encode(value), (encoded) => sql`(${columns}) = (${sql.csv(encoded.map((entry) => sql`${entry}`))})`),
|
|
41
|
+
);
|
|
42
|
+
return rows.length === 0 ? sql`1 = 0` : sql.or(rows);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import type { Model } from "effect/schema";
|
|
3
|
+
import type { SqlClient, SqlModel } from "effect/sql";
|
|
4
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
5
|
+
import type { PrimaryKey, Repository } from "#compositeRepository.ts";
|
|
6
|
+
import type { OptimisticConcurrencyError } from "#repository.ts";
|
|
7
|
+
import { type VersionedOptions, versionedStatements } from "#versionedStatements.ts";
|
|
8
|
+
import type { RepositoryChanges } from "./repositoryChanges.ts";
|
|
9
|
+
|
|
10
|
+
export type IdColumn<TModel extends Model.Any> = keyof TModel["Type"] & keyof TModel["update"]["Type"] & keyof TModel["fields"];
|
|
11
|
+
export type VersionColumn<TModel extends Model.Any, TId extends PrimaryKey<TModel>> = {
|
|
12
|
+
[TKey in Extract<keyof TModel["fields"] & keyof TModel["update"]["Type"], string>]: TModel["fields"][TKey] extends Schema.ConstraintCodec<
|
|
13
|
+
number,
|
|
14
|
+
number,
|
|
15
|
+
never,
|
|
16
|
+
never
|
|
17
|
+
>
|
|
18
|
+
? TModel["update"]["Type"] extends { readonly [TField in TKey]: number }
|
|
19
|
+
? TKey
|
|
20
|
+
: never
|
|
21
|
+
: never;
|
|
22
|
+
}[Extract<keyof TModel["fields"] & keyof TModel["update"]["Type"], string>] &
|
|
23
|
+
Exclude<keyof TModel["fields"], (TId extends readonly PropertyKey[] ? TId[number] : TId) | keyof TModel["insert"]["Type"]>;
|
|
24
|
+
|
|
25
|
+
export type NativeRepository<TModel extends Model.Any, TId extends IdColumn<TModel>> = Effect.Success<
|
|
26
|
+
ReturnType<typeof SqlModel.makeRepository<TModel, TId>>
|
|
27
|
+
>;
|
|
28
|
+
type WriteError = SqlError | Schema.SchemaError | OptimisticConcurrencyError;
|
|
29
|
+
type KeyColumn<TModel extends Model.Any, TId extends PrimaryKey<TModel>> = TId extends readonly IdColumn<TModel>[]
|
|
30
|
+
? TId[number]
|
|
31
|
+
: Extract<TId, IdColumn<TModel>>;
|
|
32
|
+
export interface VersionedWrites<TModel extends Model.Any, TId extends PrimaryKey<TModel>> {
|
|
33
|
+
readonly delete: (
|
|
34
|
+
id: TId extends readonly IdColumn<TModel>[] ? Pick<TModel["Type"], TId[number]> : TModel["fields"][Extract<TId, IdColumn<TModel>>]["Type"],
|
|
35
|
+
expectedVersion: number,
|
|
36
|
+
) => Effect.Effect<void, WriteError, TModel["fields"][KeyColumn<TModel, TId>]["EncodingServices"]>;
|
|
37
|
+
readonly insert: Repository<TModel, TId>["insert"];
|
|
38
|
+
readonly insertVoid: Repository<TModel, TId>["insertVoid"];
|
|
39
|
+
readonly update: (
|
|
40
|
+
input: TModel["update"]["Type"],
|
|
41
|
+
) => Effect.Effect<TModel["Type"], WriteError, TModel["update"]["EncodingServices"] | TModel["DecodingServices"]>;
|
|
42
|
+
readonly updateVoid: (input: TModel["update"]["Type"]) => Effect.Effect<void, WriteError, TModel["update"]["EncodingServices"]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function makeVersionedWrites(
|
|
46
|
+
model: Model.Any,
|
|
47
|
+
sql: SqlClient.SqlClient,
|
|
48
|
+
options: VersionedOptions & { readonly changes: RepositoryChanges; readonly spanPrefix: string },
|
|
49
|
+
) {
|
|
50
|
+
const { spanPrefix, changes } = options;
|
|
51
|
+
const statements = versionedStatements(model, sql, options);
|
|
52
|
+
const decodeRow = Schema.decodeUnknownEffect(model);
|
|
53
|
+
function insert(input: unknown) {
|
|
54
|
+
return Effect.gen(function* () {
|
|
55
|
+
const statement = yield* statements.insert(input);
|
|
56
|
+
const rows = yield* sql`${statement} returning *`;
|
|
57
|
+
yield* changes.record(rows);
|
|
58
|
+
return yield* decodeRow(rows[0]);
|
|
59
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insert`));
|
|
60
|
+
}
|
|
61
|
+
function insertVoid(input: unknown) {
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
const statement = yield* statements.insert(input);
|
|
64
|
+
const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
|
|
65
|
+
yield* changes.record(rows);
|
|
66
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insertVoid`));
|
|
67
|
+
}
|
|
68
|
+
function insertOrIgnore(input: unknown) {
|
|
69
|
+
return Effect.gen(function* () {
|
|
70
|
+
const statement = yield* statements.insert(input, true);
|
|
71
|
+
const rows = yield* sql`${statement} returning *`;
|
|
72
|
+
yield* changes.record(rows);
|
|
73
|
+
return rows.length === 0 ? Option.none() : Option.some(yield* decodeRow(rows[0]));
|
|
74
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.insertOrIgnore`));
|
|
75
|
+
}
|
|
76
|
+
function update(input: Readonly<Record<string, unknown>>) {
|
|
77
|
+
return Effect.gen(function* () {
|
|
78
|
+
const { statement, classify } = yield* statements.update(input);
|
|
79
|
+
const rows = yield* sql`${statement} returning *`;
|
|
80
|
+
if (rows.length === 0) {
|
|
81
|
+
return yield* classify();
|
|
82
|
+
}
|
|
83
|
+
yield* changes.record(rows);
|
|
84
|
+
return yield* decodeRow(rows[0]);
|
|
85
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.update`));
|
|
86
|
+
}
|
|
87
|
+
function updateVoid(input: Readonly<Record<string, unknown>>) {
|
|
88
|
+
return Effect.gen(function* () {
|
|
89
|
+
const { statement, classify } = yield* statements.update(input);
|
|
90
|
+
const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
return yield* classify();
|
|
93
|
+
}
|
|
94
|
+
yield* changes.record(rows);
|
|
95
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.updateVoid`));
|
|
96
|
+
}
|
|
97
|
+
function deleteRow(id: unknown, expected: number) {
|
|
98
|
+
return Effect.gen(function* () {
|
|
99
|
+
const { statement, classify } = yield* statements.remove(id, expected);
|
|
100
|
+
const rows = yield* sql`${statement} returning ${statements.returningKeys}`;
|
|
101
|
+
if (rows.length === 0) {
|
|
102
|
+
return yield* classify();
|
|
103
|
+
}
|
|
104
|
+
yield* changes.record(rows);
|
|
105
|
+
}).pipe(changes.write, Effect.withSpan(`${spanPrefix}.delete`));
|
|
106
|
+
}
|
|
107
|
+
return { delete: deleteRow, insert, insertVoid, update, updateVoid, ...(options.conflictColumns === undefined ? {} : { insertOrIgnore }) };
|
|
108
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Model } from "effect/schema";
|
|
3
|
+
import type { SqlClient } from "effect/sql";
|
|
4
|
+
import { makeConflictClause } from "#makeConflictClause.ts";
|
|
5
|
+
import type { OptimisticConcurrencyError } from "#repository.ts";
|
|
6
|
+
|
|
7
|
+
const Version = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0));
|
|
8
|
+
export interface VersionedOptions {
|
|
9
|
+
readonly conflict: (id: unknown, expectedVersion: number, reason: "missing" | "stale") => OptimisticConcurrencyError;
|
|
10
|
+
readonly conflictColumns?: readonly string[];
|
|
11
|
+
readonly conflictWhere?: Readonly<Record<string, unknown>>;
|
|
12
|
+
readonly idColumns: readonly string[];
|
|
13
|
+
readonly tableName: string;
|
|
14
|
+
readonly versionColumn: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function versionedStatements(model: Model.Any, sql: SqlClient.SqlClient, options: VersionedOptions) {
|
|
18
|
+
const { tableName, idColumns, versionColumn, conflict } = options;
|
|
19
|
+
const fields = Object.fromEntries(
|
|
20
|
+
idColumns.map((column) => {
|
|
21
|
+
const field = model.fields[column];
|
|
22
|
+
if (field === undefined) {
|
|
23
|
+
throw new Error(`Unknown ID column: ${column}`);
|
|
24
|
+
}
|
|
25
|
+
return [column, field];
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
const encodeKey = Schema.encodeEffect(Schema.Struct(fields));
|
|
29
|
+
const versionSchema = model.fields[versionColumn];
|
|
30
|
+
if (versionSchema === undefined) {
|
|
31
|
+
throw new Error(`Unknown version column: ${versionColumn}`);
|
|
32
|
+
}
|
|
33
|
+
const encodeVersion = Schema.encodeEffect(versionSchema);
|
|
34
|
+
const encodeInsert = Schema.encodeEffect(model.insert);
|
|
35
|
+
const encodeUpdate = Schema.encodeEffect(model.update);
|
|
36
|
+
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
37
|
+
const validateVersion = Schema.decodeUnknownEffect(Version);
|
|
38
|
+
const clause =
|
|
39
|
+
options.conflictColumns === undefined ? undefined : makeConflictClause(model, sql, { ...options, conflictColumns: options.conflictColumns });
|
|
40
|
+
const returningKeys = sql.csv(idColumns.map((column) => sql`${sql(column)}`));
|
|
41
|
+
function key(input: unknown) {
|
|
42
|
+
return idColumns.length === 1 ? { [idColumns[0] ?? ""]: input } : input;
|
|
43
|
+
}
|
|
44
|
+
function inputKey(input: Readonly<Record<string, unknown>>) {
|
|
45
|
+
return idColumns.length === 1 ? input[idColumns[0] ?? ""] : Object.fromEntries(idColumns.map((column) => [column, input[column]]));
|
|
46
|
+
}
|
|
47
|
+
function predicate(encoded: Readonly<Record<string, unknown>>) {
|
|
48
|
+
return sql.and(idColumns.map((column) => sql`${sql(column)} = ${encoded[column]}`));
|
|
49
|
+
}
|
|
50
|
+
function classify(id: unknown, encoded: Readonly<Record<string, unknown>>, expected: number) {
|
|
51
|
+
return Effect.gen(function* () {
|
|
52
|
+
const rows = yield* sql`select 1 from ${sql(tableName)} where ${predicate(encoded)}`;
|
|
53
|
+
return yield* Effect.fail(conflict(id, expected, rows.length === 0 ? "missing" : "stale"));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function insert(input: unknown, ignore = false) {
|
|
57
|
+
return Effect.gen(function* () {
|
|
58
|
+
const encoded = { ...(yield* record(yield* encodeInsert(input))), [versionColumn]: 0 };
|
|
59
|
+
const suffix = ignore && clause !== undefined ? yield* clause(encoded) : sql``;
|
|
60
|
+
return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function update(input: Readonly<Record<string, unknown>>) {
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const encoded = yield* record(yield* encodeUpdate(input));
|
|
66
|
+
const expectedVersion = yield* validateVersion(encoded[versionColumn]);
|
|
67
|
+
const assignments = Object.entries(encoded).filter(([column]) => !idColumns.includes(column) && column !== versionColumn);
|
|
68
|
+
const set = sql.csv([
|
|
69
|
+
...assignments.map(([column, value]) => sql`${sql(column)} = ${value}`),
|
|
70
|
+
sql`${sql(versionColumn)} = ${sql(versionColumn)} + 1`,
|
|
71
|
+
]);
|
|
72
|
+
return {
|
|
73
|
+
classify: () => classify(inputKey(input), encoded, expectedVersion),
|
|
74
|
+
statement: sql`update ${sql(tableName)} set ${set} where ${predicate(encoded)} and ${sql(versionColumn)} = ${expectedVersion}`,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function remove(id: unknown, expected: number) {
|
|
79
|
+
return Effect.gen(function* () {
|
|
80
|
+
const expectedVersion = yield* validateVersion(yield* encodeVersion(expected));
|
|
81
|
+
const yieldedKey = yield* record(key(id));
|
|
82
|
+
const encoded = yield* record(yield* encodeKey(yieldedKey));
|
|
83
|
+
return {
|
|
84
|
+
classify: () => classify(inputKey(yieldedKey), encoded, expectedVersion),
|
|
85
|
+
statement: sql`delete from ${sql(tableName)} where ${predicate(encoded)} and ${sql(versionColumn)} = ${expectedVersion}`,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { insert, remove, returningKeys, update };
|
|
90
|
+
}
|