@shivaedev/platform 0.25.1 → 0.28.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 (81) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +76 -3
  3. package/dist/http-client/body.d.ts +7 -0
  4. package/dist/http-client/body.d.ts.map +1 -0
  5. package/dist/http-client/body.js +32 -0
  6. package/dist/http-client/body.js.map +1 -0
  7. package/dist/http-client/errors.d.ts +51 -0
  8. package/dist/http-client/errors.d.ts.map +1 -0
  9. package/dist/http-client/errors.js +25 -0
  10. package/dist/http-client/errors.js.map +1 -0
  11. package/dist/http-client/filterStatus.d.ts +4 -0
  12. package/dist/http-client/filterStatus.d.ts.map +1 -0
  13. package/dist/http-client/filterStatus.js +38 -0
  14. package/dist/http-client/filterStatus.js.map +1 -0
  15. package/dist/http-client/nextLink.d.ts +2 -0
  16. package/dist/http-client/nextLink.d.ts.map +1 -0
  17. package/dist/http-client/nextLink.js +11 -0
  18. package/dist/http-client/nextLink.js.map +1 -0
  19. package/dist/http-client/paginate.d.ts +5 -0
  20. package/dist/http-client/paginate.d.ts.map +1 -0
  21. package/dist/http-client/paginate.js +31 -0
  22. package/dist/http-client/paginate.js.map +1 -0
  23. package/dist/http-client/parseRetryAfter.d.ts +3 -0
  24. package/dist/http-client/parseRetryAfter.d.ts.map +1 -0
  25. package/dist/http-client/parseRetryAfter.js +17 -0
  26. package/dist/http-client/parseRetryAfter.js.map +1 -0
  27. package/dist/rpc/publicConfig.d.ts +5 -9
  28. package/dist/rpc/publicConfig.d.ts.map +1 -1
  29. package/dist/server/MigrationOptions.d.ts +2 -0
  30. package/dist/server/MigrationOptions.d.ts.map +1 -1
  31. package/dist/server/appHost.d.ts +1 -1
  32. package/dist/server/better-auth/mount.d.ts +2 -2
  33. package/dist/server/better-auth/mount.d.ts.map +1 -1
  34. package/dist/server/better-auth/mount.js +2 -2
  35. package/dist/server/better-auth/mount.js.map +1 -1
  36. package/dist/server/better-auth/testing.d.ts +20 -3
  37. package/dist/server/better-auth/testing.d.ts.map +1 -1
  38. package/dist/server/better-auth/testing.js +17 -2
  39. package/dist/server/better-auth/testing.js.map +1 -1
  40. package/dist/server/better-auth/userProfiles.d.ts +14 -0
  41. package/dist/server/better-auth/userProfiles.d.ts.map +1 -0
  42. package/dist/server/better-auth/userProfiles.js +20 -0
  43. package/dist/server/better-auth/userProfiles.js.map +1 -0
  44. package/dist/server/databaseLayer.d.ts.map +1 -1
  45. package/dist/server/databaseLayer.js +42 -10
  46. package/dist/server/databaseLayer.js.map +1 -1
  47. package/dist/server/hostChanges.d.ts.map +1 -1
  48. package/dist/server/hostChanges.js +4 -2
  49. package/dist/server/hostChanges.js.map +1 -1
  50. package/dist/server/rpc-server/local.d.ts.map +1 -1
  51. package/dist/server/rpc-server/local.js +3 -2
  52. package/dist/server/rpc-server/local.js.map +1 -1
  53. package/dist/server/rpc-server/proxyRanges.d.ts +4 -0
  54. package/dist/server/rpc-server/proxyRanges.d.ts.map +1 -0
  55. package/dist/server/rpc-server/proxyRanges.js +62 -0
  56. package/dist/server/rpc-server/proxyRanges.js.map +1 -0
  57. package/dist/server/rpc-server/trustedProxy.d.ts.map +1 -1
  58. package/dist/server/rpc-server/trustedProxy.js +2 -58
  59. package/dist/server/rpc-server/trustedProxy.js.map +1 -1
  60. package/dist/server/rpc-server/trustedProxyRanges.d.ts +2 -0
  61. package/dist/server/rpc-server/trustedProxyRanges.d.ts.map +1 -0
  62. package/dist/server/rpc-server/trustedProxyRanges.js +5 -0
  63. package/dist/server/rpc-server/trustedProxyRanges.js.map +1 -0
  64. package/package.json +13 -13
  65. package/src/http-client/body.ts +37 -0
  66. package/src/http-client/errors.ts +23 -0
  67. package/src/http-client/filterStatus.ts +38 -0
  68. package/src/http-client/nextLink.ts +10 -0
  69. package/src/http-client/paginate.ts +39 -0
  70. package/src/http-client/parseRetryAfter.ts +17 -0
  71. package/src/server/MigrationOptions.ts +2 -0
  72. package/src/server/better-auth/mount.ts +6 -3
  73. package/src/server/better-auth/testing.ts +28 -2
  74. package/src/server/better-auth/userProfiles.ts +37 -0
  75. package/src/server/databaseLayer.ts +52 -12
  76. package/src/server/hostChanges.ts +4 -3
  77. package/src/server/rpc-server/local.ts +4 -2
  78. package/src/server/rpc-server/proxyRanges.ts +61 -0
  79. package/src/server/rpc-server/trustedProxy.ts +3 -57
  80. package/src/server/rpc-server/trustedProxyRanges.ts +5 -0
  81. package/src/server/testing/makePlatformIt.ts +1 -1
