@shivaedev/sql 0.33.0 → 0.34.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +102 -9
  3. package/dist/MigrationStartedAt.d.ts.map +1 -0
  4. package/dist/{internal/MigrationStartedAt.js → MigrationStartedAt.js} +1 -1
  5. package/dist/MigrationStartedAt.js.map +1 -0
  6. package/dist/batchWrites.d.ts +1 -1
  7. package/dist/batchWrites.d.ts.map +1 -1
  8. package/dist/batchWrites.js.map +1 -1
  9. package/dist/batchedDelete.d.ts +9 -2
  10. package/dist/batchedDelete.d.ts.map +1 -1
  11. package/dist/batchedDelete.js +34 -35
  12. package/dist/batchedDelete.js.map +1 -1
  13. package/dist/changes.d.ts +8 -0
  14. package/dist/changes.d.ts.map +1 -0
  15. package/dist/changes.js +7 -0
  16. package/dist/changes.js.map +1 -0
  17. package/dist/deleteGroupsBatched.d.ts +13 -0
  18. package/dist/deleteGroupsBatched.d.ts.map +1 -0
  19. package/dist/deleteGroupsBatched.js +23 -0
  20. package/dist/deleteGroupsBatched.js.map +1 -0
  21. package/dist/insertFromSelect.d.ts +1 -1
  22. package/dist/insertFromSelect.d.ts.map +1 -1
  23. package/dist/insertFromSelect.js.map +1 -1
  24. package/dist/invalidationKeys.d.ts +8 -0
  25. package/dist/invalidationKeys.d.ts.map +1 -0
  26. package/dist/invalidationKeys.js +15 -0
  27. package/dist/invalidationKeys.js.map +1 -0
  28. package/dist/keysetPage.d.ts +26 -0
  29. package/dist/keysetPage.d.ts.map +1 -0
  30. package/dist/keysetPage.js +23 -0
  31. package/dist/keysetPage.js.map +1 -0
  32. package/dist/makeConflictClause.d.ts +1 -1
  33. package/dist/makeConflictClause.d.ts.map +1 -1
  34. package/dist/makeConflictClause.js +24 -2
  35. package/dist/makeConflictClause.js.map +1 -1
  36. package/dist/migrationLock.d.ts.map +1 -0
  37. package/dist/migrationLock.js.map +1 -0
  38. package/dist/migrations.d.ts +1 -1
  39. package/dist/migrations.d.ts.map +1 -1
  40. package/dist/migrations.js +18 -9
  41. package/dist/migrations.js.map +1 -1
  42. package/dist/postgresChanges.d.ts.map +1 -1
  43. package/dist/postgresChanges.js +8 -4
  44. package/dist/postgresChanges.js.map +1 -1
  45. package/dist/postgresLayer.d.ts +2 -1
  46. package/dist/postgresLayer.d.ts.map +1 -1
  47. package/dist/postgresLayer.js +1 -1
  48. package/dist/postgresLayer.js.map +1 -1
  49. package/dist/postgresRelease.js +1 -1
  50. package/dist/postgresRelease.js.map +1 -1
  51. package/dist/repositoryChanges.d.ts.map +1 -1
  52. package/dist/repositoryChanges.js +4 -3
  53. package/dist/repositoryChanges.js.map +1 -1
  54. package/dist/transact.d.ts +13 -5
  55. package/dist/transact.d.ts.map +1 -1
  56. package/dist/transact.js +45 -19
  57. package/dist/transact.js.map +1 -1
  58. package/dist/versionedStatements.d.ts +1 -1
  59. package/package.json +7 -6
  60. package/src/{internal/MigrationStartedAt.ts → MigrationStartedAt.ts} +1 -1
  61. package/src/batchWrites.ts +2 -1
  62. package/src/batchedDelete.ts +53 -40
  63. package/src/changes.ts +12 -0
  64. package/src/deleteGroupsBatched.ts +41 -0
  65. package/src/insertFromSelect.ts +1 -1
  66. package/src/invalidationKeys.ts +24 -0
  67. package/src/keysetPage.ts +54 -0
  68. package/src/makeConflictClause.ts +31 -2
  69. package/src/migrations.ts +21 -10
  70. package/src/postgresChanges.ts +13 -4
  71. package/src/postgresLayer.ts +1 -1
  72. package/src/postgresRelease.ts +1 -1
  73. package/src/repositoryChanges.ts +4 -3
  74. package/src/transact.ts +61 -24
  75. package/dist/internal/CommandRecovery.d.ts +0 -3
  76. package/dist/internal/CommandRecovery.d.ts.map +0 -1
  77. package/dist/internal/CommandRecovery.js +0 -5
  78. package/dist/internal/CommandRecovery.js.map +0 -1
  79. package/dist/internal/MigrationStartedAt.d.ts.map +0 -1
  80. package/dist/internal/MigrationStartedAt.js.map +0 -1
  81. package/dist/internal/migrationLock.d.ts.map +0 -1
  82. package/dist/internal/migrationLock.js.map +0 -1
  83. package/src/internal/CommandRecovery.ts +0 -5
  84. /package/dist/{internal/MigrationStartedAt.d.ts → MigrationStartedAt.d.ts} +0 -0
  85. /package/dist/{internal/migrationLock.d.ts → migrationLock.d.ts} +0 -0
  86. /package/dist/{internal/migrationLock.js → migrationLock.js} +0 -0
  87. /package/src/{internal/migrationLock.ts → migrationLock.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/sql",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Schema-derived repositories over native Effect SQL",
5
5
  "keywords": [
6
6
  "effect"
@@ -43,17 +43,18 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@shivaedev/changes": "0.8.0",
46
+ "@shivaedev/changes": "0.9.0",
47
47
  "@shivaedev/service": "0.6.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@effect/platform-node": "4.0.6",
51
51
  "@effect/sql-pg": "4.0.6",
52
52
  "@effect/sql-sqlite-node": "4.0.6",
53
- "@shivaedev/jobs": "0.30.0",
54
- "@shivaedev/quality": "0.56.0",
55
- "@shivaedev/test-sql": "0.16.2",
56
- "@shivaedev/test-story": "0.34.0",
53
+ "@shivaedev/contract": "0.32.0",
54
+ "@shivaedev/jobs": "0.31.0",
55
+ "@shivaedev/quality": "0.57.0",
56
+ "@shivaedev/test-sql": "0.17.0",
57
+ "@shivaedev/test-story": "0.35.0",
57
58
  "@types/node": "24.19.2",
58
59
  "effect": "4.0.6",
59
60
  "typescript": "7.0.2",
@@ -1,5 +1,5 @@
1
1
  import { Context } from "effect";
2
2
 
3
- export const MigrationStartedAt = Context.Reference<string | undefined>("@shivaedev/sql/internal/MigrationStartedAt", {
3
+ export const MigrationStartedAt = Context.Reference<string | undefined>("@shivaedev/sql/MigrationStartedAt", {
4
4
  defaultValue: () => undefined,
5
5
  });
@@ -19,7 +19,8 @@ export function makeInsertMany<TServices = never>(
19
19
  sql: SqlClient.SqlClient,
20
20
  options: { readonly tableName: string; readonly spanPrefix: string; readonly changes: RepositoryChanges },
21
21
  operation: string,
22
- prepare: (row: Readonly<Record<string, unknown>>) => Effect.Effect<Fragment, Schema.SchemaError, TServices> = () => Effect.succeed(sql``),
22
+ prepare: (row: Readonly<Record<string, unknown>>) => Effect.Effect<Fragment, SqlError | Schema.SchemaError, TServices> = () =>
23
+ Effect.succeed(sql``),
23
24
  upsert = false,
24
25
  ) {
25
26
  const encode = Schema.encodeEffect(model.insert);
@@ -7,27 +7,62 @@ 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 BatchedDeleteOptions<TModel extends Model.Any> {
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 },
@@ -35,14 +70,6 @@ export const makeBatchedDelete = Effect.fnUntraced(function* (
35
70
  ) {
36
71
  const sql = yield* SqlClient.SqlClient;
37
72
  const { where } = makeFilter(model, sql);
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
- });
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* available;
83
- const { maxRows } = yield* Schema.decodeUnknownEffect(Limits)(options);
84
- const predicate = yield* where(options.where, true, true);
85
- if (maxRows === 0) {
86
- return 0;
87
- }
88
- if (!(yield* tryPurgeLock(tableName).pipe(Effect.provideService(SqlClient.SqlClient, sql)))) {
89
- return 0;
90
- }
91
- return yield* runBatches(options, predicate);
92
- }).pipe(Effect.scoped, Effect.withSpan(`${spanPrefix}.deleteBatched`));
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
+ }
@@ -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
+ });
@@ -29,7 +29,7 @@ export function makeInsertFromSelect<TServices = never>(
29
29
  sql: SqlClient.SqlClient,
30
30
  options: { readonly tableName: string; readonly spanPrefix: string; readonly changes: RepositoryChanges },
31
31
  operation: string,
32
- prepare: (columns: readonly string[]) => Effect.Effect<Fragment, Schema.SchemaError, TServices> = () => Effect.succeed(sql``),
32
+ prepare: (columns: readonly string[]) => Effect.Effect<Fragment, SqlError | Schema.SchemaError, TServices> = () => Effect.succeed(sql``),
33
33
  ) {
34
34
  const decode = Schema.decodeUnknownEffect(Schema.Array(model));
35
35
  return (source: RepositorySelect<Schema.Struct.Fields, unknown>, columns: Readonly<Record<string, string>>) =>
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import { Effect, Schema, SchemaAST, SchemaIssue } from "effect";
1
+ import { Array as Arr, Effect, Schema, SchemaAST, SchemaIssue } from "effect";
2
2
  import type { Model } from "effect/schema";
3
3
  import type { SqlClient } from "effect/sql";
4
4
  import { makeFilter } from "#repositoryFilter.ts";
@@ -44,6 +44,35 @@ export function makeConflictClause(
44
44
  }
45
45
  return sql`${sql(column)} = ${value}`;
46
46
  }
