@shivaedev/sql 0.33.0 → 0.35.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 +34 -0
- package/README.md +103 -10
- package/dist/MigrationStartedAt.d.ts.map +1 -0
- package/dist/{internal/MigrationStartedAt.js → MigrationStartedAt.js} +1 -1
- package/dist/MigrationStartedAt.js.map +1 -0
- package/dist/aggregateColumn.d.ts +3 -3
- package/dist/aggregateColumn.d.ts.map +1 -1
- package/dist/aggregateColumn.js +4 -2
- package/dist/aggregateColumn.js.map +1 -1
- package/dist/batchWrites.d.ts +4 -4
- package/dist/batchWrites.d.ts.map +1 -1
- package/dist/batchWrites.js +8 -5
- package/dist/batchWrites.js.map +1 -1
- package/dist/batchedDelete.d.ts +9 -2
- package/dist/batchedDelete.d.ts.map +1 -1
- package/dist/batchedDelete.js +35 -36
- package/dist/batchedDelete.js.map +1 -1
- package/dist/changes.d.ts +8 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +7 -0
- package/dist/changes.js.map +1 -0
- package/dist/compositeRepository.d.ts +9 -5
- package/dist/compositeRepository.d.ts.map +1 -1
- package/dist/compositeRepository.js +12 -9
- package/dist/compositeRepository.js.map +1 -1
- package/dist/conditionalUpsert.d.ts +4 -4
- package/dist/conditionalUpsert.d.ts.map +1 -1
- package/dist/conditionalUpsert.js +7 -4
- package/dist/conditionalUpsert.js.map +1 -1
- package/dist/conflictWrites.d.ts +4 -4
- package/dist/conflictWrites.d.ts.map +1 -1
- package/dist/conflictWrites.js +11 -8
- package/dist/conflictWrites.js.map +1 -1
- package/dist/deleteGroupsBatched.d.ts +13 -0
- package/dist/deleteGroupsBatched.d.ts.map +1 -0
- package/dist/deleteGroupsBatched.js +23 -0
- package/dist/deleteGroupsBatched.js.map +1 -0
- package/dist/insertFromSelect.d.ts +3 -3
- package/dist/insertFromSelect.d.ts.map +1 -1
- package/dist/insertFromSelect.js +5 -3
- package/dist/insertFromSelect.js.map +1 -1
- package/dist/invalidationKeys.d.ts +8 -0
- package/dist/invalidationKeys.d.ts.map +1 -0
- package/dist/invalidationKeys.js +15 -0
- package/dist/invalidationKeys.js.map +1 -0
- package/dist/keysetPage.d.ts +26 -0
- package/dist/keysetPage.d.ts.map +1 -0
- package/dist/keysetPage.js +23 -0
- package/dist/keysetPage.js.map +1 -0
- package/dist/latestPerGroup.d.ts +2 -2
- package/dist/latestPerGroup.d.ts.map +1 -1
- package/dist/latestPerGroup.js +5 -4
- package/dist/latestPerGroup.js.map +1 -1
- package/dist/makeAggregate.d.ts +4 -3
- package/dist/makeAggregate.d.ts.map +1 -1
- package/dist/makeAggregate.js +35 -31
- package/dist/makeAggregate.js.map +1 -1
- package/dist/makeConflictClause.d.ts +3 -3
- package/dist/makeConflictClause.d.ts.map +1 -1
- package/dist/makeConflictClause.js +30 -6
- package/dist/makeConflictClause.js.map +1 -1
- package/dist/makeQueryClauses.d.ts +3 -3
- package/dist/makeQueryClauses.d.ts.map +1 -1
- package/dist/makeQueryClauses.js +5 -3
- package/dist/makeQueryClauses.js.map +1 -1
- package/dist/migrationLock.d.ts.map +1 -0
- package/dist/migrationLock.js.map +1 -0
- package/dist/migrations.d.ts +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +18 -9
- package/dist/migrations.js.map +1 -1
- package/dist/pinnedGroups.d.ts +3 -4
- package/dist/pinnedGroups.d.ts.map +1 -1
- package/dist/pinnedGroups.js +8 -10
- package/dist/pinnedGroups.js.map +1 -1
- package/dist/postgresChanges.d.ts.map +1 -1
- package/dist/postgresChanges.js +8 -4
- package/dist/postgresChanges.js.map +1 -1
- package/dist/postgresLayer.d.ts +2 -1
- package/dist/postgresLayer.d.ts.map +1 -1
- package/dist/postgresLayer.js +1 -1
- package/dist/postgresLayer.js.map +1 -1
- package/dist/postgresRelease.js +1 -1
- package/dist/postgresRelease.js.map +1 -1
- package/dist/repository.d.ts +1 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +7 -9
- package/dist/repository.js.map +1 -1
- package/dist/repositoryChanges.d.ts +2 -2
- package/dist/repositoryChanges.d.ts.map +1 -1
- package/dist/repositoryChanges.js +6 -4
- package/dist/repositoryChanges.js.map +1 -1
- package/dist/repositoryFilter.d.ts +4 -4
- package/dist/repositoryFilter.d.ts.map +1 -1
- package/dist/repositoryFilter.js +5 -3
- package/dist/repositoryFilter.js.map +1 -1
- package/dist/repositoryQueries.d.ts +3 -3
- package/dist/repositoryQueries.d.ts.map +1 -1
- package/dist/repositoryQueries.js +45 -41
- package/dist/repositoryQueries.js.map +1 -1
- package/dist/repositoryWrites.d.ts +9 -5
- package/dist/repositoryWrites.d.ts.map +1 -1
- package/dist/repositoryWrites.js +8 -7
- package/dist/repositoryWrites.js.map +1 -1
- package/dist/transact.d.ts +13 -5
- package/dist/transact.d.ts.map +1 -1
- package/dist/transact.js +45 -19
- package/dist/transact.js.map +1 -1
- package/dist/tupleIn.d.ts +1 -2
- package/dist/tupleIn.d.ts.map +1 -1
- package/dist/tupleIn.js +16 -17
- package/dist/tupleIn.js.map +1 -1
- package/dist/upsertResult.d.ts +2 -2
- package/dist/upsertResult.d.ts.map +1 -1
- package/dist/upsertResult.js +4 -2
- package/dist/upsertResult.js.map +1 -1
- package/dist/versionedRepository.d.ts +5 -5
- package/dist/versionedRepository.d.ts.map +1 -1
- package/dist/versionedRepository.js +5 -3
- package/dist/versionedRepository.js.map +1 -1
- package/dist/versionedStatements.d.ts +4 -4
- package/dist/versionedStatements.d.ts.map +1 -1
- package/dist/versionedStatements.js +12 -9
- package/dist/versionedStatements.js.map +1 -1
- package/package.json +8 -6
- package/src/{internal/MigrationStartedAt.ts → MigrationStartedAt.ts} +1 -1
- package/src/aggregateColumn.ts +4 -3
- package/src/batchWrites.ts +10 -9
- package/src/batchedDelete.ts +54 -41
- package/src/changes.ts +12 -0
- package/src/compositeRepository.ts +15 -15
- package/src/conditionalUpsert.ts +7 -6
- package/src/conflictWrites.ts +14 -10
- package/src/deleteGroupsBatched.ts +41 -0
- package/src/insertFromSelect.ts +6 -6
- package/src/invalidationKeys.ts +24 -0
- package/src/keysetPage.ts +54 -0
- package/src/latestPerGroup.ts +5 -4
- package/src/makeAggregate.ts +48 -47
- package/src/makeConflictClause.ts +37 -8
- package/src/makeQueryClauses.ts +6 -6
- package/src/migrations.ts +21 -10
- package/src/pinnedGroups.ts +10 -14
- package/src/postgresChanges.ts +13 -4
- package/src/postgresLayer.ts +1 -1
- package/src/postgresRelease.ts +1 -1
- package/src/repository.ts +8 -9
- package/src/repositoryChanges.ts +6 -9
- package/src/repositoryFilter.ts +5 -4
- package/src/repositoryQueries.ts +57 -55
- package/src/repositoryWrites.ts +9 -9
- package/src/transact.ts +61 -24
- package/src/tupleIn.ts +16 -18
- package/src/upsertResult.ts +4 -3
- package/src/versionedRepository.ts +6 -6
- package/src/versionedStatements.ts +15 -15
- package/dist/internal/CommandRecovery.d.ts +0 -3
- package/dist/internal/CommandRecovery.d.ts.map +0 -1
- package/dist/internal/CommandRecovery.js +0 -5
- package/dist/internal/CommandRecovery.js.map +0 -1
- package/dist/internal/MigrationStartedAt.d.ts.map +0 -1
- package/dist/internal/MigrationStartedAt.js.map +0 -1
- package/dist/internal/migrationLock.d.ts.map +0 -1
- package/dist/internal/migrationLock.js.map +0 -1
- package/dist/testing.d.ts +0 -27
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -75
- package/dist/testing.js.map +0 -1
- package/src/internal/CommandRecovery.ts +0 -5
- package/src/testing.ts +0 -103
- /package/dist/{internal/MigrationStartedAt.d.ts → MigrationStartedAt.d.ts} +0 -0
- /package/dist/{internal/migrationLock.d.ts → migrationLock.d.ts} +0 -0
- /package/dist/{internal/migrationLock.js → migrationLock.js} +0 -0
- /package/src/{internal/migrationLock.ts → migrationLock.ts} +0 -0
package/src/batchWrites.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
import type { Model } from "effect/schema";
|
|
3
|
-
import
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
4
|
import type { SqlError } from "effect/sql/SqlError";
|
|
5
5
|
import type { Fragment } from "effect/sql/Statement";
|
|
6
|
-
import type { RepositoryChanges } from "
|
|
7
|
-
import { makeUpsertDecoder, upsertReturning } from "
|
|
6
|
+
import type { RepositoryChanges } from "#repositoryChanges.ts";
|
|
7
|
+
import { makeUpsertDecoder, upsertReturning } from "#upsertResult.ts";
|
|
8
8
|
|
|
9
9
|
export type InsertMany<TModel extends Model.Any, TServices = never> = (
|
|
10
10
|
inputs: readonly TModel["insert"]["Type"][],
|
|
@@ -14,19 +14,20 @@ export interface BatchWrites<TModel extends Model.Any> {
|
|
|
14
14
|
readonly insertMany: InsertMany<TModel>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function
|
|
17
|
+
export const makeInsertMany = Effect.fnUntraced(function* <TServices = never>(
|
|
18
18
|
model: Model.Any,
|
|
19
|
-
sql: SqlClient.SqlClient,
|
|
20
19
|
options: { readonly tableName: string; readonly spanPrefix: string; readonly changes: RepositoryChanges },
|
|
21
20
|
operation: string,
|
|
22
|
-
prepare
|
|
21
|
+
prepare?: (row: Readonly<Record<string, unknown>>) => Effect.Effect<Fragment, SqlError | Schema.SchemaError, TServices>,
|
|
23
22
|
upsert = false,
|
|
24
23
|
) {
|
|
24
|
+
const sql = yield* SqlClient.SqlClient;
|
|
25
|
+
const prepareRow = prepare ?? (() => Effect.succeed(sql``));
|
|
25
26
|
const encode = Schema.encodeEffect(model.insert);
|
|
26
27
|
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
27
28
|
const decode = Schema.decodeUnknownEffect(Schema.Array(model));
|
|
28
29
|
const decodeUpsert = makeUpsertDecoder(model);
|
|
29
|
-
const returning = options.changes.returning(upsert ? upsertReturning(
|
|
30
|
+
const returning = options.changes.returning(upsert ? yield* upsertReturning() : sql`*`);
|
|
30
31
|
return (inputs: readonly unknown[]) =>
|
|
31
32
|
Effect.gen(function* () {
|
|
32
33
|
if (inputs.length === 0) {
|
|
@@ -56,7 +57,7 @@ export function makeInsertMany<TServices = never>(
|
|
|
56
57
|
for (const row of rows) {
|
|
57
58
|
const value = sql`(${sql.csv(columns.map((column) => (Object.hasOwn(row, column) ? sql`${row[column]}` : sql`default`)))})`;
|
|
58
59
|
const size = value.compile()[1].length;
|
|
59
|
-
const prepared = yield*
|
|
60
|
+
const prepared = yield* prepareRow(row);
|
|
60
61
|
const suffixSize = sql`${prepared}`.compile()[1].length;
|
|
61
62
|
// Bound native fragment size as well as PostgreSQL's 65,535 bind parameters.
|
|
62
63
|
if (chunk.length > 0 && (chunk.length === chunkSize || parameters + size + suffixSize > 10_000)) {
|
|
@@ -70,4 +71,4 @@ export function makeInsertMany<TServices = never>(
|
|
|
70
71
|
yield* options.changes.record(changedRows);
|
|
71
72
|
return results;
|
|
72
73
|
}).pipe(options.changes.write, Effect.withSpan(`${options.spanPrefix}.${operation}`));
|
|
73
|
-
}
|
|
74
|
+
});
|
package/src/batchedDelete.ts
CHANGED
|
@@ -7,42 +7,69 @@ import type { RepositoryChanges } from "#repositoryChanges.ts";
|
|
|
7
7
|
import { type FindManyFilter, makeFilter } from "#repositoryFilter.ts";
|
|
8
8
|
import { tryPurgeLock } from "#tryPurgeLock.ts";
|
|
9
9
|
|
|
10
|
-
export interface
|
|
10
|
+
export interface PurgeLimits {
|
|
11
11
|
readonly batchSize: number;
|
|
12
12
|
readonly maxRows: number;
|
|
13
13
|
readonly pause: Duration.Input;
|
|
14
|
-
readonly where: FindManyFilter<TModel>;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export class BatchedDeleteUnavailable extends Data.TaggedError("BatchedDeleteUnavailable")<{
|
|
18
17
|
readonly reason: "transaction" | "dialect";
|
|
19
18
|
}> {}
|
|
20
19
|
|
|
20
|
+
export const PositiveRowCount = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
21
|
+
const Nonnegative = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
22
|
+
const Limits = Schema.Struct({ batchSize: PositiveRowCount, maxRows: Nonnegative });
|
|
23
|
+
|
|
24
|
+
export const runPurgeBatches = Effect.fnUntraced(function* <E, R>(
|
|
25
|
+
tableName: string,
|
|
26
|
+
options: PurgeLimits,
|
|
27
|
+
batch: (limit: number) => Effect.Effect<{ readonly count: number; readonly done: boolean }, E, R>,
|
|
28
|
+
prepare?: Effect.Effect<unknown, E, R>,
|
|
29
|
+
) {
|
|
30
|
+
const sql = yield* SqlClient.SqlClient;
|
|
31
|
+
if (sql.driver !== "pg") {
|
|
32
|
+
return yield* new BatchedDeleteUnavailable({ reason: "dialect" });
|
|
33
|
+
}
|
|
34
|
+
if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) {
|
|
35
|
+
return yield* new BatchedDeleteUnavailable({ reason: "transaction" });
|
|
36
|
+
}
|
|
37
|
+
const { batchSize, maxRows } = yield* Schema.decodeUnknownEffect(Limits)(options);
|
|
38
|
+
if (prepare !== undefined) {
|
|
39
|
+
yield* prepare;
|
|
40
|
+
}
|
|
41
|
+
if (maxRows === 0 || !(yield* tryPurgeLock(tableName))) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
let removed = 0;
|
|
45
|
+
while (removed < maxRows) {
|
|
46
|
+
const { count, done } = yield* batch(Math.min(batchSize, maxRows - removed));
|
|
47
|
+
removed += count;
|
|
48
|
+
if (done || count === 0 || removed === maxRows) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
yield* Effect.sleep(options.pause);
|
|
52
|
+
}
|
|
53
|
+
return removed;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export interface BatchedDeleteOptions<TModel extends Model.Any> extends PurgeLimits {
|
|
57
|
+
readonly where: FindManyFilter<TModel>;
|
|
58
|
+
}
|
|
59
|
+
|
|
21
60
|
export interface BatchedDelete<TModel extends Model.Any> {
|
|
22
61
|
readonly deleteBatched: (
|
|
23
62
|
options: BatchedDeleteOptions<TModel>,
|
|
24
63
|
) => Effect.Effect<number, SqlError | Schema.SchemaError | BatchedDeleteUnavailable, TModel["fields"][keyof TModel["fields"]]["EncodingServices"]>;
|
|
25
64
|
}
|
|
26
65
|
|
|
27
|
-
const Positive = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
28
|
-
const Nonnegative = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
29
|
-
const Limits = Schema.Struct({ batchSize: Positive, maxRows: Nonnegative });
|
|
30
|
-
|
|
31
66
|
export const makeBatchedDelete = Effect.fnUntraced(function* (
|
|
32
67
|
model: Model.Any,
|
|
33
68
|
{ tableName, spanPrefix }: { readonly tableName: string; readonly spanPrefix: string },
|
|
34
69
|
changes: RepositoryChanges,
|
|
35
70
|
) {
|
|
36
71
|
const sql = yield* SqlClient.SqlClient;
|
|
37
|
-
const { where } = makeFilter(model
|
|
38
|
-
const available = Effect.gen(function* () {
|
|
39
|
-
if (sql.driver !== "pg") {
|
|
40
|
-
return yield* new BatchedDeleteUnavailable({ reason: "dialect" });
|
|
41
|
-
}
|
|
42
|
-
if (Option.isSome(yield* Effect.serviceOption(sql.transactionService))) {
|
|
43
|
-
return yield* new BatchedDeleteUnavailable({ reason: "transaction" });
|
|
44
|
-
}
|
|
45
|
-
});
|
|
72
|
+
const { where } = yield* makeFilter(model);
|
|
46
73
|
function deleteBatch(predicate: Fragment, limit: number) {
|
|
47
74
|
const count = SqlSchema.findOne({
|
|
48
75
|
execute: (batchLimit) => sql`with removed as (
|
|
@@ -62,32 +89,18 @@ export const makeBatchedDelete = Effect.fnUntraced(function* (
|
|
|
62
89
|
);
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
const runBatches = Effect.fnUntraced(function* (options: BatchedDeleteOptions<Model.Any>, predicate: Fragment) {
|
|
66
|
-
const { batchSize, maxRows } = options;
|
|
67
|
-
let removed = 0;
|
|
68
|
-
while (removed < maxRows) {
|
|
69
|
-
const limit = Math.min(batchSize, maxRows - removed);
|
|
70
|
-
const count = yield* deleteBatch(predicate, limit);
|
|
71
|
-
removed += count;
|
|
72
|
-
if (count < limit || removed === maxRows) {
|
|
73
|
-
break;
|
|
74
|
-
}
|
|
75
|
-
yield* Effect.sleep(options.pause);
|
|
76
|
-
}
|
|
77
|
-
return removed;
|
|
78
|
-
});
|
|
79
|
-
|
|
80
92
|
return (options: BatchedDeleteOptions<Model.Any>) =>
|
|
81
93
|
Effect.gen(function* () {
|
|
82
|
-
yield*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const predicate = yield* Effect.cached(where(options.where, true, true));
|
|
95
|
+
return yield* runPurgeBatches(
|
|
96
|
+
tableName,
|
|
97
|
+
options,
|
|
98
|
+
(limit) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const count = yield* deleteBatch(yield* predicate, limit);
|
|
101
|
+
return { count, done: count < limit };
|
|
102
|
+
}),
|
|
103
|
+
predicate,
|
|
104
|
+
);
|
|
105
|
+
}).pipe(Effect.provideService(SqlClient.SqlClient, sql), Effect.scoped, Effect.withSpan(`${spanPrefix}.deleteBatched`));
|
|
93
106
|
});
|
package/src/changes.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BucketKey, type ItemKey, invalidationKeys } from "@shivaedev/changes/keys.ts";
|
|
2
|
+
import { type AnnouncementOptions, invalidateOnCommit } from "#transact.ts";
|
|
3
|
+
|
|
4
|
+
export interface ChangeDeclaration {
|
|
5
|
+
readonly covers: Readonly<Record<string, readonly (ItemKey | BucketKey)[]>>;
|
|
6
|
+
readonly record: (options?: AnnouncementOptions) => ReturnType<typeof invalidateOnCommit>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function changes(covers: ChangeDeclaration["covers"]): ChangeDeclaration {
|
|
10
|
+
const keys = invalidationKeys(Object.values(covers).flat());
|
|
11
|
+
return { covers, record: (options) => invalidateOnCommit(keys, options) };
|
|
12
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Cause } from "effect";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
import type { Model } from "effect/schema";
|
|
4
|
-
import {
|
|
4
|
+
import { SqlClient, SqlSchema } from "effect/sql";
|
|
5
5
|
import type { SqlError } from "effect/sql/SqlError";
|
|
6
6
|
import type { BatchedDelete } from "#batchedDelete.ts";
|
|
7
7
|
import type { RepositoryChanges } from "#repositoryChanges.ts";
|
|
@@ -41,9 +41,8 @@ export type Repository<TModel extends Model.Any, TId extends PrimaryKey<TModel>>
|
|
|
41
41
|
}
|
|
42
42
|
: never);
|
|
43
43
|
|
|
44
|
-
export
|
|
44
|
+
export const makeCompositeCrud = Effect.fnUntraced(function* (
|
|
45
45
|
model: Model.Any,
|
|
46
|
-
sql: SqlClient.SqlClient,
|
|
47
46
|
options: {
|
|
48
47
|
readonly columns: readonly string[];
|
|
49
48
|
readonly tableName: string;
|
|
@@ -51,19 +50,20 @@ export function makeCompositeCrud(
|
|
|
51
50
|
},
|
|
52
51
|
changes: RepositoryChanges,
|
|
53
52
|
) {
|
|
53
|
+
const sql = yield* SqlClient.SqlClient;
|
|
54
54
|
const { columns, tableName, spanPrefix } = options;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
55
|
+
const entries: [string, Schema.Constraint][] = [];
|
|
56
|
+
for (const column of columns) {
|
|
57
|
+
const field = model.fields[column];
|
|
58
|
+
if (field === undefined) {
|
|
59
|
+
return yield* Effect.die(new Error(`Unknown ID column: ${column}`));
|
|
60
|
+
}
|
|
61
|
+
entries.push([column, field]);
|
|
62
|
+
}
|
|
63
|
+
const fields = Object.fromEntries(entries);
|
|
64
64
|
const Key = columns.length === 1 ? model.fields[columns[0] ?? ""] : Schema.Struct(fields);
|
|
65
65
|
if (Key === undefined) {
|
|
66
|
-
|
|
66
|
+
return yield* Effect.die(new Error("Unknown repository ID column"));
|
|
67
67
|
}
|
|
68
68
|
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
69
69
|
function predicate(input: Readonly<Record<string, unknown>>) {
|
|
@@ -80,7 +80,7 @@ export function makeCompositeCrud(
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
return {
|
|
83
|
-
...repositoryWrites(model,
|
|
83
|
+
...(yield* repositoryWrites(model, { ...options, idColumns: columns }, changes)),
|
|
84
84
|
findById: (id: unknown) => find(id).pipe(Effect.withSpan(`${spanPrefix}.findById`)),
|
|
85
85
|
};
|
|
86
|
-
}
|
|
86
|
+
});
|
package/src/conditionalUpsert.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
import type { Model } from "effect/schema";
|
|
3
|
-
import
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
4
|
import type { SqlError } from "effect/sql/SqlError";
|
|
5
5
|
import { makeConflictClause } from "#makeConflictClause.ts";
|
|
6
6
|
import type { RepositoryChanges } from "#repositoryChanges.ts";
|
|
@@ -53,9 +53,8 @@ export interface ConditionalUpsertMany<TModel extends Model.Any, TOptions, TServ
|
|
|
53
53
|
): Effect.Effect<UpsertRow<TModel["Type"]>[], SqlError | Schema.SchemaError, TServices>;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export
|
|
56
|
+
export const makeConditionalUpsert = Effect.fnUntraced(function* (
|
|
57
57
|
model: Model.Any,
|
|
58
|
-
sql: SqlClient.SqlClient,
|
|
59
58
|
options: {
|
|
60
59
|
readonly tableName: string;
|
|
61
60
|
readonly spanPrefix: string;
|
|
@@ -65,10 +64,12 @@ export function makeConditionalUpsert(
|
|
|
65
64
|
readonly idColumns: readonly string[];
|
|
66
65
|
},
|
|
67
66
|
) {
|
|
67
|
+
const sql = yield* SqlClient.SqlClient;
|
|
68
|
+
const returning = yield* upsertReturning();
|
|
68
69
|
const encode = Schema.encodeEffect(model.insert);
|
|
69
70
|
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
70
71
|
const decode = makeUpsertDecoder(model);
|
|
71
|
-
const clause = makeConflictClause(model,
|
|
72
|
+
const clause = yield* makeConflictClause(model, options);
|
|
72
73
|
return (
|
|
73
74
|
inputs: readonly unknown[],
|
|
74
75
|
settings: { readonly updateColumns: readonly UpsertAssignment[]; readonly updateWhere: Readonly<Record<string, unknown>> },
|
|
@@ -78,7 +79,7 @@ export function makeConditionalUpsert(
|
|
|
78
79
|
Effect.gen(function* () {
|
|
79
80
|
const row = yield* record(yield* encode(input));
|
|
80
81
|
const suffix = yield* clause(row, settings.updateColumns, settings.updateWhere);
|
|
81
|
-
return sql`insert into ${sql(options.tableName)} ${sql.insert(row)} ${suffix} returning ${options.changes.returning(
|
|
82
|
+
return sql`insert into ${sql(options.tableName)} ${sql.insert(row)} ${suffix} returning ${options.changes.returning(returning)}`;
|
|
82
83
|
}),
|
|
83
84
|
);
|
|
84
85
|
const changedRows: Readonly<Record<string, unknown>>[] = [];
|
|
@@ -99,4 +100,4 @@ export function makeConditionalUpsert(
|
|
|
99
100
|
yield* options.changes.record(changedRows);
|
|
100
101
|
return results;
|
|
101
102
|
}).pipe(options.changes.write, Effect.withSpan(`${options.spanPrefix}.conditionalUpsert`));
|
|
102
|
-
}
|
|
103
|
+
});
|
package/src/conflictWrites.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Option, Schema } from "effect";
|
|
2
2
|
import type { Model } from "effect/schema";
|
|
3
|
-
import
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
4
|
import type { SqlError } from "effect/sql/SqlError";
|
|
5
5
|
import { type InsertMany, makeInsertMany } from "#batchWrites.ts";
|
|
6
6
|
import { type ConditionalUpsert, type ConditionalUpsertMany, makeConditionalUpsert } from "#conditionalUpsert.ts";
|
|
@@ -40,9 +40,8 @@ export interface ConflictWrites<TModel extends Model.Any, TExcluded extends Prop
|
|
|
40
40
|
>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export
|
|
43
|
+
export const makeConflictWrites = Effect.fnUntraced(function* (
|
|
44
44
|
model: Model.Any,
|
|
45
|
-
sql: SqlClient.SqlClient,
|
|
46
45
|
options: {
|
|
47
46
|
readonly changes: RepositoryChanges;
|
|
48
47
|
readonly tableName: string;
|
|
@@ -52,25 +51,27 @@ export function makeConflictWrites(
|
|
|
52
51
|
readonly idColumns: readonly string[];
|
|
53
52
|
},
|
|
54
53
|
) {
|
|
54
|
+
const sql = yield* SqlClient.SqlClient;
|
|
55
55
|
const { tableName, spanPrefix } = options;
|
|
56
|
+
const returning = yield* upsertReturning();
|
|
56
57
|
const encode = Schema.encodeEffect(model.insert);
|
|
57
58
|
const decode = Schema.decodeUnknownEffect(model);
|
|
58
59
|
const decodeUpsert = makeUpsertDecoder(model);
|
|
59
60
|
const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
60
|
-
const conditional = makeConditionalUpsert(model,
|
|
61
|
-
const clause = makeConflictClause(model,
|
|
61
|
+
const conditional = yield* makeConditionalUpsert(model, options);
|
|
62
|
+
const clause = yield* makeConflictClause(model, options);
|
|
62
63
|
function statement(input: unknown, updateColumns?: readonly UpsertAssignment[]) {
|
|
63
64
|
return Effect.gen(function* () {
|
|
64
65
|
const encoded = yield* record(yield* encode(input));
|
|
65
66
|
const suffix = yield* clause(encoded, updateColumns);
|
|
66
|
-
return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix} returning ${options.changes.returning(updateColumns === undefined ? sql`*` :
|
|
67
|
+
return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix} returning ${options.changes.returning(updateColumns === undefined ? sql`*` : returning)}`;
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
return {
|
|
70
|
-
insertFromSelectOrIgnore: makeInsertFromSelect(model,
|
|
71
|
+
insertFromSelectOrIgnore: yield* makeInsertFromSelect(model, options, "insertFromSelectOrIgnore", (columns) =>
|
|
71
72
|
clause(Object.fromEntries(columns.map((key) => [key, undefined]))),
|
|
72
73
|
),
|
|
73
|
-
insertManyOrIgnore: makeInsertMany(model,
|
|
74
|
+
insertManyOrIgnore: yield* makeInsertMany(model, options, "insertManyOrIgnore", (row) => clause(row)),
|
|
74
75
|
insertOrIgnore: (input: unknown) =>
|
|
75
76
|
Effect.gen(function* () {
|
|
76
77
|
const rows = yield* yield* statement(input);
|
|
@@ -93,7 +94,10 @@ export function makeConflictWrites(
|
|
|
93
94
|
settings: { readonly updateColumns: readonly UpsertAssignment[]; readonly updateWhere?: Readonly<Record<string, unknown>> },
|
|
94
95
|
) =>
|
|
95
96
|
settings.updateWhere === undefined
|
|
96
|
-
?
|
|
97
|
+
? Effect.flatMap(
|
|
98
|
+
makeInsertMany(model, options, "upsertMany", (row) => clause(row, settings.updateColumns), true),
|
|
99
|
+
(insertMany) => insertMany(inputs),
|
|
100
|
+
).pipe(Effect.provideService(SqlClient.SqlClient, sql))
|
|
97
101
|
: conditional(inputs, { ...settings, updateWhere: settings.updateWhere }),
|
|
98
102
|
};
|
|
99
|
-
}
|
|
103
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { PositiveRowCount, type PurgeLimits, runPurgeBatches } from "#batchedDelete.ts";
|
|
3
|
+
import { transact } from "#transact.ts";
|
|
4
|
+
|
|
5
|
+
export interface DeleteGroup<T> {
|
|
6
|
+
readonly rowCount: number;
|
|
7
|
+
readonly value: T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DeleteGroupsBatchedOptions<T, TSelectError, TSelectServices, TDeleteError, TDeleteServices> extends PurgeLimits {
|
|
11
|
+
readonly delete: (group: T) => Effect.Effect<unknown, TDeleteError, TDeleteServices>;
|
|
12
|
+
readonly select: (limit: number) => Effect.Effect<readonly DeleteGroup<T>[], TSelectError, TSelectServices>;
|
|
13
|
+
readonly tableName: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const deleteGroupsBatched = Effect.fn("Sql.deleteGroupsBatched")(function* <T, TSelectError, TSelectServices, TDeleteError, TDeleteServices>(
|
|
17
|
+
options: DeleteGroupsBatchedOptions<T, TSelectError, TSelectServices, TDeleteError, TDeleteServices>,
|
|
18
|
+
) {
|
|
19
|
+
return yield* runPurgeBatches(options.tableName, options, (limit) =>
|
|
20
|
+
transact(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const candidates = yield* options.select(limit);
|
|
23
|
+
const selected: T[] = [];
|
|
24
|
+
let count = 0;
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
const size = yield* Schema.decodeUnknownEffect(PositiveRowCount)(candidate.rowCount);
|
|
27
|
+
if (size > limit - count) {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
selected.push(candidate.value);
|
|
31
|
+
count += size;
|
|
32
|
+
}
|
|
33
|
+
for (const group of selected) {
|
|
34
|
+
yield* options.delete(group);
|
|
35
|
+
}
|
|
36
|
+
return { count, done: candidates.length === 0 };
|
|
37
|
+
}),
|
|
38
|
+
{ onSqlError: (error) => error },
|
|
39
|
+
),
|
|
40
|
+
).pipe(Effect.scoped);
|
|
41
|
+
});
|
package/src/insertFromSelect.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
import type { Model } from "effect/schema";
|
|
3
|
-
import
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
4
|
import type { SqlError } from "effect/sql/SqlError";
|
|
5
5
|
import type { Fragment } from "effect/sql/Statement";
|
|
6
6
|
import type { RepositoryChanges } from "#repositoryChanges.ts";
|
|
@@ -24,24 +24,24 @@ export interface SelectWrites<TModel extends Model.Any> {
|
|
|
24
24
|
readonly insertFromSelect: InsertFromSelect<TModel>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function
|
|
27
|
+
export const makeInsertFromSelect = Effect.fnUntraced(function* <TServices = never>(
|
|
28
28
|
model: Model.Any,
|
|
29
|
-
sql: SqlClient.SqlClient,
|
|
30
29
|
options: { readonly tableName: string; readonly spanPrefix: string; readonly changes: RepositoryChanges },
|
|
31
30
|
operation: string,
|
|
32
|
-
prepare
|
|
31
|
+
prepare?: (columns: readonly string[]) => Effect.Effect<Fragment, SqlError | Schema.SchemaError, TServices>,
|
|
33
32
|
) {
|
|
33
|
+
const sql = yield* SqlClient.SqlClient;
|
|
34
34
|
const decode = Schema.decodeUnknownEffect(Schema.Array(model));
|
|
35
35
|
return (source: RepositorySelect<Schema.Struct.Fields, unknown>, columns: Readonly<Record<string, string>>) =>
|
|
36
36
|
Effect.gen(function* () {
|
|
37
37
|
const names = Object.keys(columns);
|
|
38
38
|
const selected = sql.csv(Object.values(columns).map((column) => sql`${sql(column)}`));
|
|
39
39
|
const target = sql.csv(names.map((column) => sql`${sql(column)}`));
|
|
40
|
-
const suffix = yield* prepare(names);
|
|
40
|
+
const suffix = yield* prepare?.(names) ?? Effect.succeed(sql``);
|
|
41
41
|
const statement = yield* source.statement;
|
|
42
42
|
const rows =
|
|
43
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}`));
|
|
47
|
-
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { invalidationKeys, Key, recordKey } from "@shivaedev/changes/keys.ts";
|
|
3
|
+
import type { InvalidationKeys } from "#transact.ts";
|
|
4
|
+
|
|
5
|
+
export interface Invalidation {
|
|
6
|
+
readonly key: unknown;
|
|
7
|
+
readonly publication: "local" | "shared";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const isKey = Schema.is(Key);
|
|
11
|
+
|
|
12
|
+
function isList(keys: InvalidationKeys): keys is readonly unknown[] {
|
|
13
|
+
return Array.isArray(keys);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function flatten(keys: InvalidationKeys): readonly unknown[] {
|
|
17
|
+
return isList(keys)
|
|
18
|
+
? keys.flatMap<unknown>((key) => (isKey(key) ? invalidationKeys([key]) : [key]))
|
|
19
|
+
: Object.entries(keys).flatMap(([name, ids]) => [name, ...ids.map((id) => recordKey(name, id))]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function keysOf(changes: readonly Invalidation[]) {
|
|
23
|
+
return [...new Set(changes.map((change) => change.key))];
|
|
24
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/sql";
|
|
3
|
+
import type { Fragment } from "effect/sql/Statement";
|
|
4
|
+
|
|
5
|
+
export interface KeysetOrder<TCursor> {
|
|
6
|
+
readonly column: string | Fragment;
|
|
7
|
+
readonly direction: "asc" | "desc";
|
|
8
|
+
readonly value: (cursor: TCursor) => string | number | bigint | boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface KeysetPageQuery {
|
|
12
|
+
readonly limit: number;
|
|
13
|
+
readonly orderBy: Fragment;
|
|
14
|
+
readonly seek: Fragment;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface KeysetPage<TRow, TCursor> {
|
|
18
|
+
readonly next: Option.Option<TCursor>;
|
|
19
|
+
readonly rows: readonly TRow[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PageSize = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThan(Number.MAX_SAFE_INTEGER));
|
|
23
|
+
|
|
24
|
+
export const keysetPage = Effect.fnUntraced(function* <TRow, TCursor, E, R>(options: {
|
|
25
|
+
readonly limit: number;
|
|
26
|
+
readonly after?: TCursor | undefined;
|
|
27
|
+
readonly orderBy: readonly KeysetOrder<TCursor>[];
|
|
28
|
+
readonly tieBreaker: KeysetOrder<TCursor>;
|
|
29
|
+
readonly cursorFor: (row: TRow) => TCursor;
|
|
30
|
+
readonly read: (query: KeysetPageQuery) => Effect.Effect<readonly TRow[], E, R>;
|
|
31
|
+
}): Effect.fn.Return<KeysetPage<TRow, TCursor>, E | Schema.SchemaError, R | SqlClient.SqlClient> {
|
|
32
|
+
const limit = yield* Schema.decodeUnknownEffect(PageSize)(options.limit);
|
|
33
|
+
const sql = yield* SqlClient.SqlClient;
|
|
34
|
+
const order = [...options.orderBy, options.tieBreaker];
|
|
35
|
+
const columns = order.map(({ column }) => (typeof column === "string" ? sql`${sql(column)}` : column));
|
|
36
|
+
const after = options.after;
|
|
37
|
+
const values = after === undefined ? undefined : order.map(({ value }) => value(after));
|
|
38
|
+
const seek =
|
|
39
|
+
values === undefined
|
|
40
|
+
? sql`true`
|
|
41
|
+
: sql.or(
|
|
42
|
+
order.map(({ direction }, index) =>
|
|
43
|
+
sql.and([
|
|
44
|
+
...columns.slice(0, index).map((column, previous) => sql`${column} = ${values[previous]}`),
|
|
45
|
+
direction === "asc" ? sql`${columns[index]} > ${values[index]}` : sql`${columns[index]} < ${values[index]}`,
|
|
46
|
+
]),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const orderBy = sql.csv(order.map(({ direction }, index) => sql`${columns[index]} ${sql.literal(direction === "asc" ? "asc" : "desc")}`));
|
|
50
|
+
const found = yield* options.read({ limit: limit + 1, orderBy, seek });
|
|
51
|
+
const rows = found.slice(0, limit);
|
|
52
|
+
const last = rows.at(-1);
|
|
53
|
+
return { next: found.length > limit && last !== undefined ? Option.some(options.cursorFor(last)) : Option.none(), rows };
|
|
54
|
+
});
|
package/src/latestPerGroup.ts
CHANGED
|
@@ -18,8 +18,9 @@ function groupMasks(input: readonly (readonly unknown[])[]) {
|
|
|
18
18
|
return [...masks.values()];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export
|
|
22
|
-
const
|
|
21
|
+
export const latestPerGroup = Effect.fnUntraced(function* (model: Model.Any, tableName: string) {
|
|
22
|
+
const sql = yield* SqlClient.SqlClient;
|
|
23
|
+
const { fieldAt, where } = yield* makeFilter(model);
|
|
23
24
|
function groupSource(groups: readonly (readonly unknown[])[], partitionBy: readonly string[], fromTuple: boolean) {
|
|
24
25
|
const partitions = sql.csv(partitionBy.map((key) => sql`${sql(key)}`));
|
|
25
26
|
if (fromTuple) {
|
|
@@ -37,7 +38,7 @@ export function latestPerGroup(model: Model.Any, sql: SqlClient.SqlClient, table
|
|
|
37
38
|
const partitions = sql.csv(query.partitionBy.map((key) => sql`${sql(key)}`));
|
|
38
39
|
const keys = Object.keys(model.fields);
|
|
39
40
|
const columns = sql.csv(keys.map((key) => sql`${sql(key)}`));
|
|
40
|
-
const pinned = yield* pinnedGroups(model,
|
|
41
|
+
const pinned = yield* pinnedGroups(model, query.partitionBy, query.where).pipe(Effect.provideService(SqlClient.SqlClient, sql));
|
|
41
42
|
if (pinned !== undefined && pinned.groups.length === 0) {
|
|
42
43
|
return [];
|
|
43
44
|
}
|
|
@@ -82,4 +83,4 @@ export function latestPerGroup(model: Model.Any, sql: SqlClient.SqlClient, table
|
|
|
82
83
|
order by ${partitions}, ${sql(rankColumn)}`;
|
|
83
84
|
return yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct(model.fields)))(rows);
|
|
84
85
|
});
|
|
85
|
-
}
|
|
86
|
+
});
|