@shivaedev/sql 0.0.0 → 0.21.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 (189) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/LICENSE +21 -0
  3. package/README.md +613 -1
  4. package/dist/SchemaComponent.d.ts +9 -0
  5. package/dist/SchemaComponent.d.ts.map +1 -0
  6. package/dist/SchemaComponent.js +6 -0
  7. package/dist/SchemaComponent.js.map +1 -0
  8. package/dist/WorkerRelease.d.ts +14 -0
  9. package/dist/WorkerRelease.d.ts.map +1 -0
  10. package/dist/WorkerRelease.js +30 -0
  11. package/dist/WorkerRelease.js.map +1 -0
  12. package/dist/advisoryXactLock.d.ts +10 -0
  13. package/dist/advisoryXactLock.d.ts.map +1 -0
  14. package/dist/advisoryXactLock.js +18 -0
  15. package/dist/advisoryXactLock.js.map +1 -0
  16. package/dist/aggregateColumn.d.ts +8 -0
  17. package/dist/aggregateColumn.d.ts.map +1 -0
  18. package/dist/aggregateColumn.js +44 -0
  19. package/dist/aggregateColumn.js.map +1 -0
  20. package/dist/aggregateQuery.d.ts +51 -0
  21. package/dist/aggregateQuery.d.ts.map +1 -0
  22. package/dist/aggregateQuery.js +2 -0
  23. package/dist/aggregateQuery.js.map +1 -0
  24. package/dist/assertPostgresMigrations.d.ts +7 -0
  25. package/dist/assertPostgresMigrations.d.ts.map +1 -0
  26. package/dist/assertPostgresMigrations.js +20 -0
  27. package/dist/assertPostgresMigrations.js.map +1 -0
  28. package/dist/batchWrites.d.ts +16 -0
  29. package/dist/batchWrites.d.ts.map +1 -0
  30. package/dist/batchWrites.js +49 -0
  31. package/dist/batchWrites.js.map +1 -0
  32. package/dist/clientOptions.d.ts +8 -0
  33. package/dist/clientOptions.d.ts.map +1 -0
  34. package/dist/clientOptions.js +8 -0
  35. package/dist/clientOptions.js.map +1 -0
  36. package/dist/compositeRepository.d.ts +41 -0
  37. package/dist/compositeRepository.d.ts.map +1 -0
  38. package/dist/compositeRepository.js +38 -0
  39. package/dist/compositeRepository.js.map +1 -0
  40. package/dist/conditionalUpsert.d.ts +59 -0
  41. package/dist/conditionalUpsert.d.ts.map +1 -0
  42. package/dist/conditionalUpsert.js +28 -0
  43. package/dist/conditionalUpsert.js.map +1 -0
  44. package/dist/conflictWrites.d.ts +45 -0
  45. package/dist/conflictWrites.d.ts.map +1 -0
  46. package/dist/conflictWrites.js +40 -0
  47. package/dist/conflictWrites.js.map +1 -0
  48. package/dist/coordinateWorkerRelease.d.ts +5 -0
  49. package/dist/coordinateWorkerRelease.d.ts.map +1 -0
  50. package/dist/coordinateWorkerRelease.js +30 -0
  51. package/dist/coordinateWorkerRelease.js.map +1 -0
  52. package/dist/insertFromSelect.d.ts +24 -0
  53. package/dist/insertFromSelect.d.ts.map +1 -0
  54. package/dist/insertFromSelect.js +15 -0
  55. package/dist/insertFromSelect.js.map +1 -0
  56. package/dist/latestPerGroup.d.ts +11 -0
  57. package/dist/latestPerGroup.d.ts.map +1 -0
  58. package/dist/latestPerGroup.js +51 -0
  59. package/dist/latestPerGroup.js.map +1 -0
  60. package/dist/listenForChanges.d.ts +5 -0
  61. package/dist/listenForChanges.d.ts.map +1 -0
  62. package/dist/listenForChanges.js +75 -0
  63. package/dist/listenForChanges.js.map +1 -0
  64. package/dist/makeAggregate.d.ts +8 -0
  65. package/dist/makeAggregate.d.ts.map +1 -0
  66. package/dist/makeAggregate.js +38 -0
  67. package/dist/makeAggregate.js.map +1 -0
  68. package/dist/makeConflictClause.d.ts +10 -0
  69. package/dist/makeConflictClause.d.ts.map +1 -0
  70. package/dist/makeConflictClause.js +41 -0
  71. package/dist/makeConflictClause.js.map +1 -0
  72. package/dist/makeQueryClauses.d.ts +14 -0
  73. package/dist/makeQueryClauses.d.ts.map +1 -0
  74. package/dist/makeQueryClauses.js +20 -0
  75. package/dist/makeQueryClauses.js.map +1 -0
  76. package/dist/migrationConvention.d.ts +3 -0
  77. package/dist/migrationConvention.d.ts.map +1 -0
  78. package/dist/migrationConvention.js +3 -0
  79. package/dist/migrationConvention.js.map +1 -0
  80. package/dist/migrations.d.ts +12 -0
  81. package/dist/migrations.d.ts.map +1 -0
  82. package/dist/migrations.js +41 -0
  83. package/dist/migrations.js.map +1 -0
  84. package/dist/migrationsFromDirectory.d.ts +4 -0
  85. package/dist/migrationsFromDirectory.d.ts.map +1 -0
  86. package/dist/migrationsFromDirectory.js +7 -0
  87. package/dist/migrationsFromDirectory.js.map +1 -0
  88. package/dist/notifyPayload.d.ts +7 -0
  89. package/dist/notifyPayload.d.ts.map +1 -0
  90. package/dist/notifyPayload.js +31 -0
  91. package/dist/notifyPayload.js.map +1 -0
  92. package/dist/pendingPostgresMigrations.d.ts +24 -0
  93. package/dist/pendingPostgresMigrations.d.ts.map +1 -0
  94. package/dist/pendingPostgresMigrations.js +30 -0
  95. package/dist/pendingPostgresMigrations.js.map +1 -0
  96. package/dist/pinnedGroups.d.ts +22 -0
  97. package/dist/pinnedGroups.d.ts.map +1 -0
  98. package/dist/pinnedGroups.js +35 -0
  99. package/dist/pinnedGroups.js.map +1 -0
  100. package/dist/postgresChanges.d.ts +20 -0
  101. package/dist/postgresChanges.d.ts.map +1 -0
  102. package/dist/postgresChanges.js +26 -0
  103. package/dist/postgresChanges.js.map +1 -0
  104. package/dist/postgresRelease.d.ts +9 -0
  105. package/dist/postgresRelease.d.ts.map +1 -0
  106. package/dist/postgresRelease.js +39 -0
  107. package/dist/postgresRelease.js.map +1 -0
  108. package/dist/releaseWorkers.d.ts +8 -0
  109. package/dist/releaseWorkers.d.ts.map +1 -0
  110. package/dist/releaseWorkers.js +26 -0
  111. package/dist/releaseWorkers.js.map +1 -0
  112. package/dist/repository.d.ts +66 -0
  113. package/dist/repository.d.ts.map +1 -0
  114. package/dist/repository.js +43 -0
  115. package/dist/repository.js.map +1 -0
  116. package/dist/repositoryChanges.d.ts +9 -0
  117. package/dist/repositoryChanges.d.ts.map +1 -0
  118. package/dist/repositoryChanges.js +32 -0
  119. package/dist/repositoryChanges.js.map +1 -0
  120. package/dist/repositoryFilter.d.ts +43 -0
  121. package/dist/repositoryFilter.d.ts.map +1 -0
  122. package/dist/repositoryFilter.js +69 -0
  123. package/dist/repositoryFilter.js.map +1 -0
  124. package/dist/repositoryQueries.d.ts +26 -0
  125. package/dist/repositoryQueries.d.ts.map +1 -0
  126. package/dist/repositoryQueries.js +49 -0
  127. package/dist/repositoryQueries.js.map +1 -0
  128. package/dist/repositoryWrites.d.ts +18 -0
  129. package/dist/repositoryWrites.d.ts.map +1 -0
  130. package/dist/repositoryWrites.js +49 -0
  131. package/dist/repositoryWrites.js.map +1 -0
  132. package/dist/testing.d.ts +12 -0
  133. package/dist/testing.d.ts.map +1 -0
  134. package/dist/testing.js +29 -0
  135. package/dist/testing.js.map +1 -0
  136. package/dist/transact.d.ts +18 -0
  137. package/dist/transact.d.ts.map +1 -0
  138. package/dist/transact.js +43 -0
  139. package/dist/transact.js.map +1 -0
  140. package/dist/tupleIn.d.ts +19 -0
  141. package/dist/tupleIn.d.ts.map +1 -0
  142. package/dist/tupleIn.js +20 -0
  143. package/dist/tupleIn.js.map +1 -0
  144. package/dist/versionedRepository.d.ts +37 -0
  145. package/dist/versionedRepository.d.ts.map +1 -0
  146. package/dist/versionedRepository.js +63 -0
  147. package/dist/versionedRepository.js.map +1 -0
  148. package/dist/versionedStatements.d.ts +25 -0
  149. package/dist/versionedStatements.d.ts.map +1 -0
  150. package/dist/versionedStatements.js +75 -0
  151. package/dist/versionedStatements.js.map +1 -0
  152. package/package.json +71 -4
  153. package/src/SchemaComponent.ts +12 -0
  154. package/src/WorkerRelease.ts +38 -0
  155. package/src/advisoryXactLock.ts +17 -0
  156. package/src/aggregateColumn.ts +49 -0
  157. package/src/aggregateQuery.ts +57 -0
  158. package/src/assertPostgresMigrations.ts +24 -0
  159. package/src/batchWrites.ts +69 -0
  160. package/src/clientOptions.ts +16 -0
  161. package/src/compositeRepository.ts +101 -0
  162. package/src/conditionalUpsert.ts +97 -0
  163. package/src/conflictWrites.ts +93 -0
  164. package/src/coordinateWorkerRelease.ts +33 -0
  165. package/src/insertFromSelect.ts +47 -0
  166. package/src/latestPerGroup.ts +58 -0
  167. package/src/listenForChanges.ts +97 -0
  168. package/src/makeAggregate.ts +64 -0
  169. package/src/makeConflictClause.ts +53 -0
  170. package/src/makeQueryClauses.ts +36 -0
  171. package/src/migrationConvention.ts +2 -0
  172. package/src/migrations.ts +58 -0
  173. package/src/migrationsFromDirectory.ts +7 -0
  174. package/src/notifyPayload.ts +34 -0
  175. package/src/pendingPostgresMigrations.ts +42 -0
  176. package/src/pinnedGroups.ts +46 -0
  177. package/src/postgresChanges.ts +48 -0
  178. package/src/postgresRelease.ts +41 -0
  179. package/src/releaseWorkers.ts +32 -0
  180. package/src/repository.ts +147 -0
  181. package/src/repositoryChanges.ts +49 -0
  182. package/src/repositoryFilter.ts +108 -0
  183. package/src/repositoryQueries.ts +107 -0
  184. package/src/repositoryWrites.ts +70 -0
  185. package/src/testing.ts +38 -0
  186. package/src/transact.ts +70 -0
  187. package/src/tupleIn.ts +44 -0
  188. package/src/versionedRepository.ts +108 -0
  189. package/src/versionedStatements.ts +90 -0