@@ -0,0 +1,37 @@
1
+ import { Effect, Schema, Stream } from "effect";
2
+ import type { HttpClientResponse } from "effect/http";
3
+ import { ResponseBodyTooLarge } from "./errors.ts";
4
+
5
+ export const readBoundedBody = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, limit: number) {
6
+ const length = response.headers["content-length"];
7
+ if (length !== undefined && Number(length) > limit) {
8
+ return yield* new ResponseBodyTooLarge({ limit });
9
+ }
10
+ const chunks: Uint8Array[] = [];
11
+ let size = 0;
12
+ yield* response.stream.pipe(
13
+ Stream.runForEach((chunk) => {
14
+ size += chunk.byteLength;
15
+ if (size > limit) {
16
+ return Effect.fail(new ResponseBodyTooLarge({ limit }));
17
+ }
18
+ chunks.push(chunk);
19
+ return Effect.void;
20
+ }),
21
+ );
22
+ const bytes = new Uint8Array(size);
23
+ let offset = 0;
24
+ for (const chunk of chunks) {
25
+ bytes.set(chunk, offset);
26
+ offset += chunk.byteLength;
27
+ }
28
+ return bytes;
29
+ });
30
+
31
+ export function readBoundedText(response: HttpClientResponse.HttpClientResponse, limit: number) {
32
+ return Effect.map(readBoundedBody(response, limit), (bytes) => new TextDecoder().decode(bytes));
33
+ }
34
+
35
+ export function readBoundedJson<TSchema extends Schema.Constraint>(response: HttpClientResponse.HttpClientResponse, limit: number, schema: TSchema) {
36
+ return Effect.flatMap(readBoundedText(response, limit), Schema.decodeUnknownEffect(Schema.fromJsonString(schema)));
37
+ }
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+
3
+ const answer = { method: Schema.String, status: Schema.Number, url: Schema.String };
4
+
5
+ export class RemoteUnauthorized extends Schema.TaggedError<RemoteUnauthorized>()("RemoteUnauthorized", answer) {}
6
+ export class RemoteNotFound extends Schema.TaggedError<RemoteNotFound>()("RemoteNotFound", answer) {}
7
+ export class RemoteGone extends Schema.TaggedError<RemoteGone>()("RemoteGone", answer) {}
8
+ export class RemoteRateLimited extends Schema.TaggedError<RemoteRateLimited>()("RemoteRateLimited", {
9
+ ...answer,
10
+ retryAfter: Schema.optionalKey(Schema.DateTimeUtc),
11
+ }) {}
12
+ export class RemoteHttpError extends Schema.TaggedError<RemoteHttpError>()("RemoteHttpError", answer) {}
13
+
14
+ export type RemoteStatusError = RemoteUnauthorized | RemoteNotFound | RemoteGone | RemoteRateLimited | RemoteHttpError;
15
+
16
+ export class PaginationLinkError extends Schema.TaggedError<PaginationLinkError>()("PaginationLinkError", {
17
+ reason: Schema.Literals(["cross-origin", "invalid"]),
18
+ url: Schema.String,
19
+ }) {}
20
+
21
+ export class ResponseBodyTooLarge extends Schema.TaggedError<ResponseBodyTooLarge>()("ResponseBodyTooLarge", {
22
+ limit: Schema.Number,
23
+ }) {}
@@ -0,0 +1,38 @@
1
+ import { DateTime, Effect } from "effect";
2
+ import { HttpClient, type HttpClientResponse } from "effect/http";
3
+ import { RemoteGone, RemoteHttpError, RemoteNotFound, RemoteRateLimited, type RemoteStatusError, RemoteUnauthorized } from "./errors.ts";
4
+ import { parseRetryAfter } from "./parseRetryAfter.ts";
5
+
6
+ const checkStatus = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
7
+ const { status, request } = response;
8
+ if (status >= 200 && status < 300) {
9
+ return response;
10
+ }
11
+ const fields = { method: request.method, status, url: request.url.split(/[?#]/u)[0] ?? request.url };
12
+ let error: RemoteStatusError;
13
+ switch (status) {
14
+ case 401:
15
+ error = new RemoteUnauthorized(fields);
16
+ break;
17
+ case 404:
18
+ error = new RemoteNotFound(fields);
19
+ break;
20
+ case 410:
21
+ error = new RemoteGone(fields);
22
+ break;
23
+ default: {
24
+ const header = response.headers["retry-after"];
25
+ if (status === 429 || (status === 503 && header !== undefined)) {
26
+ const retryAfter = parseRetryAfter(header, yield* DateTime.now);
27
+ error = new RemoteRateLimited({ ...fields, ...(retryAfter === undefined ? {} : { retryAfter }) });
28
+ } else {
29
+ error = new RemoteHttpError(fields);
30
+ }
31
+ }
32
+ }
33
+ return yield* Effect.fail(error);
34
+ });
35
+
36
+ export function filterStatus<E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E | RemoteStatusError, R> {
37
+ return HttpClient.transformResponse(client, Effect.flatMap(checkStatus));
38
+ }
@@ -0,0 +1,10 @@
1
+ export function nextLink(header: string | undefined): string | undefined {
2
+ for (const link of header?.matchAll(/<(?<url>[^>]*)>(?<parameters>(?:[^<",]|"(?:\\.|[^"\\])*")*)/gu) ?? []) {
3
+ const relation = link.groups?.parameters?.match(/(?:^|;)\s*rel\s*=\s*(?:"(?<quoted>[^"]*)"|(?<unquoted>[^;\s,]+))/iu);
4
+ const relations = relation?.groups?.quoted ?? relation?.groups?.unquoted;
5
+ if (relations?.toLowerCase().split(/\s+/u).includes("next")) {
6
+ return link.groups?.url;
7
+ }
8
+ }
9
+ return undefined;
10
+ }
@@ -0,0 +1,39 @@
1
+ import { Effect, Stream } from "effect";
2
+ import { type HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
3
+ import { PaginationLinkError } from "./errors.ts";
4
+ import { nextLink } from "./nextLink.ts";
5
+
6
+ const nextRequest = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
7
+ const link = nextLink(response.headers.link);
8
+ if (link === undefined) {
9
+ return undefined;
10
+ }
11
+ const current = response.url || response.request.url;
12
+ const url = current.split(/[?#]/u)[0] ?? current;
13
+ const next = yield* Effect.try({
14
+ catch: () => new PaginationLinkError({ reason: "invalid", url }),
15
+ try: () => new URL(link, current),
16
+ });
17
+ if (next.origin !== new URL(current).origin) {
18
+ return yield* new PaginationLinkError({ reason: "cross-origin", url });
19
+ }
20
+ return HttpClientRequest.setUrl(response.request, next);
21
+ });
22
+
23
+ export function paginate<E, R>(client: HttpClient.HttpClient.With<E, R>, first: HttpClientRequest.HttpClientRequest) {
24
+ return Stream.unfold<
25
+ HttpClientRequest.HttpClientRequest | HttpClientResponse.HttpClientResponse,
26
+ HttpClientResponse.HttpClientResponse,
27
+ E | PaginationLinkError,
28
+ R
29
+ >(first, (state) =>
30
+ Effect.gen(function* () {
31
+ const request = HttpClientRequest.isHttpClientRequest(state) ? state : yield* nextRequest(state);
32
+ if (request === undefined) {
33
+ return undefined;
34
+ }
35
+ const response = yield* client.execute(request);
36
+ return [response, response] as const;
37
+ }),
38
+ );
39
+ }
@@ -0,0 +1,17 @@
1
+ import { DateTime, Option } from "effect";
2
+
3
+ const httpDate = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/u;
4
+
5
+ export function parseRetryAfter(header: string | undefined, now: DateTime.Utc): DateTime.Utc | undefined {
6
+ if (header === undefined) {
7
+ return undefined;
8
+ }
9
+ const value = header.trim();
10
+ let millis = Number.NaN;
11
+ if (/^\d+$/u.test(value)) {
12
+ millis = DateTime.toEpochMillis(now) + Number(value) * 1000;
13
+ } else if (httpDate.test(value)) {
14
+ millis = Date.parse(value);
15
+ }
16
+ return Option.getOrUndefined(DateTime.make(millis));
17
+ }
@@ -8,6 +8,8 @@ export interface MigrationOptions<R = never> {
8
8
  readonly lockTimeout?: Duration.Input;
9
9
  readonly mode?: "migrate" | "check" | "wait";
10
10
  readonly pollInterval?: Duration.Input;
11
+ readonly progressInterval?: Duration.Input;
12
+ readonly release?: string;
11
13
  readonly table?: string;
12
14
  readonly waitTimeout?: Duration.Input;
13
15
  }
@@ -1,7 +1,7 @@
1
1
  import { type BetterAuthOptions, betterAuth } from "better-auth";
2
2
  import { Config, Effect, Option, Redacted } from "effect";
3
3
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
4
- import { Pool, type PoolClient } from "pg";
4
+ import { Pool, type PoolClient, type PoolConfig } from "pg";
5
5
  import { type RevokeUnadmitted, revokeUnadmitted } from "#server/better-auth/revokeUnadmitted.ts";
6
6
  import { type PolicyStorage, type SignInPolicy, signInGate } from "#server/better-auth/signInGate.ts";
7
7
  import { signInPolicyOptions } from "#server/better-auth/signInPolicy.ts";
@@ -96,12 +96,15 @@ export const betterAuthMount = Effect.fn("Platform.betterAuthMount")(function* <
96
96
  return { admittedSessions, auth, revokeUnadmitted: revoke, routes };
97
97
  });
98
98
 
99
- export const authDatabase = Effect.fn("Platform.authDatabase")(function* (url: Redacted.Redacted<string>) {
99
+ export const authDatabase = Effect.fn("Platform.authDatabase")(function* (
100
+ url: Redacted.Redacted<string>,
101
+ options: Pick<PoolConfig, "max" | "application_name"> = {},
102
+ ) {
100
103
  const clients = new Set<PoolClient>();
101
104
  const run = Effect.runForkWith(yield* Effect.context<never>());
102
105
  return yield* Effect.acquireRelease(
103
106
  Effect.sync(() => {
104
- const pool = new Pool({ connectionString: Redacted.value(url) });
107
+ const pool = new Pool({ ...options, connectionString: Redacted.value(url) });
105
108
  pool.on("connect", (client) => clients.add(client));
106
109
  pool.on("remove", (client) => clients.delete(client));
107
110
  pool.on("error", (error) => {
@@ -1,3 +1,4 @@
1
+ import type { AuthContext } from "better-auth";
1
2
  import { testUtils } from "better-auth/plugins";
2
3
 
3
4
  export function authTestUtils(options?: Parameters<typeof testUtils>[0]) {
@@ -13,9 +14,34 @@ export function authTestUtils(options?: Parameters<typeof testUtils>[0]) {
13
14
  }
14
15
 
15
16
  type TestContext = ReturnType<ReturnType<typeof authTestUtils>["init"]>["context"];
17
+ type AccountContext = Pick<AuthContext, "internalAdapter">;
16
18
 
17
- export async function signedInUser(auth: { readonly $context: Promise<TestContext> }, fields: Parameters<TestContext["test"]["createUser"]>[0]) {
18
- const { test } = await auth.$context;
19
+ export async function signedInUser(
20
+ auth: { readonly $context: Promise<TestContext & AccountContext> },
21
+ {
22
+ providerAccounts = [],
23
+ ...fields
24
+ }: Parameters<TestContext["test"]["createUser"]>[0] & {
25
+ readonly providerAccounts?: readonly { readonly providerId: string; readonly accountId: string }[];
26
+ },
27
+ ) {
28
+ const { test, internalAdapter } = await auth.$context;
19
29
  const user = await test.saveUser(test.createUser(fields));
30
+ for (const { providerId, accountId } of providerAccounts) {
31
+ await internalAdapter.createAccount({ accountId, providerId, userId: user.id });
32
+ }
20
33
  return test.login({ userId: user.id });
21
34
  }
35
+
36
+ export async function storedProviderAccounts(auth: { readonly $context: Promise<AccountContext> }, userId: string) {
37
+ const { internalAdapter } = await auth.$context;
38
+ const accounts = await internalAdapter.findAccounts(userId);
39
+ return accounts.map(({ providerId, accountId, accessToken, refreshToken, idToken, password }) => ({
40
+ accountId,
41
+ hasAccessToken: Boolean(accessToken),
42
+ hasIdToken: Boolean(idToken),
43
+ hasPassword: Boolean(password),
44
+ hasRefreshToken: Boolean(refreshToken),
45
+ providerId,
46
+ }));
47
+ }
@@ -0,0 +1,37 @@
1
+ import type { Auth } from "better-auth";
2
+ import { Effect } from "effect";
3
+ import { AuthUnavailable } from "#errors/taxonomy.ts";
4
+ import { redact } from "#server/rpc-server/redact.ts";
5
+
6
+ export interface UserProfile {
7
+ readonly id: string;
8
+ readonly image: string | null;
9
+ readonly name: string;
10
+ }
11
+
12
+ export const userProfiles = Effect.fn("Platform.userProfiles")(function* (
13
+ auth: { readonly $context: Promise<{ readonly adapter: Pick<Awaited<Auth["$context"]>["adapter"], "findMany"> }> },
14
+ userIds: readonly string[],
15
+ ): Effect.fn.Return<readonly UserProfile[], AuthUnavailable> {
16
+ const ids = [...new Set(userIds)];
17
+ if (ids.length === 0) {
18
+ return [];
19
+ }
20
+ const users = yield* Effect.tryPromise(async () => {
21
+ const { adapter } = await auth.$context;
22
+ return adapter.findMany<UserProfile>({
23
+ limit: ids.length,
24
+ model: "user",
25
+ select: ["id", "name", "image"],
26
+ where: [{ field: "id", operator: "in", value: ids }],
27
+ });
28
+ }).pipe(
29
+ Effect.catch((error) =>
30
+ Effect.andThen(
31
+ Effect.logError("Authentication provider failed", redact(error.cause)),
32
+ Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" })),
33
+ ),
34
+ ),
35
+ );
36
+ return users.map(({ id, name, image }) => ({ id, image: image ?? null, name }));
37
+ });
@@ -1,13 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createConnection } from "node:net";
3
3
  import * as PgClient from "@effect/sql-pg/PgClient";
4
- import { Config, Effect, Layer, Redacted, Schedule } from "effect";
4
+ import { Config, Duration, Effect, Fiber, Layer, Redacted, Schedule } from "effect";
5
5
  import * as Reactivity from "effect/reactivity/Reactivity";
6
6
  import { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
7
- import { assertPostgresMigrations } from "@shivaedev/effect-sql/assertPostgresMigrations.ts";
7
+ import { assertPostgresMigrations, postgresMigrationError } from "@shivaedev/effect-sql/assertPostgresMigrations.ts";
8
8
  import type { SqlNameTransforms } from "@shivaedev/effect-sql/clientOptions.ts";
9
+ import { coordinateWorkerRelease } from "@shivaedev/effect-sql/coordinateWorkerRelease.ts";
9
10
  import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
11
+ import { type PendingPostgresMigrations, pendingPostgresMigrations } from "@shivaedev/effect-sql/pendingPostgresMigrations.ts";
10
12
  import { type PostgresChangesOptions, postgresChanges } from "@shivaedev/effect-sql/postgresChanges.ts";
13
+ import { WorkerRelease } from "@shivaedev/effect-sql/WorkerRelease.ts";
11
14
  import type { AppChanges, AppDatabaseOptions } from "#server/appHost.ts";
12
15
  import { databaseAddress } from "./databaseAddress.ts";
13
16
 
@@ -52,7 +55,12 @@ export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService:
52
55
  );
53
56
  const prepared = Layer.effectDiscard(prepareMigrations(options.migrations)).pipe(Layer.provideMerge(pool));
54
57
  const live = Layer.effect(changesService, makeLiveServer).pipe(Layer.provideMerge(Reactivity.layer));
55
- const base = Layer.merge(prepared, live);
58
+ const migrations = options.migrations;
59
+ const worker =
60
+ migrations.mode === "wait" && migrations.release !== undefined
61
+ ? Layer.effect(WorkerRelease, coordinateWorkerRelease(migrations.release, migrations.pollInterval, migrations.table))
62
+ : Layer.empty;
63
+ const base = Layer.merge(worker.pipe(Layer.provideMerge(prepared)), live);
56
64
  const changes = options.config?.changes;
57
65
  return Layer.unwrap(
58
66
  Effect.gen(function* () {
@@ -68,20 +76,52 @@ export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService:
68
76
 
69
77
  function prepareMigrations<R>(migrations: AppDatabaseOptions<R>["migrations"]) {
70
78
  switch (migrations.mode) {
71
- case "check":
72
- return assertPostgresMigrations(migrations);
79
+ case "check": {
80
+ const schema = { ...migrations };
81
+ delete schema.release;
82
+ return assertPostgresMigrations(schema);
83
+ }
73
84
  case "wait":
74
85
  return waitForMigrations(migrations);
75
86
  default:
76
- return migratePostgres({ ...migrations, lockTimeout: migrations.lockTimeout ?? "5 seconds" });
87
+ return migratePostgres({ ...migrations, lockTimeout: migrations.lockTimeout ?? "5 seconds" }).pipe(Effect.asVoid);
77
88
  }
78
89
  }
79
90
 
80
91
  function waitForMigrations<R>(migrations: AppDatabaseOptions<R>["migrations"]) {
81
- return assertPostgresMigrations(migrations).pipe(
82
- Effect.retry({
83
- schedule: Schedule.max([Schedule.spaced(migrations.pollInterval ?? "1 second"), Schedule.during(migrations.waitTimeout ?? "5 minutes")]),
84
- while: (error) => error._tag === "MigrationError" && error.kind === "BadState",
85
- }),
86
- );
92
+ return Effect.gen(function* () {
93
+ const progressInterval = Duration.fromInputUnsafe(migrations.progressInterval ?? "30 seconds");
94
+ let pending: PendingPostgresMigrations = { components: [], migrations: [] };
95
+ let progress: Fiber.Fiber<never> | undefined;
96
+ function log(message: string) {
97
+ return Effect.suspend(() =>
98
+ Effect.logInfo(message).pipe(Effect.annotateLogs({ pendingComponents: pending.components, pendingMigrations: pending.migrations })),
99
+ );
100
+ }
101
+ yield* Effect.gen(function* () {
102
+ pending = yield* pendingPostgresMigrations(migrations);
103
+ if (pending.migrations.length === 0 && pending.components.length === 0) {
104
+ return;
105
+ }
106
+ if (progress === undefined) {
107
+ yield* log("Waiting for database migrations");
108
+ progress = yield* Effect.forever(Effect.sleep(progressInterval).pipe(Effect.andThen(log("Still waiting for database migrations")))).pipe(
109
+ Effect.forkScoped,
110
+ );
111
+ }
112
+ return yield* Effect.fail(postgresMigrationError(pending));
113
+ }).pipe(
114
+ Effect.retry({
115
+ schedule: Schedule.max([Schedule.spaced(migrations.pollInterval ?? "1 second"), Schedule.during(migrations.waitTimeout ?? "5 minutes")]),
116
+ while: (error) => error._tag === "MigrationError" && error.kind === "BadState",
117
+ }),
118
+ Effect.tapError((error) =>
119
+ error._tag === "MigrationError" && error.kind === "BadState" ? log("Timed out waiting for database migrations") : Effect.void,
120
+ ),
121
+ );
122
+ if (progress !== undefined) {
123
+ yield* Fiber.interrupt(progress);
124
+ yield* Effect.logInfo("Database migrations current");
125
+ }
126
+ }).pipe(Effect.scoped);
87
127
  }
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, Layer, Option, Stream } from "effect";
2
2
  import type { Rpc, RpcGroup } from "effect/rpc";
3
- import type { Key } from "@shivaedev/effect-contract/keys.ts";
3
+ import { ChangesReads, type QueryReference } from "@shivaedev/effect-contract/serverMetadata.ts";
4
4
  import type { AppChanges } from "./appHost.ts";
5
5
 
6
6
  export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, changesService: typeof AppChanges) {
@@ -18,8 +18,9 @@ export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, chan
18
18
  handlers.set(rpc.key, provided);
19
19
  continue;
20
20
  }
21
- function handler({ keys }: { readonly keys: readonly Key[] }) {
22
- return Option.isSome(changes) ? changes.value.stream(keys) : Stream.never;
21
+ const reads = Context.getUnsafe(rpc.annotations, ChangesReads);
22
+ function handler({ queries }: { readonly queries: readonly QueryReference[] }) {
23
+ return Option.isSome(changes) ? changes.value.stream(reads(queries)) : Stream.never;
23
24
  }
24
25
  handlers.set(rpc.key, { context: services, handler, tag: rpc._tag });
25
26
  }
@@ -1,7 +1,8 @@
1
1
  import { Effect, Option } from "effect";
2
2
  import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/http";
3
3
  import { RpcSerialization } from "effect/rpc";
4
- import { proxyAddresses, proxyMatches, proxyRanges } from "./trustedProxy.ts";
4
+ import { proxyAddresses, proxyMatches } from "./trustedProxy.ts";
5
+ import { trustedProxyRanges as normalizedRanges } from "./trustedProxyRanges.ts";
5
6
 
6
7
  function loopbackAddress(address: string): boolean {
7
8
  const unmapped = unmapAddress(address);
@@ -46,8 +47,9 @@ export function trustedProxyPeer(trustedProxy?: string): (address: string) => bo
46
47
  return (address) => addresses !== undefined && proxyMatches(addresses, address);
47
48
  }
48
49
  export function trustedProxyRanges(trustedProxy?: string): string[] {
49
- return trustedProxy === undefined ? [] : (proxyRanges(trustedProxy) ?? []);
50
+ return normalizedRanges(trustedProxy);
50
51
  }
52
+
51
53
  function proxyOrigin(request: HttpServerRequest.HttpServerRequest, isTrustedProxy: (address: string) => boolean): boolean {
52
54
  if (!Option.exists(request.remoteAddress, isTrustedProxy)) {
53
55
  return false;
@@ -0,0 +1,61 @@
1
+ import { BlockList, isIP } from "node:net";
2
+
3
+ export function addProxyAddress(addresses: BlockList, entry: string): boolean {
4
+ const parts = entry.trim().split("/");
5
+ const address = parts[0] ?? "";
6
+ const family = isIP(address);
7
+ if (family === 0 || address.includes("%") || parts.length > 2) {
8
+ return false;
9
+ }
10
+ const type = family === 4 ? "ipv4" : "ipv6";
11
+ const prefix = parts[1];
12
+ if (prefix === undefined) {
13
+ addresses.addAddress(address, type);
14
+ } else {
15
+ if (!/^\d+$/u.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)) {
16
+ return false;
17
+ }
18
+ addresses.addSubnet(address, Number(prefix), type);
19
+ }
20
+ return true;
21
+ }
22
+ function mappedIpv4(address: string): string | undefined {
23
+ const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
24
+ if (groups?.high === undefined || groups.low === undefined) {
25
+ return undefined;
26
+ }
27
+ const high = Number.parseInt(groups.high, 16);
28
+ const low = Number.parseInt(groups.low, 16);
29
+ return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
30
+ }
31
+ function normalizedProxyRange(entry: string): string[] | undefined {
32
+ const range = new BlockList();
33
+ if (!addProxyAddress(range, entry)) {
34
+ return undefined;
35
+ }
36
+ const [address = "", prefix] = entry.split("/");
37
+ if (isIP(address) !== 6) {
38
+ return [entry];
39
+ }
40
+ const ipv4 = mappedIpv4(address);
41
+ const bits = prefix === undefined ? 128 : Number(prefix);
42
+ if (ipv4 !== undefined) {
43
+ if (bits >= 96) {
44
+ return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
45
+ }
46
+ const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
47
+ return [`${network}/${bits}`, "0.0.0.0/0"];
48
+ }
49
+ return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
50
+ }
51
+ export function proxyRanges(value: string): string[] | undefined {
52
+ const ranges: string[] = [];
53
+ for (const entry of value.split(",")) {
54
+ const normalized = normalizedProxyRange(entry.trim());
55
+ if (normalized === undefined) {
56
+ return undefined;
57
+ }
58
+ ranges.push(...normalized);
59
+ }
60
+ return ranges;
61
+ }
@@ -1,65 +1,11 @@
1
1
  import { BlockList, isIP } from "node:net";
2
2
  import { Schema } from "effect";
3
+ import { addProxyAddress, proxyRanges as normalizedRanges } from "./proxyRanges.ts";
3
4
 
4
- function addProxyAddress(addresses: BlockList, entry: string): boolean {
5
- const parts = entry.trim().split("/");
6
- const address = parts[0] ?? "";
7
- const family = isIP(address);
8
- if (family === 0 || address.includes("%") || parts.length > 2) {
9
- return false;
10
- }
11
- const type = family === 4 ? "ipv4" : "ipv6";
12
- const prefix = parts[1];
13
- if (prefix === undefined) {
14
- addresses.addAddress(address, type);
15
- } else {
16
- if (!/^\d+$/u.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)) {
17
- return false;
18
- }
19
- addresses.addSubnet(address, Number(prefix), type);
20
- }
21
- return true;
22
- }
23
- function mappedIpv4(address: string): string | undefined {
24
- const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
25
- if (groups?.high === undefined || groups.low === undefined) {
26
- return undefined;
27
- }
28
- const high = Number.parseInt(groups.high, 16);
29
- const low = Number.parseInt(groups.low, 16);
30
- return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
31
- }
32
- function normalizedProxyRange(entry: string): string[] | undefined {
33
- const range = new BlockList();
34
- if (!addProxyAddress(range, entry)) {
35
- return undefined;
36
- }
37
- const [address = "", prefix] = entry.split("/");
38
- if (isIP(address) !== 6) {
39
- return [entry];
40
- }
41
- const ipv4 = mappedIpv4(address);
42
- const bits = prefix === undefined ? 128 : Number(prefix);
43
- if (ipv4 !== undefined) {
44
- if (bits >= 96) {
45
- return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
46
- }
47
- const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
48
- return [`${network}/${bits}`, "0.0.0.0/0"];
49
- }
50
- return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
51
- }
52
5
  export function proxyRanges(value: string): string[] | undefined {
53
- const ranges: string[] = [];
54
- for (const entry of value.split(",")) {
55
- const normalized = normalizedProxyRange(entry.trim());
56
- if (normalized === undefined) {
57
- return undefined;
58
- }
59
- ranges.push(...normalized);
60
- }
61
- return ranges;
6
+ return normalizedRanges(value);
62
7
  }
8
+
63
9
  export function proxyAddresses(value: string): BlockList | undefined {
64
10
  const addresses = new BlockList();
65
11
  return proxyRanges(value)?.every((entry) => addProxyAddress(addresses, entry)) ? addresses : undefined;
@@ -0,0 +1,5 @@
1
+ import { proxyRanges } from "./proxyRanges.ts";
2
+
3
+ export function trustedProxyRanges(trustedProxy?: string): string[] {
4
+ return trustedProxy === undefined ? [] : (proxyRanges(trustedProxy) ?? []);
5
+ }
@@ -6,7 +6,7 @@ import type { EffectTRPCAdapter } from "@shivaedev/effect-trpc/server/adapter.ts
6
6
  import type { EffectCallerFactory } from "@shivaedev/effect-trpc/server/testing/caller.ts";
7
7
  import type { CallerOptions, CallerResult } from "@shivaedev/effect-trpc/server/testing/types.ts";
8
8
  import { makeTrpcHarnessIt } from "@shivaedev/effect-trpc/server/testing/vitest.ts";
9
- import type { Bivariant } from "@shivaedev/types/bivariant.ts";
9
+ import type { Bivariant } from "@shivaedev/types/Bivariant.ts";
10
10
  import type { MakePlatformItOptions, PlatformHarness, PlatformIt } from "./types.ts";
11
11
 
12
12
  interface LooseOptions {