47
+ function encodePredicate([key, value]: readonly [string, unknown]) {
48
+ return Effect.gen(function* () {
49
+ const field = yield* filter.fieldAt(key);
50
+ const encoded = yield* Schema.encodeEffect(field)(value);
51
+ return { encoded, key, nullish: value === null || encoded === null };
52
+ });
53
+ }
54
+ function postgresPredicate(input: Readonly<Record<string, unknown>>) {
55
+ return Effect.gen(function* () {
56
+ const entries = yield* Effect.forEach(
57
+ Object.entries(input).filter(([, value]) => value !== undefined),
58
+ encodePredicate,
59
+ );
60
+ if (entries.length === 0) {
61
+ return sql.and([]);
62
+ }
63
+ // PostgreSQL must see constants to infer a partial index under generic plans.
64
+ const [quoted] = yield* sql`select ${sql.csv(entries.map(({ encoded }) => sql`quote_nullable(${encoded}::text)`))}`.values;
65
+ const literals = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.String))(quoted);
66
+ return sql.and(
67
+ Arr.zip(entries, literals).map(([{ key, nullish }, literal]) =>
68
+ nullish ? sql`${sql(key)} is null` : sql`${sql(key)} = ${sql.literal(literal)}`,
69
+ ),
70
+ );
71
+ });
72
+ }
73
+ function conflictPredicate(input: Readonly<Record<string, unknown>>) {
74
+ return sql.onDialectOrElse({ orElse: () => filter.where(input), pg: () => postgresPredicate(input) });
75
+ }
47
76
  function clause(
48
77
  encoded: Readonly<Record<string, unknown>>,
49
78
  updateColumns?: readonly UpsertAssignment[],
@@ -59,7 +88,7 @@ export function makeConflictClause(
59
88
  );
60
89
  }