@@ -0,0 +1,101 @@
1
+ import type { Cause } from "effect";
2
+ import { Effect, Schema } from "effect";
3
+ import type { Model } from "effect/schema";
4
+ import { type SqlClient, SqlSchema } from "effect/sql";
5
+ import type { SqlError } from "effect/sql/SqlError";
6
+ import { type RepositoryChanges, repositoryChanges } from "#repositoryChanges.ts";
7
+ import type { RepositoryListFilter } from "#repositoryFilter.ts";
8
+ import { repositoryWrites } from "#repositoryWrites.ts";
9
+ import type { IdColumn, NativeRepository } from "#versionedRepository.ts";
10
+
11
+ export type CompositeKey<TModel extends Model.Any> = readonly [IdColumn<TModel>, IdColumn<TModel>, ...IdColumn<TModel>[]];
12
+ export type PrimaryKey<TModel extends Model.Any> = IdColumn<TModel> | CompositeKey<TModel>;
13
+ type DeleteWhere<TModel extends Model.Any> = (
14
+ where: RepositoryListFilter<TModel>,
15
+ ) => Effect.Effect<void, SqlError | Schema.SchemaError, TModel["fields"][keyof TModel["fields"]]["EncodingServices"]>;
16
+
17
+ interface Update<TModel extends Model.Any> {
18
+ (
19
+ input: TModel["update"]["Type"],
20
+ options: { readonly onMissing: "fail" },
21
+ ): Effect.Effect<
22
+ TModel["Type"],
23
+ SqlError | Schema.SchemaError | Cause.NoSuchElementError,
24
+ TModel["update"]["EncodingServices"] | TModel["DecodingServices"]
25
+ >;
26
+ (
27
+ input: TModel["update"]["Type"],
28
+ ): Effect.Effect<TModel["Type"], SqlError | Schema.SchemaError, TModel["update"]["EncodingServices"] | TModel["DecodingServices"]>;
29
+ }
30
+
31
+ export type Repository<TModel extends Model.Any, TId extends PrimaryKey<TModel>> = [TId] extends [IdColumn<TModel>]
32
+ ? Omit<NativeRepository<TModel, Extract<TId, IdColumn<TModel>>>, "delete" | "update"> & {
33
+ readonly delete: NativeRepository<TModel, Extract<TId, IdColumn<TModel>>>["delete"] & DeleteWhere<TModel>;
34
+ readonly update: Update<TModel>;
35
+ }
36
+ : TId extends CompositeKey<TModel>
37
+ ? Omit<NativeRepository<TModel, TId[number]>, "findById" | "delete" | "update"> & {
38
+ readonly update: Update<TModel>;
39
+ readonly findById: (
40
+ id: Pick<TModel["Type"], TId[number]>,
41
+ ) => Effect.Effect<
42
+ TModel["Type"],
43
+ SqlError | Schema.SchemaError | Cause.NoSuchElementError,
44
+ TModel["DecodingServices"] | TModel["fields"][TId[number]]["EncodingServices"]
45
+ >;
46
+ readonly delete: ((
47
+ id: Pick<TModel["Type"], TId[number]>,
48
+ ) => Effect.Effect<void, SqlError | Schema.SchemaError, TModel["fields"][TId[number]]["EncodingServices"]>) &
49
+ DeleteWhere<TModel>;
50
+ }
51
+ : never;
52
+
53
+ export function makeCompositeCrud(
54
+ model: Model.Any,
55
+ sql: SqlClient.SqlClient,
56
+ options: {
57
+ readonly columns: readonly string[];
58
+ readonly tableName: string;
59
+ readonly spanPrefix: string;
60
+ },
61
+ supplied?: RepositoryChanges,
62
+ ) {
63
+ const { columns, tableName, spanPrefix } = options;
64
+ const fields = Object.fromEntries(
65
+ columns.map((column) => {
66
+ const field = model.fields[column];
67
+ if (field === undefined) {
68
+ throw new Error(`Unknown ID column: ${column}`);
69
+ }
70
+ return [column, field];
71
+ }),
72
+ );
73
+ const Key = columns.length === 1 ? model.fields[columns[0] ?? ""] : Schema.Struct(fields);
74
+ if (Key === undefined) {
75
+ throw new Error("Unknown repository ID column");
76
+ }
77
+ const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
78
+ function predicate(input: Readonly<Record<string, unknown>>) {
79
+ return sql.and(columns.map((column) => sql`${sql(column)} = ${input[column]}`));
80
+ }
81
+ const find = SqlSchema.findOne({
82
+ execute: (input) =>
83
+ Effect.flatMap(
84
+ record(columns.length === 1 ? { [columns[0] ?? ""]: input } : input),
85
+ (key) => sql`select * from ${sql(tableName)} where ${predicate(key)}`,
86
+ ),
87
+ Request: Key,
88
+ Result: model,
89
+ });
90
+ const changes = supplied ?? {
91
+ record: (rows: readonly Readonly<Record<string, unknown>>[]) =>
92
+ Effect.flatMap(repositoryChanges(sql, tableName, columns), (boundary) => boundary.record(rows)),
93
+ write<A, E, R>(effect: Effect.Effect<A, E, R>) {
94
+ return Effect.flatMap(repositoryChanges(sql, tableName, columns), (boundary) => boundary.write(effect));
95
+ },
96
+ };
97
+ return {
98
+ ...repositoryWrites(model, sql, { ...options, idColumns: columns }, changes),
99
+ findById: (id: unknown) => find(id).pipe(Effect.withSpan(`${spanPrefix}.findById`)),
100
+ };
101
+ }
@@ -0,0 +1,97 @@
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 { makeConflictClause } from "#makeConflictClause.ts";
6
+ import type { Comparisons, FindManyFilter } from "#repositoryFilter.ts";
7
+ import type { RepositoryChanges } from "./repositoryChanges.ts";
8
+
9
+ type Fields<TModel extends Model.Any> = Schema.Struct.Type<TModel["fields"]>;
10
+ interface Incoming<TModel extends Model.Any, TValue> {
11
+ readonly excluded: {
12
+ [TKey in keyof Fields<TModel>]: [Fields<TModel>[TKey]] extends [TValue] ? TKey : never;
13
+ }[keyof Fields<TModel>];
14
+ }
15
+ export type UpsertFilter<TModel extends Model.Any> = {
16
+ readonly [TKey in keyof Fields<TModel>]?:
17
+ | FindManyFilter<TModel>[TKey]
18
+ | Incoming<TModel, Fields<TModel>[TKey]>
19
+ | Comparisons<Fields<TModel>[TKey], NonNullable<Fields<TModel>[TKey]> | Incoming<TModel, NonNullable<Fields<TModel>[TKey]>>>;
20
+ };
21
+ export type UpsertResult<TRow> =
22
+ | { readonly _tag: "Inserted"; readonly row: TRow }
23
+ | { readonly _tag: "Updated"; readonly row: TRow }
24
+ | { readonly _tag: "Unchanged" };
25
+ export interface ConditionalUpsert<TModel extends Model.Any, TOptions, TServices> {
26
+ (
27
+ input: TModel["insert"]["Type"],
28
+ options: TOptions & { readonly updateWhere: UpsertFilter<TModel> },
29
+ ): Effect.Effect<
30
+ UpsertResult<TModel["Type"]>,
31
+ SqlError | Schema.SchemaError,
32
+ TServices | TModel["fields"][keyof TModel["fields"]]["EncodingServices"]
33
+ >;
34
+ (
35
+ input: TModel["insert"]["Type"],
36
+ options: TOptions & { readonly updateWhere?: never },
37
+ ): Effect.Effect<TModel["Type"], SqlError | Schema.SchemaError, TServices>;
38
+ }
39
+ export interface ConditionalUpsertMany<TModel extends Model.Any, TOptions, TServices> {
40
+ (
41
+ inputs: readonly TModel["insert"]["Type"][],
42
+ options: TOptions & { readonly updateWhere: UpsertFilter<TModel> },
43
+ ): Effect.Effect<
44
+ UpsertResult<TModel["Type"]>[],
45
+ SqlError | Schema.SchemaError,
46
+ TServices | TModel["fields"][keyof TModel["fields"]]["EncodingServices"]
47
+ >;
48
+ (
49
+ inputs: readonly TModel["insert"]["Type"][],
50
+ options: TOptions & { readonly updateWhere?: never },
51
+ ): Effect.Effect<TModel["Type"][], SqlError | Schema.SchemaError, TServices>;
52
+ }
53
+
54
+ export function makeConditionalUpsert(
55
+ model: Model.Any,
56
+ sql: SqlClient.SqlClient,
57
+ options: {
58
+ readonly tableName: string;
59
+ readonly spanPrefix: string;
60
+ readonly changes: RepositoryChanges;
61
+ readonly conflictColumns: readonly string[];
62
+ readonly conflictWhere?: Readonly<Record<string, unknown>>;
63
+ readonly idColumns: readonly string[];
64
+ },
65
+ ) {
66
+ const encode = Schema.encodeEffect(model.insert);
67
+ const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
68
+ const decode = Schema.decodeUnknownEffect(model);
69
+ const clause = makeConflictClause(model, sql, options);
70
+ return (
71
+ inputs: readonly unknown[],
72
+ settings: { readonly updateColumns: readonly string[]; readonly updateWhere: Readonly<Record<string, unknown>> },
73
+ ) =>
74
+ Effect.gen(function* () {
75
+ const statements = yield* Effect.forEach(inputs, (input) =>
76
+ Effect.gen(function* () {
77
+ const row = yield* record(yield* encode(input));
78
+ const suffix = yield* clause(row, settings.updateColumns, settings.updateWhere);
79
+ return sql`insert into ${sql(options.tableName)} ${sql.insert(row)} ${suffix} returning *, (xmax = 0) as ${sql("effectSqlInserted")}`;
80
+ }),
81
+ );
82
+ const changedRows: Readonly<Record<string, unknown>>[] = [];
83
+ const results = yield* Effect.forEach(statements, (statement) =>
84
+ Effect.gen(function* () {
85
+ const rows = yield* statement;
86
+ changedRows.push(...rows);
87
+ const row = rows[0];
88
+ if (row === undefined) {
89
+ return { _tag: "Unchanged" } as const;
90
+ }
91
+ return { _tag: row.effectSqlInserted ? "Inserted" : "Updated", row: yield* decode(row) } as const;
92
+ }),
93
+ );
94
+ yield* options.changes.record(changedRows);
95
+ return results;
96
+ }).pipe(options.changes.write, Effect.withSpan(`${options.spanPrefix}.conditionalUpsert`));
97
+ }
@@ -0,0 +1,93 @@
1
+ import { Effect, Option, 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 InsertMany, makeInsertMany } from "#batchWrites.ts";
6
+ import { type ConditionalUpsert, type ConditionalUpsertMany, makeConditionalUpsert } from "#conditionalUpsert.ts";
7
+ import { type InsertFromSelect, makeInsertFromSelect } from "#insertFromSelect.ts";
8
+ import { makeConflictClause } from "#makeConflictClause.ts";
9
+ import type { RepositoryChanges } from "./repositoryChanges.ts";
10
+
11
+ export type InsertColumn<TModel extends Model.Any> = Extract<keyof TModel["insert"]["Type"] & keyof TModel["fields"], string>;
12
+ export type ConflictColumns<TModel extends Model.Any> = readonly [InsertColumn<TModel>, ...InsertColumn<TModel>[]];
13
+ interface UpdateOptions<TModel extends Model.Any, TExcluded extends PropertyKey> {
14
+ readonly updateColumns: readonly [
15
+ Exclude<InsertColumn<TModel> & keyof TModel["update"]["Type"], TExcluded>,
16
+ ...Exclude<InsertColumn<TModel> & keyof TModel["update"]["Type"], TExcluded>[],
17
+ ];
18
+ }
19
+ export interface ConflictWrites<TModel extends Model.Any, TExcluded extends PropertyKey, TFilterServices = never> {
20
+ readonly insertFromSelectOrIgnore: InsertFromSelect<TModel, TFilterServices>;
21
+ readonly insertManyOrIgnore: InsertMany<TModel, TFilterServices>;
22
+ readonly insertOrIgnore: (
23
+ input: TModel["insert"]["Type"],
24
+ ) => Effect.Effect<
25
+ Option.Option<TModel["Type"]>,
26
+ SqlError | Schema.SchemaError,
27
+ TModel["insert"]["EncodingServices"] | TModel["DecodingServices"] | TFilterServices
28
+ >;
29
+ readonly upsert: ConditionalUpsert<
30
+ TModel,
31
+ UpdateOptions<TModel, TExcluded>,
32
+ TModel["insert"]["EncodingServices"] | TModel["DecodingServices"] | TFilterServices
33
+ >;
34
+ readonly upsertMany: ConditionalUpsertMany<
35
+ TModel,
36
+ UpdateOptions<TModel, TExcluded>,
37
+ TModel["insert"]["EncodingServices"] | TModel["DecodingServices"] | TFilterServices
38
+ >;
39
+ }
40
+
41
+ export function makeConflictWrites(
42
+ model: Model.Any,
43
+ sql: SqlClient.SqlClient,
44
+ options: {
45
+ readonly changes: RepositoryChanges;
46
+ readonly tableName: string;
47
+ readonly spanPrefix: string;
48
+ readonly conflictColumns: readonly string[];
49
+ readonly conflictWhere?: Readonly<Record<string, unknown>>;
50
+ readonly idColumns: readonly string[];
51
+ },
52
+ ) {
53
+ const { tableName, spanPrefix } = options;
54
+ const encode = Schema.encodeEffect(model.insert);
55
+ const decode = Schema.decodeUnknownEffect(model);
56
+ const record = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
57
+ const conditional = makeConditionalUpsert(model, sql, options);
58
+ const clause = makeConflictClause(model, sql, options);
59
+ function statement(input: unknown, updateColumns?: readonly string[]) {
60
+ return Effect.gen(function* () {
61
+ const encoded = yield* record(yield* encode(input));
62
+ const suffix = yield* clause(encoded, updateColumns);
63
+ return sql`insert into ${sql(tableName)} ${sql.insert(encoded)} ${suffix} returning *`;
64
+ });
65
+ }
66
+ return {
67
+ insertFromSelectOrIgnore: makeInsertFromSelect(model, sql, options, "insertFromSelectOrIgnore", (columns) =>
68
+ clause(Object.fromEntries(columns.map((key) => [key, undefined]))),
69
+ ),
70
+ insertManyOrIgnore: makeInsertMany(model, sql, options, "insertManyOrIgnore", (row) => clause(row)),
71
+ insertOrIgnore: (input: unknown) =>
72
+ Effect.gen(function* () {
73
+ const rows = yield* yield* statement(input);
74
+ yield* options.changes.record(rows);
75
+ return rows.length === 0 ? Option.none() : Option.some(yield* decode(rows[0]));
76
+ }).pipe(options.changes.write, Effect.withSpan(`${spanPrefix}.insertOrIgnore`)),
77
+ upsert: (input: unknown, settings: { readonly updateColumns: readonly string[]; readonly updateWhere?: Readonly<Record<string, unknown>> }) =>
78
+ settings.updateWhere === undefined
79
+ ? Effect.gen(function* () {
80
+ const rows = yield* yield* statement(input, settings.updateColumns);
81
+ yield* options.changes.record(rows);
82
+ return yield* decode(rows[0]);
83
+ }).pipe(options.changes.write, Effect.withSpan(`${spanPrefix}.upsert`))
84
+ : conditional([input], { ...settings, updateWhere: settings.updateWhere }).pipe(Effect.map((results) => results[0])),
85
+ upsertMany: (
86
+ inputs: readonly unknown[],
87
+ settings: { readonly updateColumns: readonly string[]; readonly updateWhere?: Readonly<Record<string, unknown>> },
88
+ ) =>
89
+ settings.updateWhere === undefined
90
+ ? makeInsertMany(model, sql, options, "upsertMany", (row) => clause(row, settings.updateColumns))(inputs)
91
+ : conditional(inputs, { ...settings, updateWhere: settings.updateWhere }),
92
+ };
93
+ }
@@ -0,0 +1,33 @@
1
+ import { Duration, Effect } from "effect";
2
+ import { readPostgresRelease } from "./postgresRelease.ts";
3
+ import { releaseWorkers } from "./releaseWorkers.ts";
4
+ import { makeWorkerRelease } from "./WorkerRelease.ts";
5
+
6
+ export const coordinateWorkerRelease = Effect.fn("EffectSql.coordinateWorkerRelease")(function* (
7
+ release: string,
8
+ pollInterval: Duration.Input = "1 second",
9
+ table = "effect_sql_migrations",
10
+ ) {
11
+ const gate = yield* makeWorkerRelease();
12
+ const rows = yield* releaseWorkers(release, Duration.toMillis(pollInterval) * 5, table);
13
+ let claimed = false;
14
+ const poll = Effect.gen(function* () {
15
+ const owner = yield* readPostgresRelease(table);
16
+ if (owner?.release !== release) {
17
+ yield* gate.setActive(false).pipe(Effect.orDie);
18
+ yield* rows.remove;
19
+ claimed = false;
20
+ } else if (!claimed) {
21
+ claimed = yield* rows.claim;
22
+ if (claimed) {
23
+ yield* gate.setActive(true).pipe(Effect.orDie);
24
+ }
25
+ }
26
+ });
27
+ yield* Effect.addFinalizer(() => rows.remove.pipe(Effect.orDie));
28
+ // Heartbeats continue while offWork waits for active jobs to finish.
29
+ yield* Effect.forever(Effect.sleep(pollInterval).pipe(Effect.andThen(rows.heartbeat))).pipe(Effect.orDie, Effect.forkScoped);
30
+ yield* poll;
31
+ yield* Effect.forever(Effect.sleep(pollInterval).pipe(Effect.andThen(poll))).pipe(Effect.orDie, Effect.forkScoped);
32
+ return gate.service;
33
+ });
@@ -0,0 +1,47 @@
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 { RepositoryChanges } from "#repositoryChanges.ts";
7
+ import type { RepositorySelect } from "#repositoryQueries.ts";
8
+
9
+ type CompatibleColumn<TType, TEncoded, TFields extends Schema.Struct.Fields> = {
10
+ [TKey in keyof TFields]: TFields[TKey]["Type"] extends TType ? (TFields[TKey]["Encoded"] extends TEncoded ? TKey : never) : never;
11
+ }[keyof TFields] &
12
+ string;
13
+
14
+ type ColumnMapping<TInput, TEncoded, TFields extends Schema.Struct.Fields> = {
15
+ [TKey in keyof TInput]: CompatibleColumn<TInput[TKey], TEncoded[Extract<TKey, keyof TEncoded>], TFields>;
16
+ };
17
+
18
+ export type InsertFromSelect<TModel extends Model.Any, TServices = never> = <TFields extends Schema.Struct.Fields, TSourceServices>(
19
+ source: RepositorySelect<TFields, TSourceServices>,
20
+ columns: ColumnMapping<TModel["insert"]["Type"], TModel["insert"]["Encoded"], TFields>,
21
+ ) => Effect.Effect<TModel["Type"][], SqlError | Schema.SchemaError, TSourceServices | TModel["DecodingServices"] | TServices>;
22
+
23
+ export interface SelectWrites<TModel extends Model.Any> {
24
+ readonly insertFromSelect: InsertFromSelect<TModel>;
25
+ }
26
+
27
+ export function makeInsertFromSelect<TServices = never>(
28
+ model: Model.Any,
29
+ sql: SqlClient.SqlClient,
30
+ options: { readonly tableName: string; readonly spanPrefix: string; readonly changes: RepositoryChanges },
31
+ operation: string,
32
+ prepare: (columns: readonly string[]) => Effect.Effect<Fragment, Schema.SchemaError, TServices> = () => Effect.succeed(sql``),
33
+ ) {
34
+ const decode = Schema.decodeUnknownEffect(Schema.Array(model));
35
+ return (source: RepositorySelect<Schema.Struct.Fields, unknown>, columns: Readonly<Record<string, string>>) =>
36
+ Effect.gen(function* () {
37
+ const names = Object.keys(columns);
38
+ const selected = sql.csv(Object.values(columns).map((column) => sql`${sql(column)}`));
39
+ const target = sql.csv(names.map((column) => sql`${sql(column)}`));
40
+ const suffix = yield* prepare(names);
41
+ const statement = yield* source.statement;
42
+ const rows =
43
+ yield* sql`insert into ${sql(options.tableName)} (${target}) select ${selected} from (${statement}) as ${sql("source")} where true ${suffix} returning *`;
44
+ yield* options.changes.record(rows);
45
+ return yield* decode(rows);
46
+ }).pipe(options.changes.write, Effect.withSpan(`${options.spanPrefix}.${operation}`));
47
+ }
@@ -0,0 +1,58 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import type { SqlClient } from "effect/sql";
4
+ import type { Fragment } from "effect/sql/Statement";
5
+ import { pinnedGroups } from "#pinnedGroups.ts";
6
+ import type { LatestPerGroup } from "#repository.ts";
7
+ import { makeFilter } from "#repositoryFilter.ts";
8
+
9
+ export function latestPerGroup(model: Model.Any, sql: SqlClient.SqlClient, tableName: string) {
10
+ const { fieldAt, where } = makeFilter(model, sql);
11
+ return Effect.fnUntraced(function* (query: LatestPerGroup<Model.Any>, order: Fragment, rowLimit: number) {
12
+ yield* Effect.forEach(query.partitionBy, fieldAt);
13
+ const predicate = yield* where(query.where, true);
14
+ const partitions = sql.csv(query.partitionBy.map((key) => sql`${sql(key)}`));
15
+ const keys = Object.keys(model.fields);
16
+ const columns = sql.csv(keys.map((key) => sql`${sql(key)}`));
17
+ const pinned = yield* pinnedGroups(model, sql, query.partitionBy, query.where);
18
+ if (pinned !== undefined && pinned.groups.length === 0) {
19
+ return [];
20
+ }
21
+ if (pinned !== undefined && sql.onDialectOrElse({ orElse: () => false, pg: () => true })) {
22
+ const remaining = yield* where(pinned.remaining, true);
23
+ const masks = new Map<string, (readonly unknown[])[]>();
24
+ for (const group of pinned.groups) {
25
+ const mask = group.map((value) => (value === null ? "0" : "1")).join("");
26
+ const groups = masks.get(mask) ?? [];
27
+ groups.push(group);
28
+ masks.set(mask, groups);
29
+ }
30
+ const branches = Array.from(masks.values(), (groups) => {
31
+ const values = sql.join(" union ", false)(groups.map((group) => sql`select ${sql.csv(group.map((value) => sql`${value}`))}`));
32
+ const matches = query.partitionBy.map((key, index) =>
33
+ groups[0]?.[index] === null ? sql`${sql(key)} is null` : sql`${sql(key)} = ${sql("__effect_sql_groups")}.${sql(key)}`,
34
+ );
35
+ // The empty table projection gives encoded parameters their actual PostgreSQL column types.
36
+ return sql`select ${sql("__effect_sql_latest")}.* from (
37
+ select ${partitions} from ${sql(tableName)} where false union ${values}
38
+ ) as ${sql("__effect_sql_groups")} cross join lateral (
39
+ select ${columns} from ${sql(tableName)} where ${sql.and([...matches, remaining])} ${order} limit ${rowLimit}
40
+ ) as ${sql("__effect_sql_latest")}`;
41
+ });
42
+ const ordering = sql.csv(query.orderBy.map(({ field, direction }) => sql`${sql(field)} ${sql.literal(direction === "desc" ? "desc" : "asc")}`));
43
+ const rows =
44
+ yield* sql`select ${columns} from (${sql.join(" union all ")(branches)}) as ${sql("__effect_sql_pinned")} order by ${partitions}, ${ordering}`;
45
+ return yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct(model.fields)))(rows);
46
+ }
47
+ let rankColumn = "__effect_sql_rank";
48
+ while (Object.hasOwn(model.fields, rankColumn)) {
49
+ rankColumn += "_";
50
+ }
51
+ const rows = yield* sql`select ${columns} from (
52
+ select ${columns}, row_number() over (partition by ${partitions} ${order}) as ${sql(rankColumn)}
53
+ from ${sql(tableName)} where ${predicate}
54
+ ) as ${sql("__effect_sql_ranked")} where ${sql(rankColumn)} <= ${rowLimit}
55
+ order by ${partitions}, ${sql(rankColumn)}`;
56
+ return yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct(model.fields)))(rows);
57
+ });
58
+ }
@@ -0,0 +1,97 @@
1
+ import { Clock, Deferred, type Duration, Effect, type Exit, Queue, Scope } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import { decodeKeys } from "./notifyPayload.ts";
4
+ import type { PostgresChangesOptions } from "./postgresChanges.ts";
5
+
6
+ function closeListener(scope: Scope.Closeable, outcome: Exit.Exit<unknown, unknown>, timeout: Duration.Input) {
7
+ return Effect.suspend(() => {
8
+ function bounded(finalizer: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>) {
9
+ return (exit: Exit.Exit<unknown, unknown>) => finalizer(exit).pipe(Effect.interruptible, Effect.timeoutOption(timeout), Effect.asVoid);
10
+ }
11
+ // Bound each finalizer so a stalled UNLISTEN cannot prevent the native connection release that follows it.
12
+ if (scope.state._tag === "Open") {
13
+ const state = scope.state;
14
+ if (state.finalizer !== undefined) {
15
+ state.finalizer = bounded(state.finalizer);
16
+ }
17
+ if (state.finalizers !== undefined) {
18
+ for (const [key, finalizer] of state.finalizers) {
19
+ state.finalizers.set(key, bounded(finalizer));
20
+ }
21
+ }
22
+ }
23
+ return Scope.closeUnsafe(scope, outcome) ?? Effect.void;
24
+ });
25
+ }
26
+
27
+ export const listenForChanges = Effect.fn("PostgresChanges.listenForChanges")(function* (options: PostgresChangesOptions) {
28
+ const ready = yield* Deferred.make<void>();
29
+ const reactivity = yield* Reactivity.Reactivity;
30
+ const { client, channel, resync } = options;
31
+ const timeout = options.heartbeatTimeout ?? "10 seconds";
32
+ let lostAt: number | undefined;
33
+ const attempt = Effect.scoped(
34
+ Effect.gen(function* () {
35
+ const scope = yield* Effect.acquireRelease(Scope.make(), (listenerScope, exit) => closeListener(listenerScope, exit, timeout));
36
+ const token = JSON.stringify({ heartbeat: crypto.randomUUID() });
37
+ let ack = yield* Deferred.make<void>();
38
+ function receive(payload: string) {
39
+ if (payload === token) {
40
+ return Effect.asVoid(Deferred.succeed(ack, undefined));
41
+ }
42
+ if (!payload.startsWith("[")) {
43
+ return Effect.void;
44
+ }
45
+ return decodeKeys(payload).pipe(
46
+ Effect.flatMap((keys) => Effect.sync(() => reactivity.invalidateUnsafe(keys))),
47
+ Effect.catchCause(() => resync),
48
+ );
49
+ }
50
+ const listening = Effect.gen(function* () {
51
+ const notifications = yield* client.listen(channel).pipe(Scope.provide(scope));
52
+ yield* Effect.forever(
53
+ Effect.flatMap(Queue.takeAll(notifications), (batch) =>
54
+ Effect.forEach(batch, (notification) => receive(notification.payload), { discard: true }),
55
+ ),
56
+ );
57
+ });
58
+ const send = Effect.asVoid(client`select pg_notify(${channel}, ${token})`);
59
+ const monitor = Effect.gen(function* () {
60
+ yield* Effect.raceFirst(Deferred.await(ack), Effect.forever(Effect.andThen(send, Effect.sleep("50 millis")))).pipe(Effect.timeout(timeout));
61
+ const streamCount = yield* resync;
62
+ if (lostAt !== undefined) {
63
+ const downtimeMs = (yield* Clock.currentTimeMillis) - lostAt;
64
+ yield* Effect.logInfo("PostgreSQL change listener recovered; resynchronized live streams").pipe(
65
+ Effect.annotateLogs({ downtimeMs, streamCount }),
66
+ );
67
+ lostAt = undefined;
68
+ }
69
+ yield* Deferred.succeed(ready, undefined);
70
+ yield* Effect.forever(
71
+ Effect.gen(function* () {
72
+ yield* Effect.sleep(options.heartbeatInterval ?? "5 seconds");
73
+ ack = yield* Deferred.make<void>();
74
+ yield* Effect.andThen(send, Deferred.await(ack)).pipe(Effect.timeout(timeout));
75
+ }),
76
+ );
77
+ });
78
+ yield* Effect.raceFirst(listening, monitor).pipe(Effect.onExit(() => resync));
79
+ }),
80
+ );
81
+ yield* Effect.forever(
82
+ attempt.pipe(
83
+ Effect.catchCause(() =>
84
+ Effect.gen(function* () {
85
+ if (lostAt !== undefined) {
86
+ yield* Effect.logDebug("PostgreSQL change listener reconnect failed; retrying");
87
+ return;
88
+ }
89
+ lostAt = yield* Clock.currentTimeMillis;
90
+ yield* Effect.logWarning("PostgreSQL change listener lost; resynchronizing live streams");
91
+ }),
92
+ ),
93
+ Effect.andThen(Effect.sleep(options.retryDelay ?? "1 second")),
94
+ ),
95
+ ).pipe(Effect.forkScoped);
96
+ yield* Deferred.await(ready);
97
+ });
@@ -0,0 +1,64 @@
1
+ import { Effect, Schema, SchemaIssue } 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 { aggregateColumn } from "#aggregateColumn.ts";
7
+ import type { Aggregate } from "#aggregateQuery.ts";
8
+ import { makeQueryClauses } from "#makeQueryClauses.ts";
9
+ import { type FindManyFilter, makeFilter } from "#repositoryFilter.ts";
10
+
11
+ export function makeAggregate<TModel extends Model.Any>(
12
+ model: TModel,
13
+ sql: SqlClient.SqlClient,
14
+ options: { readonly tableName: string; readonly spanPrefix: string },
15
+ ): Aggregate<TModel>;
16
+ export function makeAggregate(
17
+ model: Model.Any,
18
+ sql: SqlClient.SqlClient,
19
+ options: { readonly tableName: string; readonly spanPrefix: string },
20
+ ): unknown {
21
+ const { fieldAt, where } = makeFilter(model, sql);
22
+ const selectAggregate = aggregateColumn(sql, fieldAt);
23
+ return (query: {
24
+ readonly groupBy: readonly string[];
25
+ readonly aggregates: Readonly<Record<string, object>>;
26
+ readonly where?: FindManyFilter<Model.Any>;
27
+ readonly orderBy?: readonly { readonly field: string; readonly direction: "asc" | "desc" }[];
28
+ readonly limit?: number;
29
+ }): Effect.Effect<unknown, SqlError | Schema.SchemaError, unknown> =>
30
+ Effect.gen(function* () {
31
+ const groups = yield* Schema.decodeUnknownEffect(Schema.NonEmptyArray(Schema.String))(query.groupBy);
32
+ const fields = new Map<string, Schema.Constraint>();
33
+ const columns: Fragment[] = [];
34
+ const ordering = new Map<string, Fragment>();
35
+ for (const key of groups) {
36
+ fields.set(key, yield* fieldAt(key));
37
+ columns.push(sql`${sql(key)}`);
38
+ }
39
+ for (const [name, expression] of Object.entries(query.aggregates)) {
40
+ if (groups.includes(name)) {
41
+ return yield* Effect.fail(new Schema.SchemaError(new SchemaIssue.Pointer([name], new SchemaIssue.UnexpectedKey(model.ast, name))));
42
+ }
43
+ const selected = yield* selectAggregate(name, expression);
44
+ ordering.set(name, selected.order);
45
+ columns.push(selected.column);
46
+ fields.set(name, selected.schema);
47
+ }
48
+ const Result = Schema.Struct(Object.fromEntries(fields));
49
+ const clauses = makeQueryClauses(
50
+ sql,
51
+ (key) =>
52
+ fields.has(key)
53
+ ? Effect.succeed(fields.get(key))
54
+ : Effect.fail(new Schema.SchemaError(new SchemaIssue.Pointer([key], new SchemaIssue.UnexpectedKey(Result.ast, key)))),
55
+ (key) => ordering.get(key) ?? sql`${sql(key)}`,
56
+ );
57
+ const predicate = yield* where(query.where, true, true);
58
+ const order = yield* clauses.orderClause(query.orderBy);
59
+ const limit = yield* clauses.limitClause(query.limit);
60
+ const partitions = sql.csv(groups.map((key) => sql`${sql(key)}`));
61
+ const rows = yield* sql`select ${sql.csv(columns)} from ${sql(options.tableName)} where ${predicate} group by ${partitions} ${order} ${limit}`;
62
+ return yield* Schema.decodeUnknownEffect(Schema.Array(Result))(rows);
63
+ }).pipe(Effect.withSpan(`${options.spanPrefix}.aggregate`));
64
+ }
@@ -0,0 +1,53 @@
1
+ import { Effect, Schema, SchemaAST, SchemaIssue } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import type { SqlClient } from "effect/sql";
4
+ import { makeFilter } from "#repositoryFilter.ts";
5
+
6
+ export function makeConflictClause(
7
+ model: Model.Any,
8
+ sql: SqlClient.SqlClient,
9
+ options: {
10
+ readonly tableName: string;
11
+ readonly conflictColumns: readonly string[];
12
+ readonly conflictWhere?: Readonly<Record<string, unknown>>;
13
+ readonly idColumns: readonly string[];
14
+ },
15
+ ) {
16
+ const { conflictColumns, idColumns } = options;
17
+ const updateFilter = makeFilter(model, sql, options);
18
+ const filter = makeFilter(model, sql);
19
+ const updateFields = SchemaAST.isObjects(model.update.ast) ? model.update.ast.propertySignatures.map((field) => field.name) : [];
20
+ const immutable = Object.keys(model.fields).filter((column) => !updateFields.includes(column));
21
+ function invalid(column: string) {
22
+ return Effect.fail(new Schema.SchemaError(new SchemaIssue.Pointer([column], new SchemaIssue.UnexpectedKey(model.ast, column))));
23
+ }
24
+ function validateColumns(encoded: Readonly<Record<string, unknown>>, columns: readonly string[], excluded: readonly string[] = []) {
25
+ return Effect.gen(function* () {
26
+ if (columns.length === 0) {
27
+ return yield* invalid("");
28
+ }
29
+ for (const column of columns) {
30
+ if (!(Object.hasOwn(model.fields, column) && Object.hasOwn(encoded, column)) || excluded.includes(column)) {
31
+ return yield* invalid(column);
32
+ }
33
+ }
34
+ });
35
+ }
36
+ function clause(encoded: Readonly<Record<string, unknown>>, updateColumns?: readonly string[], updateWhere?: Readonly<Record<string, unknown>>) {
37
+ return Effect.gen(function* () {
38
+ yield* validateColumns(encoded, conflictColumns);
39
+ if (updateColumns !== undefined) {
40
+ yield* validateColumns(encoded, updateColumns, [...idColumns, ...conflictColumns, ...immutable]);
41
+ }
42
+ const conflict = sql.csv(conflictColumns.map((column) => sql`${sql(column)}`));
43
+ const where = options.conflictWhere === undefined ? sql`` : sql`where ${yield* filter.where(options.conflictWhere)}`;
44
+ const action =
45
+ updateColumns === undefined
46
+ ? sql`do nothing`
47
+ : sql`do update set ${sql.csv(updateColumns.map((column) => sql`${sql(column)} = excluded.${sql(column)}`))}`;
48
+ const condition = updateWhere === undefined ? sql`` : sql`where ${yield* updateFilter.where(updateWhere, true, true)}`;
49
+ return sql`on conflict (${conflict}) ${where} ${action} ${condition}`;
50
+ });
51
+ }
52
+ return clause;
53
+ }