@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,36 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { SqlClient } from "effect/sql";
3
+ import type { Fragment } from "effect/sql/Statement";
4
+
5
+ const Limit = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0));
6
+ const decodeLimit = Schema.decodeUnknownEffect(Limit);
7
+ interface Order {
8
+ readonly direction: "asc" | "desc";
9
+ readonly field: string;
10
+ }
11
+
12
+ export function makeQueryClauses(
13
+ sql: SqlClient.SqlClient,
14
+ fieldAt: (key: string) => Effect.Effect<unknown, Schema.SchemaError>,
15
+ column: (key: string) => Fragment = (key) => sql`${sql(key)}`,
16
+ ) {
17
+ function orderClause(orderBy: Order | readonly Order[] | undefined) {
18
+ if (orderBy === undefined) {
19
+ return Effect.succeed(sql``);
20
+ }
21
+ const entries = "field" in orderBy ? [orderBy] : orderBy;
22
+ if (entries.length === 0) {
23
+ return Effect.succeed(sql``);
24
+ }
25
+ return Effect.map(
26
+ Effect.forEach(entries, ({ field, direction }) =>
27
+ Effect.as(fieldAt(field), sql`${column(field)} ${sql.literal(direction === "desc" ? "desc" : "asc")}`),
28
+ ),
29
+ (columns) => sql`order by ${sql.csv(columns)}`,
30
+ );
31
+ }
32
+ function limitClause(limit: number | undefined) {
33
+ return limit === undefined ? Effect.succeed(sql``) : Effect.map(decodeLimit(limit), (rowLimit) => sql`limit ${rowLimit}`);
34
+ }
35
+ return { decodeLimit, limitClause, orderClause };
36
+ }
@@ -0,0 +1,2 @@
1
+ export const MIGRATIONS_DIRECTORY = "src/server/migrations";
2
+ export const MIGRATION_FILE_PATTERN = /^[0-9]{4}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.ts$/u;
@@ -0,0 +1,58 @@
1
+ import { Duration, Effect, Option } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import { Migrator, SqlClient } from "effect/sql";
4
+ import { advisoryXactLock } from "./advisoryXactLock.ts";
5
+ import { postgresRelease } from "./postgresRelease.ts";
6
+ import { runSchemaComponent, type SchemaComponent } from "./SchemaComponent.ts";
7
+ import { transact } from "./transact.ts";
8
+
9
+ export interface PostgresMigrationOptions<R> {
10
+ readonly components?: readonly SchemaComponent<R>[];
11
+ readonly loader: Migrator.Loader<R>;
12
+ readonly lockTimeout: Duration.Input;
13
+ readonly release?: string;
14
+ readonly table?: string;
15
+ }
16
+
17
+ const migrate = Migrator.make({});
18
+
19
+ export const migratePostgres = Effect.fn("EffectSql.migratePostgres")(function* <R>({
20
+ loader,
21
+ components = [],
22
+ table = "effect_sql_migrations",
23
+ lockTimeout,
24
+ release,
25
+ }: PostgresMigrationOptions<R>) {
26
+ const sql = yield* SqlClient.SqlClient;
27
+ const available = yield* Effect.serviceOption(Reactivity.Reactivity);
28
+ const reactivity = Option.isSome(available) ? available.value : yield* Reactivity.make;
29
+ const millis = Duration.toMillis(lockTimeout);
30
+ const timeout = millis === Number.POSITIVE_INFINITY ? "0" : `${Math.max(1, Math.ceil(millis))}ms`;
31
+ const appliedMigrations = yield* Effect.gen(function* () {
32
+ yield* sql`select set_config('lock_timeout', ${timeout}, true)`;
33
+ yield* advisoryXactLock(`effect_sql_migrations:${table}`).pipe(Effect.catchTag("AdvisoryLockOutsideTransaction", Effect.die));
34
+ yield* sql`create table if not exists ${sql(table)} (
35
+ migration_id integer primary key,
36
+ created_at timestamp with time zone not null default now(),
37
+ name text not null
38
+ )`;
39
+ const current = [...(yield* loader)].sort(([left], [right]) => left - right);
40
+ const applied = yield* sql<{ id: number }>`select migration_id as id from ${sql(table)}`.withoutTransform;
41
+ const appliedIds = new Set(applied.map(({ id }) => id));
42
+ const latest = applied.reduce((max, { id }) => Math.max(max, id), 0);
43
+ const missing = current.filter(([id]) => id <= latest && !appliedIds.has(id));
44
+ if (missing.length > 0) {
45
+ return yield* Effect.fail(
46
+ new Migrator.MigrationError({
47
+ kind: "BadState",
48
+ message: `Migrations missing from the ledger at or below latest applied id ${latest}: ${missing.map(([id]) => id).join(", ")}`,
49
+ }),
50
+ );
51
+ }
52
+ return yield* migrate({ loader: Effect.succeed(current), table });
53
+ }).pipe(transact({ onSqlError: (error) => error }), Effect.provideService(Reactivity.Reactivity, reactivity));
54
+ for (const component of [...components, ...(release === undefined ? [] : [postgresRelease(release, table)])]) {
55
+ yield* runSchemaComponent(component.name, component.apply);
56
+ }
57
+ return appliedMigrations;
58
+ });
@@ -0,0 +1,7 @@
1
+ import { Effect, Path } from "effect";
2
+ import { Migrator } from "effect/sql";
3
+ import { MIGRATIONS_DIRECTORY } from "#migrationConvention.ts";
4
+
5
+ export function migrationsFromDirectory(root: string) {
6
+ return Effect.flatMap(Path.Path, (path) => Migrator.fromFileSystem(path.join(root, MIGRATIONS_DIRECTORY)));
7
+ }
@@ -0,0 +1,34 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlError, UnknownError } from "effect/sql/SqlError";
3
+
4
+ export const NotificationKeys = Schema.Array(Schema.Union([Schema.String, Schema.Finite]));
5
+ export const decodeKeys = Schema.decodeUnknownEffect(Schema.fromJsonString(NotificationKeys));
6
+ const encoder = new TextEncoder();
7
+
8
+ export function notificationError(message: string, cause: unknown = message) {
9
+ return new SqlError({ reason: new UnknownError({ cause, message, operation: "notify" }) });
10
+ }
11
+
12
+ export const encodeKeys = Effect.fn("PostgresChanges.encodeKeys")(function* (keys: readonly unknown[]) {
13
+ const valid = yield* Schema.decodeUnknownEffect(NotificationKeys)(keys).pipe(
14
+ Effect.mapError((cause) => notificationError("PostgreSQL invalidation keys must be strings or finite numbers", cause)),
15
+ );
16
+ const payloads: string[] = [];
17
+ let batch: Array<string | number> = [];
18
+ for (const key of valid) {
19
+ const next = [...batch, key];
20
+ if (encoder.encode(JSON.stringify(next)).length >= 8000) {
21
+ if (batch.length === 0 || encoder.encode(JSON.stringify([key])).length >= 8000) {
22
+ return yield* Effect.fail(notificationError("A PostgreSQL invalidation key exceeds the NOTIFY payload limit"));
23
+ }
24
+ payloads.push(JSON.stringify(batch));
25
+ batch = [key];
26
+ } else {
27
+ batch = next;
28
+ }
29
+ }
30
+ if (batch.length > 0) {
31
+ payloads.push(JSON.stringify(batch));
32
+ }
33
+ return payloads;
34
+ });
@@ -0,0 +1,42 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { PostgresMigrationOptions } from "./migrations.ts";
4
+ import { postgresRelease } from "./postgresRelease.ts";
5
+ import { runSchemaComponent } from "./SchemaComponent.ts";
6
+
7
+ export interface PendingPostgresMigrations {
8
+ readonly components: readonly { readonly name: string; readonly detail: string }[];
9
+ readonly migrations: readonly { readonly id: number; readonly name: string }[];
10
+ }
11
+
12
+ export const pendingPostgresMigrations = Effect.fn("EffectSql.pendingPostgresMigrations")(function* <R>({
13
+ loader,
14
+ components = [],
15
+ table = "effect_sql_migrations",
16
+ release,
17
+ }: Pick<PostgresMigrationOptions<R>, "loader" | "table" | "components" | "release">) {
18
+ const expected = yield* loader;
19
+ const migrations: { id: number; name: string }[] = [];
20
+ if (expected.length > 0) {
21
+ const sql = yield* SqlClient.SqlClient;
22
+ const identifier = sql`${sql(table)}`.compile()[0];
23
+ const [ledger] = yield* sql<{ name: string | null }>`select to_regclass(${identifier})::text as name`.withoutTransform;
24
+ const applied =
25
+ ledger === undefined || ledger.name === null
26
+ ? []
27
+ : yield* sql<{ id: number; name: string }>`select migration_id as id, name from ${sql(table)}`.withoutTransform;
28
+ for (const [id, name] of expected) {
29
+ if (!applied.some((row) => row.id === id && row.name === name)) {
30
+ migrations.push({ id, name });
31
+ }
32
+ }
33
+ }
34
+ const pendingComponents: { name: string; detail: string }[] = [];
35
+ for (const component of [...components, ...(release === undefined ? [] : [postgresRelease(release, table)])]) {
36
+ const detail = yield* runSchemaComponent(component.name, component.pending);
37
+ if (detail !== undefined) {
38
+ pendingComponents.push({ detail, name: component.name });
39
+ }
40
+ }
41
+ return { components: pendingComponents, migrations } satisfies PendingPostgresMigrations;
42
+ });
@@ -0,0 +1,46 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import type { SqlClient } from "effect/sql";
4
+ import type { RepositoryListFilter } from "#repositoryFilter.ts";
5
+ import { makeFilter } from "#repositoryFilter.ts";
6
+ import { tupleFilter } from "#tupleIn.ts";
7
+
8
+ export function pinnedGroups(
9
+ model: Model.Any,
10
+ sql: SqlClient.SqlClient,
11
+ partitionBy: readonly string[],
12
+ input: RepositoryListFilter<Model.Any> = {},
13
+ ) {
14
+ const tuple = input[tupleFilter];
15
+ if (
16
+ tuple?.values.length === 0
17
+ || Object.values(input).some(
18
+ (value) => typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in) && value.in.length === 0,
19
+ )
20
+ ) {
21
+ return Effect.succeed({ groups: [], remaining: input });
22
+ }
23
+ const exactTuple = tuple !== undefined && tuple.fields.length === partitionBy.length && partitionBy.every((key) => tuple.fields.includes(key));
24
+ let groups: readonly (readonly unknown[])[] = [[]];
25
+ const remaining = { ...input };
26
+ if (exactTuple) {
27
+ groups = tuple.values.map((values) => partitionBy.map((key) => values[tuple.fields.indexOf(key)]));
28
+ delete remaining[tupleFilter];
29
+ } else {
30
+ for (const key of partitionBy) {
31
+ const value = input[key];
32
+ if (value === undefined) {
33
+ return Effect.succeed(undefined);
34
+ }
35
+ const values = typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in) ? value.in : [value];
36
+ groups = groups.flatMap((group) => values.map((entry) => [...group, entry]));
37
+ delete remaining[key];
38
+ }
39
+ }
40
+ const { fieldAt } = makeFilter(model, sql);
41
+ return Effect.gen(function* () {
42
+ const fields = yield* Effect.forEach(partitionBy, fieldAt);
43
+ const groupsEncoded = yield* Effect.forEach(groups, (group) => Schema.encodeUnknownEffect(Schema.Tuple(fields))(group));
44
+ return { groups: groupsEncoded, remaining };
45
+ });
46
+ }
@@ -0,0 +1,48 @@
1
+ import { Context, type Duration, Effect, Layer, type Queue, type Scope } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import { SqlClient } from "effect/sql";
4
+ import type { SqlError } from "effect/sql/SqlError";
5
+ import { listenForChanges } from "./listenForChanges.ts";
6
+ import { encodeKeys } from "./notifyPayload.ts";
7
+ import { BeforeCommit, CommittedSink } from "./transact.ts";
8
+
9
+ export interface PostgresNotification {
10
+ readonly payload: string;
11
+ }
12
+
13
+ export interface PostgresChangeClient extends SqlClient.SqlClient {
14
+ readonly listen: (channel: string) => Effect.Effect<Queue.Dequeue<PostgresNotification, SqlError>, SqlError, Scope.Scope>;
15
+ }
16
+
17
+ export interface PostgresChangesOptions {
18
+ readonly channel: string;
19
+ readonly client: PostgresChangeClient;
20
+ readonly heartbeatInterval?: Duration.Input;
21
+ readonly heartbeatTimeout?: Duration.Input;
22
+ readonly resync: Effect.Effect<number>;
23
+ readonly retryDelay?: Duration.Input;
24
+ }
25
+
26
+ export function postgresChanges(options: PostgresChangesOptions) {
27
+ return Layer.effectContext(
28
+ Effect.gen(function* () {
29
+ yield* listenForChanges(options);
30
+ const notify = Effect.fn("PostgresChanges.notify")(function* (keys: readonly unknown[]) {
31
+ const sql = yield* SqlClient.SqlClient;
32
+ if (sql.transactionService !== options.client.transactionService) {
33
+ return;
34
+ }
35
+ const payloads = yield* encodeKeys(keys);
36
+ for (const payload of payloads) {
37
+ yield* sql`select pg_notify(${options.channel}, ${payload})`;
38
+ }
39
+ });
40
+ function committed(keys: readonly unknown[]) {
41
+ return Effect.flatMap(SqlClient.SqlClient, (sql) =>
42
+ sql.transactionService === options.client.transactionService ? Effect.void : Reactivity.invalidate(keys),
43
+ );
44
+ }
45
+ return Context.make(BeforeCommit, notify).pipe(Context.add(CommittedSink, committed));
46
+ }),
47
+ );
48
+ }
@@ -0,0 +1,41 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { SchemaComponent } from "./SchemaComponent.ts";
4
+
5
+ export const readPostgresRelease = Effect.fn("EffectSql.readPostgresRelease")(function* (table = "effect_sql_migrations") {
6
+ const sql = yield* SqlClient.SqlClient;
7
+ const name = `${table}_release`;
8
+ const identifier = sql`${sql(name)}`.compile()[0];
9
+ const [exists] = yield* sql<{ name: string | null }>`select to_regclass(${identifier})::text as name`.withoutTransform;
10
+ if (exists === undefined || exists.name === null) {
11
+ return undefined;
12
+ }
13
+ const [row] = yield* sql<{ release: string; recordedAt: Date }>`select release, recorded_at as "recordedAt" from ${sql(name)}`.withoutTransform;
14
+ return row;
15
+ });
16
+
17
+ export function postgresRelease(release: string, table = "effect_sql_migrations"): SchemaComponent {
18
+ return {
19
+ apply: Effect.gen(function* () {
20
+ const sql = yield* SqlClient.SqlClient;
21
+ yield* sql`create table if not exists ${sql(`${table}_release`)} (
22
+ id boolean primary key default true check (id),
23
+ release text not null,
24
+ recorded_at timestamptz not null
25
+ )`;
26
+ yield* sql`create table if not exists ${sql(`${table}_workers`)} (
27
+ process_id text primary key,
28
+ release text not null,
29
+ heartbeat timestamptz not null
30
+ )`;
31
+ yield* sql`insert into ${sql(`${table}_release`)} (id, release, recorded_at)
32
+ values (true, ${release}, clock_timestamp())
33
+ on conflict (id) do update set release = excluded.release, recorded_at = excluded.recorded_at`;
34
+ }),
35
+ name: "database release",
36
+ pending: Effect.gen(function* () {
37
+ const current = yield* readPostgresRelease(table);
38
+ return current?.release === release ? undefined : `Expected release ${release}; recorded ${current?.release ?? "none"}`;
39
+ }),
40
+ };
41
+ }
@@ -0,0 +1,32 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+
4
+ export const releaseWorkers = Effect.fn("EffectSql.releaseWorkers")(function* (
5
+ release: string,
6
+ staleMillis: number,
7
+ table = "effect_sql_migrations",
8
+ ) {
9
+ const sql = yield* SqlClient.SqlClient;
10
+ const processId = crypto.randomUUID();
11
+ const workers = `${table}_workers`;
12
+ return {
13
+ claim: sql.withTransaction(
14
+ Effect.gen(function* () {
15
+ const [owner] = yield* sql<{ release: string }>`select release from ${sql(`${table}_release`)} where id = true for update`.withoutTransform;
16
+ if (owner?.release !== release) {
17
+ return false;
18
+ }
19
+ const others = yield* sql`select process_id from ${sql(workers)} where release <> ${release}
20
+ and heartbeat > clock_timestamp() - ${staleMillis} * interval '1 millisecond'`;
21
+ if (others.length > 0) {
22
+ return false;
23
+ }
24
+ yield* sql`insert into ${sql(workers)} (process_id, release, heartbeat) values (${processId}, ${release}, clock_timestamp())
25
+ on conflict (process_id) do update set heartbeat = excluded.heartbeat`;
26
+ return true;
27
+ }),
28
+ ),
29
+ heartbeat: sql`update ${sql(workers)} set heartbeat = clock_timestamp() where process_id = ${processId}`.pipe(Effect.asVoid),
30
+ remove: sql`delete from ${sql(workers)} where process_id = ${processId}`.pipe(Effect.asVoid),
31
+ };
32
+ });
@@ -0,0 +1,147 @@
1
+ import { Data, Effect, type Schema } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import { SqlClient } from "effect/sql";
4
+ import { type BatchWrites, makeInsertMany } from "#batchWrites.ts";
5
+ import { makeCompositeCrud, type PrimaryKey, type Repository } from "#compositeRepository.ts";
6
+ import { type ConflictColumns, type ConflictWrites, makeConflictWrites } from "#conflictWrites.ts";
7
+ import { makeInsertFromSelect, type SelectWrites } from "#insertFromSelect.ts";
8
+ import { repositoryChanges } from "#repositoryChanges.ts";
9
+ import type { FindManyFilter, RepositoryFilter, RepositoryListFilter } from "#repositoryFilter.ts";
10
+ import { makeQueries, type Queries } from "#repositoryQueries.ts";
11
+ import { type IdColumn, makeVersionedWrites, type VersionColumn, type VersionedWrites } from "#versionedRepository.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
+ export interface OrderBy<TModel extends Model.Any> {
17
+ readonly direction: "asc" | "desc";
18
+ readonly field: Key<TModel>;
19
+ }
20
+
21
+ export interface FindMany<TModel extends Model.Any, TKey extends Key<TModel>> {
22
+ readonly limit?: number;
23
+ readonly orderBy?: OrderBy<TModel> | readonly [OrderBy<TModel>, ...OrderBy<TModel>[]];
24
+ readonly select?: readonly [TKey, ...TKey[]];
25
+ readonly where?: FindManyFilter<TModel>;
26
+ }
27
+
28
+ export interface LatestPerGroup<TModel extends Model.Any> {
29
+ readonly limit: number;
30
+ readonly orderBy: readonly [OrderBy<TModel>, ...OrderBy<TModel>[]];
31
+ readonly partitionBy: readonly [Key<TModel>, ...Key<TModel>[]];
32
+ readonly where?: RepositoryListFilter<TModel>;
33
+ }
34
+
35
+ export class OptimisticConcurrencyError extends Data.TaggedError("OptimisticConcurrencyError")<{
36
+ readonly tableName: string;
37
+ readonly id: unknown;
38
+ readonly expectedVersion: number;
39
+ readonly reason: "missing" | "stale";
40
+ }> {}
41
+
42
+ interface RepositoryOptions<TId> {
43
+ readonly idColumn: TId;
44
+ readonly spanPrefix: string;
45
+ readonly tableName: string;
46
+ }
47
+
48
+ type VersionedRepository<TModel extends Model.Any, TId extends PrimaryKey<TModel>> = Omit<
49
+ Repository<TModel, TId>,
50
+ keyof VersionedWrites<TModel, TId>
51
+ > &
52
+ VersionedWrites<TModel, TId> &
53
+ Queries<TModel>;
54
+
55
+ export function makeRepository<
56
+ TModel extends Model.Any,
57
+ const TId extends PrimaryKey<TModel>,
58
+ const TConflict extends ConflictColumns<TModel>,
59
+ const TWhere extends keyof RepositoryFilter<TModel> = never,
60
+ >(
61
+ model: TModel,
62
+ options: RepositoryOptions<TId> & {
63
+ readonly versionColumn: VersionColumn<TModel, TId>;
64
+ readonly conflictColumns: TConflict;
65
+ readonly conflictWhere?: Pick<RepositoryFilter<TModel>, TWhere>;
66
+ },
67
+ ): Effect.Effect<
68
+ VersionedRepository<TModel, TId> &
69
+ Pick<ConflictWrites<TModel, never, TModel["fields"][Extract<TWhere, keyof TModel["fields"]>]["EncodingServices"]>, "insertOrIgnore">,
70
+ never,
71
+ SqlClient.SqlClient
72
+ >;
73
+ export function makeRepository<TModel extends Model.Any, const TId extends PrimaryKey<TModel>>(
74
+ model: TModel,
75
+ options: RepositoryOptions<TId> & {
76
+ readonly versionColumn: VersionColumn<TModel, TId>;
77
+ readonly conflictColumns?: never;
78
+ readonly conflictWhere?: never;
79
+ },
80
+ ): Effect.Effect<VersionedRepository<TModel, TId>, never, SqlClient.SqlClient>;
81
+ export function makeRepository<
82
+ TModel extends Model.Any,
83
+ const TId extends PrimaryKey<TModel>,
84
+ const TConflict extends ConflictColumns<TModel>,
85
+ const TWhere extends keyof RepositoryFilter<TModel> = never,
86
+ >(
87
+ model: TModel,
88
+ options: RepositoryOptions<TId> & {
89
+ readonly versionColumn?: never;
90
+ readonly conflictColumns: TConflict;
91
+ readonly conflictWhere?: Pick<RepositoryFilter<TModel>, TWhere>;
92
+ },
93
+ ): Effect.Effect<
94
+ Repository<TModel, TId> &
95
+ BatchWrites<TModel> &
96
+ SelectWrites<TModel> &
97
+ Queries<TModel> &
98
+ ConflictWrites<
99
+ TModel,
100
+ TConflict[number] | (TId extends readonly PropertyKey[] ? TId[number] : TId),
101
+ TModel["fields"][Extract<TWhere, keyof TModel["fields"]>]["EncodingServices"]
102
+ >,
103
+ never,
104
+ SqlClient.SqlClient
105
+ >;
106
+ export function makeRepository<TModel extends Model.Any, const TId extends PrimaryKey<TModel>>(
107
+ model: TModel,
108
+ options: RepositoryOptions<TId> & { readonly versionColumn?: never; readonly conflictColumns?: never; readonly conflictWhere?: never },
109
+ ): Effect.Effect<Repository<TModel, TId> & BatchWrites<TModel> & SelectWrites<TModel> & Queries<TModel>, never, SqlClient.SqlClient>;
110
+ export function makeRepository<TModel extends Model.Any, TId extends IdColumn<TModel>>(
111
+ model: TModel,
112
+ options: RepositoryOptions<TId | readonly [TId, TId, ...TId[]]> & {
113
+ readonly versionColumn?: string;
114
+ readonly conflictColumns?: readonly string[];
115
+ readonly conflictWhere?: Readonly<Record<string, unknown>>;
116
+ },
117
+ ): Effect.Effect<unknown, never, SqlClient.SqlClient> {
118
+ return Effect.fnUntraced(function* () {
119
+ const sql = yield* SqlClient.SqlClient;
120
+ const composite = typeof options.idColumn === "object";
121
+ const idColumns = (composite ? options.idColumn : [options.idColumn]).map(String);
122
+ const changes = yield* repositoryChanges(sql, options.tableName, idColumns);
123
+ const crud = makeCompositeCrud(model, sql, { ...options, columns: idColumns }, changes);
124
+ const queries = makeQueries(model, sql, options);
125
+ return options.versionColumn === undefined
126
+ ? {
127
+ ...crud,
128
+ insertFromSelect: makeInsertFromSelect(model, sql, { ...options, changes }, "insertFromSelect"),
129
+ insertMany: makeInsertMany(model, sql, { ...options, changes }, "insertMany"),
130
+ ...(options.conflictColumns === undefined
131
+ ? {}
132
+ : makeConflictWrites(model, sql, { ...options, changes, conflictColumns: options.conflictColumns, idColumns })),
133
+ ...queries,
134
+ }
135
+ : {
136
+ ...crud,
137
+ ...makeVersionedWrites(model, sql, {
138
+ ...options,
139
+ changes,
140
+ conflict: (id, expectedVersion, reason) => new OptimisticConcurrencyError({ expectedVersion, id, reason, tableName: options.tableName }),
141
+ idColumns,
142
+ versionColumn: options.versionColumn,
143
+ }),
144
+ ...queries,
145
+ };
146
+ })();
147
+ }
@@ -0,0 +1,49 @@
1
+ import { Effect, Option } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import { SqlClient } from "effect/sql";
4
+ import { invalidateOnCommit, transact } from "#transact.ts";
5
+
6
+ const ITEM_KEY_LIMIT = 1000;
7
+
8
+ function encodeKeyValue(_key: string, value: unknown) {
9
+ return typeof value === "bigint" ? String(value) : value;
10
+ }
11
+
12
+ export type RepositoryChanges = Effect.Success<ReturnType<typeof repositoryChanges>>;
13
+
14
+ export const repositoryChanges = Effect.fnUntraced(function* (sql: SqlClient.SqlClient, table: string, columns: readonly string[]) {
15
+ const initial = yield* Effect.serviceOption(Reactivity.Reactivity);
16
+ const fallback = Option.isSome(initial) ? initial.value : yield* Reactivity.make;
17
+ function provide<A, E, R>(effect: Effect.Effect<A, E, R | Reactivity.Reactivity | SqlClient.SqlClient>) {
18
+ return Effect.flatMap(Effect.serviceOption(Reactivity.Reactivity), (current) =>
19
+ effect.pipe(
20
+ Effect.provideService(SqlClient.SqlClient, sql),
21
+ Effect.provideService(
22
+ Reactivity.Reactivity,
23
+ Option.getOrElse(current, () => fallback),
24
+ ),
25
+ ),
26
+ );
27
+ }
28
+ function record(rows: readonly Readonly<Record<string, unknown>>[]) {
29
+ if (rows.length === 0) {
30
+ return Effect.void;
31
+ }
32
+ if (rows.length > ITEM_KEY_LIMIT) {
33
+ return provide(invalidateOnCommit([table, `${table}:*`]));
34
+ }
35
+ const ids = rows.map((row) =>
36
+ columns.length === 1
37
+ ? row[columns[0] ?? ""]
38
+ : JSON.stringify(
39
+ columns.map((column) => row[column]),
40
+ encodeKeyValue,
41
+ ),
42
+ );
43
+ return provide(invalidateOnCommit({ [table]: ids }));
44
+ }
45
+ function write<A, E, R>(effect: Effect.Effect<A, E, R>) {
46
+ return provide(transact(effect, { onSqlError: (error) => error }));
47
+ }
48
+ return { record, write };
49
+ });
@@ -0,0 +1,108 @@
1
+ import { Effect, Schema, SchemaIssue } from "effect";
2
+ import type { Model } from "effect/schema";
3
+ import type { SqlClient } from "effect/sql";
4
+ import { type TupleFilter, tuplePredicate } from "#tupleIn.ts";
5
+
6
+ export type RepositoryFilter<TModel extends Model.Any> = {
7
+ readonly [TField in keyof Schema.Struct.Type<TModel["fields"]>]?: Schema.Struct.Type<TModel["fields"]>[TField] | undefined;
8
+ };
9
+
10
+ export type RepositoryListFilter<TModel extends Model.Any> = Partial<TupleFilter<TModel>> & {
11
+ readonly [TField in keyof Schema.Struct.Type<TModel["fields"]>]?:
12
+ | Schema.Struct.Type<TModel["fields"]>[TField]
13
+ | { readonly in: readonly Schema.Struct.Type<TModel["fields"]>[TField][] }
14
+ | undefined;
15
+ };
16
+
17
+ const Ordered = Schema.Union([Schema.String, Schema.Number, Schema.BigInt, Schema.Date, Schema.BigDecimal, Schema.DateTimeUtc]);
18
+ export type OrderedValue = typeof Ordered.Type;
19
+ export type Comparisons<T, TOperand = NonNullable<T>> = [NonNullable<T>] extends [OrderedValue]
20
+ ? { readonly gt?: TOperand; readonly gte?: TOperand; readonly lt?: TOperand; readonly lte?: TOperand }
21
+ : { readonly gt?: never; readonly gte?: never; readonly lt?: never; readonly lte?: never };
22
+ type Pattern<T> = [NonNullable<T>] extends [string] ? { readonly like?: NonNullable<T> } : { readonly like?: never };
23
+
24
+ export type FindManyFilter<TModel extends Model.Any> = Partial<TupleFilter<TModel>> & {
25
+ readonly [TField in keyof Schema.Struct.Type<TModel["fields"]>]?:
26
+ | Schema.Struct.Type<TModel["fields"]>[TField]
27
+ | (Comparisons<Schema.Struct.Type<TModel["fields"]>[TField]> & Pattern<Schema.Struct.Type<TModel["fields"]>[TField]>)
28
+ | { readonly in: readonly Schema.Struct.Type<TModel["fields"]>[TField][] }
29
+ | undefined;
30
+ };
31
+
32
+ const operators = { gt: ">", gte: ">=", like: "like", lt: "<", lte: "<=" } as const;
33
+ function isOperator(key: string): key is keyof typeof operators {
34
+ return Object.hasOwn(operators, key);
35
+ }
36
+
37
+ export function makeFilter<TModel extends Model.Any>(model: TModel, sql: SqlClient.SqlClient, references?: { readonly tableName: string }) {
38
+ function fieldAt(key: string) {
39
+ const field = Object.hasOwn(model.fields, key) ? model.fields[key] : undefined;
40
+ return field === undefined
41
+ ? Effect.fail(new Schema.SchemaError(new SchemaIssue.Pointer([key], new SchemaIssue.UnexpectedKey(model.ast, key))))
42
+ : Effect.succeed(field);
43
+ }
44
+ function column(key: string) {
45
+ return references === undefined ? sql`${sql(key)}` : sql`${sql(references.tableName)}.${sql(key)}`;
46
+ }
47
+ function reference(value: unknown) {
48
+ return references !== undefined && typeof value === "object" && value !== null && "excluded" in value && typeof value.excluded === "string"
49
+ ? value.excluded
50
+ : undefined;
51
+ }
52
+ function comparison(key: string, field: Schema.Constraint, operator: string, operand: unknown) {
53
+ if (!isOperator(operator)) {
54
+ return Effect.fail(new Schema.SchemaError(new SchemaIssue.Pointer([key, operator], new SchemaIssue.UnexpectedKey(model.ast, operator))));
55
+ }
56
+ const incoming = reference(operand);
57
+ if (incoming !== undefined) {
58
+ return Effect.as(fieldAt(incoming), sql`${column(key)} ${sql.literal(operators[operator])} excluded.${sql(incoming)}`);
59
+ }
60
+ const validate = Schema.decodeUnknownEffect(operator === "like" ? Schema.String : Ordered);
61
+ return Effect.flatMap(validate(operand), (decoded) =>
62
+ Effect.map(Schema.encodeEffect(field)(decoded), (encoded) => sql`${column(key)} ${sql.literal(operators[operator])} ${encoded}`),
63
+ );
64
+ }
65
+ function inPredicate(key: string, encoded: readonly unknown[]) {
66
+ if (references === undefined) {
67
+ return sql.in(key, encoded);
68
+ }
69
+ return encoded.length === 0 ? sql`1 = 0` : sql`${column(key)} in ${sql.in(encoded)}`;
70
+ }
71
+ function predicate(key: string, value: unknown, allowIn: boolean, allowComparisons: boolean) {
72
+ return Effect.flatMap(fieldAt(key), (field) => {
73
+ const incoming = reference(value);
74
+ if (incoming !== undefined) {
75
+ return Effect.as(fieldAt(incoming), sql`${column(key)} = excluded.${sql(incoming)}`);
76
+ }
77
+ const encode = Schema.encodeEffect(field);
78
+ if (allowComparisons && typeof value === "object" && value !== null && Object.keys(value).some(isOperator)) {
79
+ return Effect.map(
80
+ Effect.forEach(Object.entries(value), ([operator, operand]) => comparison(key, field, operator, operand)),
81
+ (predicates) => sql.and(predicates),
82
+ );
83
+ }
84
+ return allowIn && typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in)
85
+ ? Effect.map(
86
+ Effect.forEach(value.in, (entry) => encode(entry)),
87
+ (encoded) => inPredicate(key, encoded),
88
+ )
89
+ : Effect.map(encode(value), (encoded) => (encoded === null ? sql`${column(key)} is null` : sql`${column(key)} = ${encoded}`));
90
+ });
91
+ }
92
+ function where(input: Readonly<Record<string, unknown>> & Partial<TupleFilter<Model.Any>> = {}, allowIn = false, allowComparisons = false) {
93
+ return Effect.gen(function* () {
94
+ const predicates = yield* Effect.forEach(
95
+ Object.entries(input).filter(([, value]) => value !== undefined),
96
+ ([key, value]) => predicate(key, value, allowIn, allowComparisons),
97
+ );
98
+ if (allowIn) {
99
+ const tuple = yield* tuplePredicate(input, sql, fieldAt);
100
+ if (tuple !== undefined) {
101
+ predicates.push(tuple);
102
+ }
103
+ }
104
+ return sql.and(predicates);
105
+ });
106
+ }
107
+ return { fieldAt, where };
108
+ }