61
90
  const conflict = sql.csv(conflictColumns.map((column) => sql`${sql(column)}`));
62
- const where = options.conflictWhere === undefined ? sql`` : sql`where ${yield* filter.where(options.conflictWhere)}`;
91
+ const where = options.conflictWhere === undefined ? sql`` : sql`where ${yield* conflictPredicate(options.conflictWhere)}`;
63
92
  const action = updateColumns === undefined ? sql`do nothing` : sql`do update set ${sql.csv(updateColumns.map(assignment))}`;
64
93
  const condition = updateWhere === undefined ? sql`` : sql`where ${yield* updateFilter.where(updateWhere, true, true)}`;
65
94
  return sql`on conflict (${conflict}) ${where} ${action} ${condition}`;
package/src/migrations.ts CHANGED
@@ -2,12 +2,12 @@ import { Duration, Effect, Option, Schema } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { Migrator, SqlClient } from "effect/sql";
4
4
  import type { SqlError } from "effect/sql/SqlError";
5
- import { MigrationStartedAt } from "#internal/MigrationStartedAt.ts";
6
- import { migrationLock } from "#internal/migrationLock.ts";
7
- import { advisoryXactLock } from "./advisoryXactLock.ts";
8
- import { postgresRelease } from "./postgresRelease.ts";
9
- import { runSchemaComponent, type SchemaComponent } from "./SchemaComponent.ts";
10
- import { transact } from "./transact.ts";
5
+ import { advisoryXactLock } from "#advisoryXactLock.ts";
6
+ import { MigrationStartedAt } from "#MigrationStartedAt.ts";
7
+ import { migrationLock } from "#migrationLock.ts";
8
+ import { postgresRelease } from "#postgresRelease.ts";
9
+ import { runSchemaComponent, type SchemaComponent } from "#SchemaComponent.ts";
10
+ import { transact } from "#transact.ts";
11
11
 
12
12
  export interface PostgresMigrationOptions<R, E = never> {
13
13
  readonly components?: readonly SchemaComponent<R, E>[];
@@ -52,12 +52,12 @@ export const migratePostgres = Effect.fn("EffectSql.migratePostgres")(function*
52
52
  created_at timestamp with time zone not null default now(),
53
53
  name text not null
54
54
  )`;
55
- const applied = yield* sql`select migration_id as id from ${sql(table)}`.withoutTransform.pipe(
56
- Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.Int })))),
55
+ const applied = yield* sql`select migration_id as id, name from ${sql(table)}`.withoutTransform.pipe(
56
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.Int, name: Schema.String })))),
57
57
  );
58
- const appliedIds = new Set(applied.map(({ id }) => id));
58
+ const appliedNames = new Map(applied.map(({ id, name }) => [id, name]));
59
59
  const latest = applied.reduce((max, { id }) => Math.max(max, id), 0);
60
- const missing = current.filter(([id]) => id <= latest && !appliedIds.has(id));
60
+ const missing = current.filter(([id]) => id <= latest && !appliedNames.has(id));
61
61
  if (missing.length > 0) {
62
62
  return yield* Effect.fail(
63
63
  new Migrator.MigrationError({
@@ -66,6 +66,17 @@ export const migratePostgres = Effect.fn("EffectSql.migratePostgres")(function*
66
66
  }),
67
67
  );
68
68
  }
69
+ for (const [id, name] of current) {
70
+ const recorded = appliedNames.get(id);
71
+ if (recorded !== undefined && recorded !== name) {
72
+ return yield* Effect.fail(
73
+ new Migrator.MigrationError({
74
+ kind: "BadState",
75
+ message: `Migration ${id} is recorded as ${JSON.stringify(recorded)} but loaded as ${JSON.stringify(name)}`,
76
+ }),
77
+ );
78
+ }
79
+ }
69
80
  return yield* migrate({ loader: Effect.succeed(current), table });
70
81
  }).pipe(transact({ onSqlError: (error) => error }), Effect.provideService(Reactivity.Reactivity, reactivity));
71
82
  const schemaComponents: readonly SchemaComponent<R, E | SqlError | Schema.SchemaError>[] = [
@@ -2,10 +2,10 @@ import { Context, type Duration, Effect, Layer, type Queue, type Scope } from "e
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { SqlClient } from "effect/sql";
4
4
  import type { SqlError } from "effect/sql/SqlError";
5
- import { CommandRecovery } from "#internal/CommandRecovery.ts";
5
+ import { CommandPublication, CommandPublicationError, type CommandPublisher } from "@shivaedev/changes/commandPublication.ts";
6
6
  import { listenForChanges } from "./listenForChanges.ts";
7
7
  import { encodeKeys, resyncPayload } from "./notifyPayload.ts";
8
- import { BeforeCommit, CommittedSink } from "./transact.ts";
8
+ import { BeforeCommit, CommittedSink, invalidateOnCommit, transact } from "./transact.ts";
9
9
 
10
10
  export interface PostgresNotification {
11
11
  readonly payload: string;
@@ -48,10 +48,19 @@ export function postgresChanges(options: PostgresChangesOptions) {
48
48
  }
49
49
  const recover = options.resync.pipe(
50
50
  Effect.andThen(options.client`select pg_notify(${options.channel}, ${resyncPayload})`),
51
- Effect.catchCause((cause) => Effect.logError("Committed command Resync broadcast failed; other hosts may remain stale", cause)),
51
+ Effect.catchCause((cause) => Effect.fail(new CommandPublicationError({ cause }))),
52
52
  Effect.asVoid,
53
53
  );
54
- return Context.make(BeforeCommit, notify).pipe(Context.add(CommittedSink, committed), Context.add(CommandRecovery, recover));
54
+ const publication: CommandPublisher = {
55
+ publish: (keys) =>
56
+ invalidateOnCommit(keys).pipe(
57
+ transact({ onSqlError: (error) => error }),
58
+ Effect.provideService(SqlClient.SqlClient, options.client),
59
+ Effect.catchCause((cause) => Effect.fail(new CommandPublicationError({ cause }))),
60
+ ),
61
+ recover,
62
+ };
63
+ return Context.make(BeforeCommit, notify).pipe(Context.add(CommittedSink, committed), Context.add(CommandPublication, publication));
55
64
  }),
56
65
  );
57
66
  }
@@ -26,5 +26,5 @@ export function postgresLayer(url: Redacted.Redacted<string>, options?: Postgres
26
26
  const client = yield* makePostgresClient(config).pipe(Effect.provideService(PgPool.PgPool, pool));
27
27
  return Context.make(PgPool.PgPool, pool).pipe(Context.add(PgClient.PgClient, client), Context.add(SqlClient.SqlClient, client));
28
28
  }),
29
- ).pipe(Layer.provide(Reactivity.layer));
29
+ ).pipe(Layer.provideMerge(Layer.fresh(Reactivity.layer)));
30
30
  }
@@ -1,6 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { SqlClient } from "effect/sql";
3
- import { MigrationStartedAt } from "#internal/MigrationStartedAt.ts";
3
+ import { MigrationStartedAt } from "#MigrationStartedAt.ts";
4
4
  import type { SchemaComponent } from "./SchemaComponent.ts";
5
5
 
6
6
  export const readPostgresRelease = Effect.fn("EffectSql.readPostgresRelease")(function* (table = "effect_sql_migrations") {
@@ -2,7 +2,7 @@ import { Effect, Option, type Schema } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { SqlClient } from "effect/sql";
4
4
  import { declaredBuckets } from "@shivaedev/changes/buckets.ts";
5
- import { compositeId, invalidationKeys, type Key, recordKey } from "@shivaedev/changes/keys.ts";
5
+ import { collectionInvalidationKeys, compositeId, invalidationKeys, type Key, recordKey } from "@shivaedev/changes/keys.ts";
6
6
  import { ChangeDeclarations } from "#ChangeDeclarations.ts";
7
7
  import { invalidateOnCommit, transact } from "#transact.ts";
8
8
 
@@ -71,8 +71,9 @@ export const repositoryChanges = Effect.fnUntraced(function* (
71
71
  return;
72
72
  }
73
73
  const declarations = yield* ChangeDeclarations;
74
- yield* declarations.declare(table, [table], rows);
75
- yield* provide(invalidateOnCommit([table]));
74
+ const keys = collectionInvalidationKeys(table);
75
+ yield* declarations.declare(table, keys, rows);
76
+ yield* provide(invalidateOnCommit(keys));
76
77
  });
77
78
  return { record, recordTable, returning, write };
78
79
  });
package/src/transact.ts CHANGED
@@ -1,45 +1,78 @@
1
- import { Effect } from "effect";
1
+ import { Context, Effect } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import type { ReadonlyRecord } from "effect/Record";
4
4
  import * as Reactivity from "effect/reactivity/Reactivity";
5
5
  import { SqlClient } from "effect/sql";
6
6
  import { isSqlError, type SqlError } from "effect/sql/SqlError";
7
- import { makeChannel } from "@shivaedev/changes/channel.ts";
8
- import { recordKey } from "@shivaedev/changes/keys.ts";
7
+ import { type BeforeCommit as CommitHook, makeChannel } from "@shivaedev/changes/channel.ts";
8
+ import { type Observation, type Observer, unobserved } from "@shivaedev/changes/observe.ts";
9
+ import type { Publish } from "@shivaedev/changes/publish.ts";
10
+ import { flatten, type Invalidation, keysOf } from "#invalidationKeys.ts";
9
11
 
10
12
  export type InvalidationKeys = readonly unknown[] | ReadonlyRecord<string, readonly unknown[]>;
11
13
 
12
- function isList(keys: InvalidationKeys): keys is readonly unknown[] {
13
- return Array.isArray(keys);
14
+ export interface AnnouncementOptions {
15
+ readonly publication?: "local" | "shared";
14
16
  }
15
17
 
16
- function flatten(keys: InvalidationKeys): readonly unknown[] {
17
- return isList(keys) ? keys : Object.entries(keys).flatMap(([name, ids]) => [name, ...ids.map((id) => recordKey(name, id))]);
18
+ type Services = SqlClient.SqlClient | Reactivity.Reactivity;
19
+
20
+ export const ChangeObserver = Context.Reference<Observer<unknown>>("@shivaedev/sql/ChangeObserver", { defaultValue: () => unobserved });
21
+ export const BeforeCommit = Context.Reference<CommitHook<unknown, SqlError, Services>>("@shivaedev/sql/BeforeCommit", {
22
+ defaultValue: () => () => Effect.void,
23
+ });
24
+ export const CommittedSink = Context.Reference<Publish<unknown, Services> | undefined>("@shivaedev/sql/CommittedSink", {
25
+ defaultValue: () => undefined,
26
+ });
27
+
28
+ function observe(event: Observation<Invalidation>) {
29
+ return Effect.flatMap(ChangeObserver, (observer) => observer({ ...event, changes: keysOf(event.changes) }));
30
+ }
31
+
32
+ function invalidate(keys: readonly unknown[]) {
33
+ return Reactivity.Reactivity.use((reactivity) => Effect.sync(() => reactivity.invalidateUnsafe(keys)));
18
34
  }
19
35
 
20
- const guard = Effect.fn("Transact.guard")(function* () {
21
- const sql = yield* SqlClient.SqlClient;
22
- const native = yield* Effect.serviceOption(sql.transactionService);
23
- if (native._tag === "Some") {
24
- return yield* Effect.die(
25
- new Error("transact cannot announce changes after a transaction it does not own; begin the outer transaction with transact"),
26
- );
36
+ const committed = Effect.fnUntraced(function* (changes: readonly Invalidation[]) {
37
+ const sink = yield* CommittedSink;
38
+ if (sink === undefined) {
39
+ return yield* invalidate(keysOf(changes));
40
+ }
41
+ const shared = keysOf(changes.filter((change) => change.publication === "shared"));
42
+ const sharedKeys = new Set(shared);
43
+ const local = keysOf(changes.filter((change) => change.publication === "local" && !sharedKeys.has(change.key)));
44
+ if (local.length > 0) {
45
+ yield* invalidate(local);
46
+ }
47
+ if (shared.length > 0) {
48
+ yield* sink(shared);
27
49
  }
28
50
  });
29
51
 
30
- const invalidations = makeChannel<unknown, SqlClient.SqlClient | Reactivity.Reactivity, SqlError>({
52
+ const invalidations = makeChannel<Invalidation, Services, SqlError>({
53
+ beforeCommit: (changes) => {
54
+ const shared = keysOf(changes.filter((change) => change.publication === "shared"));
55
+ return shared.length === 0 ? Effect.void : Effect.flatMap(BeforeCommit, (hook) => hook(shared));
56
+ },
57
+ key: (change) => (change.publication === "shared" ? change.key : change),
31
58
  name: "@shivaedev/sql/transact",
32
59
  owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
33
- publish: (keys) => Reactivity.Reactivity.use((reactivity) => Effect.sync(() => reactivity.invalidateUnsafe(keys))),
34
- unowned: guard(),
60
+ publish: (changes) => invalidate(keysOf(changes)),
61
+ unowned: Effect.gen(function* () {
62
+ const sql = yield* SqlClient.SqlClient;
63
+ const native = yield* Effect.serviceOption(sql.transactionService);
64
+ if (native._tag === "Some") {
65
+ return yield* Effect.die(
66
+ new Error("transact cannot announce changes after a transaction it does not own; begin the outer transaction with transact"),
67
+ );
68
+ }
69
+ }),
35
70
  });
36
71
 
37
- export const ChangeObserver = invalidations.Observer;
38
- export const BeforeCommit = invalidations.BeforeCommit;
39
- export const CommittedSink = invalidations.CommittedSink;
40
-
41
- export const invalidateOnCommit = Effect.fn("Transact.invalidateOnCommit")(function* (keys: InvalidationKeys) {
42
- yield* invalidations.record(flatten(keys));
72
+ export const invalidateOnCommit = Effect.fn("Transact.invalidateOnCommit")(function* (keys: InvalidationKeys, options?: AnnouncementOptions) {
73
+ yield* invalidations
74
+ .record(flatten(keys).map((key) => ({ key, publication: options?.publication ?? "shared" })))
75
+ .pipe(Effect.provideService(invalidations.Observer, observe));
43
76
  });
44
77
 
45
78
  export interface TransactOptions<TError> {
@@ -65,6 +98,10 @@ export const transact: {
65
98
  ? sql.withTransaction(body)
66
99
  : boundary.restore(sql.withTransaction(Effect.tap(body, () => sql.afterCommit(boundary.committed)))),
67
100
  )(effect)
68
- .pipe(Effect.catchIf(isSqlError, (error) => Effect.fail(options.onSqlError(error))));
101
+ .pipe(
102
+ Effect.provideService(invalidations.Observer, observe),
103
+ Effect.provideService(invalidations.CommittedSink, committed),
104
+ Effect.catchIf(isSqlError, (error) => Effect.fail(options.onSqlError(error))),
105
+ );
69
106
  }),
70
107
  );
@@ -1,3 +0,0 @@
1
- import { Context, Effect } from "effect";
2
- export declare const CommandRecovery: Context.Reference<Effect.Effect<void, never, never>>;
3
- //# sourceMappingURL=CommandRecovery.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"CommandRecovery.d.ts","sourceRoot":"","sources":["../../src/internal/CommandRecovery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,eAAO,MAAM,eAAe,sDAE1B,CAAC"}
@@ -1,5 +0,0 @@
1
- import { Context, Effect } from "effect";
2
- export const CommandRecovery = Context.Reference("@shivaedev/sql/CommandRecovery", {
3
- defaultValue: () => Effect.void,
4
- });
5
- //# sourceMappingURL=CommandRecovery.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"CommandRecovery.js","sourceRoot":"","sources":["../../src/internal/CommandRecovery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,MAAM,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,SAAS,CAAsB,gCAAgC,EAAE;IACvG,YAAY,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI;CAC/B,CAAC,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"MigrationStartedAt.d.ts","sourceRoot":"","sources":["../../src/internal/MigrationStartedAt.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,eAAO,MAAM,kBAAkB,uCAE7B,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"MigrationStartedAt.js","sourceRoot":"","sources":["../../src/internal/MigrationStartedAt.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,MAAM,CAAC,MAAM,kBAAkB,GAAG,OAAO,CAAC,SAAS,CAAqB,4CAA4C,EAAE;IACrH,YAAY,EAAE,GAAG,EAAE,CAAC,SAAS;CAC7B,CAAC,CAAC"}