From c6afc0a65ff8073175354fb58d9cddde185f5a82 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Tue, 2 Jun 2026 20:33:08 +0200 Subject: [PATCH 1/2] feat: add azure sql cluster adapter --- .changeset/azure-sql-cluster-storage.md | 5 + package.json | 3 +- packages/infra/package.json | 2 + packages/infra/src/ClusterAzureSql.ts | 180 +++++++ packages/infra/test/cluster-azure-sql.test.ts | 489 ++++++++++++++++++ patches/effect@4.0.0-beta.74.patch | 87 ++++ pnpm-lock.yaml | 284 ++++++++-- 7 files changed, 1019 insertions(+), 31 deletions(-) create mode 100644 .changeset/azure-sql-cluster-storage.md create mode 100644 packages/infra/src/ClusterAzureSql.ts create mode 100644 packages/infra/test/cluster-azure-sql.test.ts create mode 100644 patches/effect@4.0.0-beta.74.patch diff --git a/.changeset/azure-sql-cluster-storage.md b/.changeset/azure-sql-cluster-storage.md new file mode 100644 index 0000000000..be92b8163e --- /dev/null +++ b/.changeset/azure-sql-cluster-storage.md @@ -0,0 +1,5 @@ +--- +"@effect-app/infra": patch +--- + +Add Azure SQL cluster storage backed by Effect SQL MSSQL. diff --git a/package.json b/package.json index 6b33cc4973..d6b4cf950f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "ts-plugin-sort-import-suggestions": "patches/ts-plugin-sort-import-suggestions.patch", "@tanstack/query-core": "patches/@tanstack__query-core.patch", "typescript@6.0.3": "patches/typescript.patch", - "@sendgrid/mail": "patches/@sendgrid__mail.patch" + "@sendgrid/mail": "patches/@sendgrid__mail.patch", + "effect@4.0.0-beta.74": "patches/effect@4.0.0-beta.74.patch" } }, "engines": { diff --git a/packages/infra/package.json b/packages/infra/package.json index 1a825e4213..8364e39dae 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@azure/cosmos": "^4.9.3", "@azure/service-bus": "^7.9.5", + "@effect/sql-mssql": "4.0.0-beta.74", "@effect/sql-sqlite-node": "4.0.0-beta.74", "@sentry/node": "10.55.0", "@sentry/opentelemetry": "10.55.0", @@ -38,6 +39,7 @@ "peerDependencies": { "@azure/cosmos": "^4.9.3", "@azure/service-bus": "^7.9.5", + "@effect/sql-mssql": "4.0.0-beta.74", "@effect/vitest": "^4.0.0-beta.74", "@sendgrid/helpers": "^8.0.0", "@sendgrid/mail": "^8.1.6", diff --git a/packages/infra/src/ClusterAzureSql.ts b/packages/infra/src/ClusterAzureSql.ts new file mode 100644 index 0000000000..5ee04c039b --- /dev/null +++ b/packages/infra/src/ClusterAzureSql.ts @@ -0,0 +1,180 @@ +import { MssqlClient } from "@effect/sql-mssql" +import * as Duration from "effect/Duration" +import * as Layer from "effect/Layer" +import * as Redacted from "effect/Redacted" +import type * as MessageStorage from "effect/unstable/cluster/MessageStorage" +import type * as RunnerStorage from "effect/unstable/cluster/RunnerStorage" +import type * as ShardingConfig from "effect/unstable/cluster/ShardingConfig" +import * as SqlMessageStorage from "effect/unstable/cluster/SqlMessageStorage" +import * as SqlRunnerStorage from "effect/unstable/cluster/SqlRunnerStorage" + +export interface ClusterAzureSqlConfig { + readonly url: Redacted.Redacted + readonly prefix?: string | undefined + readonly minConnections?: number | undefined + readonly maxConnections?: number | undefined + readonly connectionTTL?: Duration.Input | undefined + readonly connectTimeout?: Duration.Input | undefined + readonly encrypt?: boolean | undefined + readonly trustServer?: boolean | undefined +} + +interface ParsedConnection { + readonly server: string + readonly port?: number | undefined + readonly instanceName?: string | undefined + readonly database?: string | undefined + readonly username?: string | undefined + readonly password?: Redacted.Redacted | undefined + readonly encrypt?: boolean | undefined + readonly trustServer?: boolean | undefined + readonly connectTimeout?: Duration.Input | undefined +} + +export const mssqlConfigFromUrl = (config: ClusterAzureSqlConfig): MssqlClient.MssqlClientConfig => { + const parsed = parseConnection(Redacted.value(config.url)) + return { + ...parsed, + encrypt: config.encrypt ?? parsed.encrypt ?? true, + trustServer: config.trustServer ?? parsed.trustServer ?? false, + minConnections: config.minConnections, + maxConnections: config.maxConnections, + connectionTTL: config.connectionTTL, + connectTimeout: config.connectTimeout ?? parsed.connectTimeout + } +} + +export const layerSqlClient = (config: ClusterAzureSqlConfig) => MssqlClient.layer(mssqlConfigFromUrl(config)) + +export const layerAzureSql = ( + config: ClusterAzureSqlConfig +): Layer.Layer< + MessageStorage.MessageStorage | RunnerStorage.RunnerStorage, + never, + ShardingConfig.ShardingConfig +> => + Layer + .merge( + SqlMessageStorage.layerWith({ prefix: config.prefix }), + Layer.orDie(SqlRunnerStorage.layerWith({ prefix: config.prefix })) + ) + .pipe(Layer.provide(Layer.orDie(layerSqlClient(config)))) + +const parseConnection = (value: string): ParsedConnection => { + if (value.includes(";") && value.includes("=")) { + return parseConnectionString(value) + } + return parseConnectionUrl(value) +} + +const parseConnectionUrl = (value: string): ParsedConnection => { + const url = new URL(value) + const port = parsePort(url.port) + const database = url.pathname.length > 1 ? decodeURIComponent(url.pathname.slice(1)) : undefined + const username = url.username.length > 0 ? decodeURIComponent(url.username) : undefined + const password = url.password.length > 0 ? Redacted.make(decodeURIComponent(url.password)) : undefined + return { + ...parseServer(url.hostname, port), + database, + username, + password, + encrypt: parseBoolean(url.searchParams.get("encrypt")), + trustServer: parseBoolean(url.searchParams.get("trustServerCertificate") ?? url.searchParams.get("trustServer")), + connectTimeout: parseSeconds(url.searchParams.get("connectTimeout") ?? url.searchParams.get("connectionTimeout")) + } +} + +const parseConnectionString = (value: string): ParsedConnection => { + const parts = new Map() + for (const part of value.split(";")) { + const separator = part.indexOf("=") + if (separator === -1) continue + const key = part.slice(0, separator).trim().toLowerCase().replace(/\s+/g, "") + const field = part.slice(separator + 1).trim() + if (field.length > 0) { + parts.set(key, field) + } + } + + const server = readPart(parts, "server", "datasource", "address", "addr", "networkaddress") + if (server === undefined) { + throw new Error("Azure SQL connection string is missing Server") + } + + const password = readPart(parts, "password", "pwd") + return { + ...parseServer(server, parsePort(readPart(parts, "port"))), + database: readPart(parts, "database", "initialcatalog"), + username: readPart(parts, "userid", "uid", "user"), + password: password === undefined ? undefined : Redacted.make(password), + encrypt: parseBoolean(readPart(parts, "encrypt")), + trustServer: parseBoolean(readPart(parts, "trustservercertificate")), + connectTimeout: parseSeconds(readPart(parts, "connectiontimeout", "connecttimeout")) + } +} + +const parseServer = ( + input: string, + port: number | undefined +): Pick => { + let server = input.trim() + if (server.toLowerCase().startsWith("tcp:")) { + server = server.slice(4) + } + + let parsedPort = port + const comma = server.lastIndexOf(",") + if (comma !== -1) { + parsedPort = parsePort(server.slice(comma + 1)) + server = server.slice(0, comma) + } + + const instanceSeparator = server.indexOf("\\") + if (instanceSeparator !== -1) { + return { + server: server.slice(0, instanceSeparator), + instanceName: server.slice(instanceSeparator + 1), + port: parsedPort + } + } + + return { server, port: parsedPort } +} + +const readPart = (parts: ReadonlyMap, ...keys: ReadonlyArray) => { + for (const key of keys) { + const value = parts.get(key) + if (value !== undefined) return value + } + return undefined +} + +const parsePort = (value: string | undefined): number | undefined => { + if (value === undefined || value.length === 0) return undefined + const port = Number(value) + if (Number.isInteger(port) && port > 0) return port + throw new Error(`Invalid Azure SQL port: ${value}`) +} + +const parseSeconds = (value: string | undefined | null): Duration.Duration | undefined => { + if (value === undefined || value === null || value.length === 0) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return Duration.seconds(seconds) + throw new Error(`Invalid Azure SQL timeout: ${value}`) +} + +const parseBoolean = (value: string | undefined | null): boolean | undefined => { + if (value === undefined || value === null || value.length === 0) return undefined + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "1": + return true + case "false": + case "no": + case "0": + return false + default: + throw new Error(`Invalid Azure SQL boolean: ${value}`) + } +} diff --git a/packages/infra/test/cluster-azure-sql.test.ts b/packages/infra/test/cluster-azure-sql.test.ts new file mode 100644 index 0000000000..7ec3fc2cf3 --- /dev/null +++ b/packages/infra/test/cluster-azure-sql.test.ts @@ -0,0 +1,489 @@ +import { assert, describe, it } from "@effect/vitest" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Redacted, Schema } from "effect" +import { ClusterSchema, ClusterWorkflowEngine, Entity, EntityAddress, EntityId, EntityType, Envelope, Message, MessageStorage, Reply, Runner, RunnerAddress, RunnerHealth, Runners, RunnerStorage, ShardId, Sharding, ShardingConfig, Snowflake } from "effect/unstable/cluster" +import { Headers } from "effect/unstable/http" +import { Rpc, RpcSchema } from "effect/unstable/rpc" +import { DurableDeferred, Workflow } from "effect/unstable/workflow" +import { layerAzureSql, mssqlConfigFromUrl } from "../src/ClusterAzureSql.js" + +const azureSqlUrl = process.env["AZURE_SQL_TEST_URL"] +const testKey = `${Date.now().toString(36)}${Math.random().toString(16).slice(2, 8)}` +const testRunId = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}` +const tablePrefix = `test_azsql_${testKey}` +const runnerPortBase = 10000 + Date.now() % 40000 +const liveSnowflake = Layer.effect(Snowflake.Generator, Snowflake.makeGenerator) + +describe("ClusterAzureSql config", () => { + it("parses mssql URLs", () => { + const config = mssqlConfigFromUrl({ + url: Redacted.make( + "mssql://user:secret@example.database.windows.net:1433/app?encrypt=true&trustServerCertificate=false&connectTimeout=7" + ) + }) + + assert.strictEqual(config.server, "example.database.windows.net") + assert.strictEqual(config.port, 1433) + assert.strictEqual(config.database, "app") + assert.strictEqual(config.username, "user") + assert.strictEqual(config.password && Redacted.value(config.password), "secret") + assert.strictEqual(config.encrypt, true) + assert.strictEqual(config.trustServer, false) + }) + + it("parses Azure SQL connection strings", () => { + const config = mssqlConfigFromUrl({ + url: Redacted.make( + "Server=tcp:example.database.windows.net,1433;Initial Catalog=app;User ID=user;Password=secret;Encrypt=True;TrustServerCertificate=False;Connection Timeout=7;" + ) + }) + + assert.strictEqual(config.server, "example.database.windows.net") + assert.strictEqual(config.port, 1433) + assert.strictEqual(config.database, "app") + assert.strictEqual(config.username, "user") + assert.strictEqual(config.password && Redacted.value(config.password), "secret") + assert.strictEqual(config.encrypt, true) + assert.strictEqual(config.trustServer, false) + }) +}) + +const layerFor = () => + layerAzureSql({ + url: Redacted.make(azureSqlUrl ?? ""), + prefix: tablePrefix, + maxConnections: 16, + connectTimeout: Duration.seconds(15) + }) + .pipe( + Layer.provideMerge(liveSnowflake), + Layer.provide(ShardingConfig.layerDefaults) + ) + +describe.skipIf(!azureSqlUrl)("ClusterAzureSql MessageStorage", () => { + it.effect("deduplicates keyed requests and returns the last reply", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const shardId = testShardId("md") + const primaryKey = `primary/${testRunId}/with-specials` + const request = yield* makeStreamRequest(primaryKey, shardId) + + assert.strictEqual((yield* storage.saveRequest(request))._tag, "Success") + + const chunk = yield* makeChunkReply(request, 0) + yield* storage.saveReply(chunk) + + const duplicateWithChunk = yield* storage.saveRequest(yield* makeStreamRequest(primaryKey, shardId)) + assert(duplicateWithChunk._tag === "Duplicate" && Option.isSome(duplicateWithChunk.lastReceivedReply)) + assert.strictEqual(duplicateWithChunk.lastReceivedReply.value._tag, "Chunk") + + yield* storage.saveEnvelope(yield* makeAckChunk(request, chunk)) + assert.strictEqual((yield* storage.repliesFor([request])).length, 0) + + yield* storage.saveReply(yield* makeStreamReply(request)) + const duplicateWithExit = yield* storage.saveRequest(yield* makeStreamRequest(primaryKey, shardId)) + assert(duplicateWithExit._tag === "Duplicate" && Option.isSome(duplicateWithExit.lastReceivedReply)) + assert.strictEqual(duplicateWithExit.lastReceivedReply.value._tag, "WithExit") + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("returns each unprocessed message to only one concurrent poll", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const shardId = testShardId("mp") + const expectedIds = Array.from({ length: 20 }, (_, index) => index + 1) + const requests = yield* Effect.forEach(expectedIds, (id) => makeRequest({ payload: { id }, shardId })) + yield* Effect.forEach(requests, (request) => storage.saveRequest(request), { discard: true }) + + const polls = yield* Effect.forEach( + Array.from({ length: 8 }, () => void 0), + () => storage.unprocessedMessages([shardId]), + { concurrency: "unbounded" } + ) + const receivedIds = polls + .flatMap((messages) => messages.map(requestPayloadId)) + .sort((a, b) => a - b) + + assert.deepStrictEqual(receivedIds, expectedIds) + assert.strictEqual((yield* storage.unprocessedMessages([shardId])).length, 0) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("fails on duplicate terminal replies", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const request = yield* makeStreamRequest(`duplicate-with-exit/${testRunId}`) + yield* storage.saveRequest(request) + yield* storage.saveReply(yield* makeStreamReply(request)) + + const error = yield* Effect.flip(storage.saveReply(yield* makeStreamReply(request))) + assert.strictEqual(error._tag, "PersistenceError") + }) + .pipe(Effect.provide(layerFor())), 20000) +}) + +describe.skipIf(!azureSqlUrl)("ClusterAzureSql RunnerStorage", () => { + it.effect("registers runners and tracks health", () => + Effect + .gen(function*() { + const storage = yield* RunnerStorage.RunnerStorage + const runnerAddress = testRunnerAddress(1) + const runner = Runner.make({ address: runnerAddress, groups: ["default"], weight: 1 }) + + const machineId1 = yield* storage.register(runner, true) + const machineId2 = yield* storage.register(runner, true) + assert.deepStrictEqual(machineId2, machineId1) + assert.deepStrictEqual(runnerStatus(yield* storage.getRunners, runnerAddress), [runner, true]) + + yield* storage.setRunnerHealth(runnerAddress, false) + assert.deepStrictEqual(runnerStatus(yield* storage.getRunners, runnerAddress), [runner, false]) + + yield* storage.unregister(runnerAddress) + assert.strictEqual(runnerStatus(yield* storage.getRunners, runnerAddress), undefined) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("preserves shard lock ownership when two runners acquire concurrently", () => + Effect + .gen(function*() { + const storage = yield* RunnerStorage.RunnerStorage + const runnerAddress1 = testRunnerAddress(2) + const runnerAddress2 = testRunnerAddress(3) + const runner1 = Runner.make({ address: runnerAddress1, groups: ["default"], weight: 1 }) + const runner2 = Runner.make({ address: runnerAddress2, groups: ["default"], weight: 1 }) + const shards = Array.from({ length: 16 }, (_, index) => testShardId("ro", index + 1)) + + yield* storage.register(runner1, true) + yield* storage.register(runner2, true) + + const [acquired1, acquired2] = yield* Effect.all([ + storage.acquire(runnerAddress1, shards), + storage.acquire(runnerAddress2, shards) + ], { concurrency: "unbounded" }) + const acquiredIds = [...acquired1, ...acquired2].map((shard) => shard.id).sort((a, b) => a - b) + + assert.deepStrictEqual(acquiredIds, shards.map((shard) => shard.id)) + assert.strictEqual(new Set(acquiredIds).size, shards.length) + }) + .pipe(Effect.provide(layerFor())), 20000) +}) + +describe.skipIf(!azureSqlUrl)("ClusterAzureSql Sharding RPC", () => { + it.effect("runs persisted entity RPCs through Azure SQL-backed cluster storage", () => + Effect + .gen(function*() { + const sharding = yield* Sharding.Sharding + const makeClient = yield* AzureSqlRpcEntity.client + const entityId = `entity-${testRunId}` + const shardId = sharding.getShardId(EntityId.make(entityId), testShardGroup("rpc")) + yield* waitForShard(sharding, shardId) + assert.isTrue(sharding.hasShardId(shardId)) + + const client = makeClient(entityId) + const user = yield* client.GetAzureSqlUser({ id: 42 }) + assert.deepStrictEqual(user, new AzureSqlRpcUser({ id: 42, name: "User 42" })) + + const primaryKey = `rpc/${testRunId}` + assert.strictEqual(yield* client.AzureSqlRequestWithKey({ key: primaryKey }), primaryKey) + assert.strictEqual(yield* client.AzureSqlRequestWithKey({ key: primaryKey }), primaryKey) + }) + .pipe(Effect.provide(clusterRpcLayer("rpc"))), 30000) +}) + +describe.skipIf(!azureSqlUrl)("ClusterAzureSql Workflow", () => { + it.live("resumes a running workflow suspended on a durable deferred", () => + Effect + .gen(function*() { + const sharding = yield* Sharding.Sharding + const payload = { id: `deferred-${testRunId}` } + const executionId = yield* AzureSqlDeferredWorkflow.executionId(payload) + + const fiber = yield* AzureSqlDeferredWorkflow.execute(payload).pipe(Effect.forkScoped) + yield* waitForDeferredWorkflowSuspended(executionId) + + const token = yield* DurableDeferred.tokenFromPayload(AzureSqlDeferred, { + workflow: AzureSqlDeferredWorkflow, + payload + }) + yield* DurableDeferred.done(AzureSqlDeferred, { token, exit: Exit.succeed("resolved") }) + yield* sharding.pollStorage + + const value = yield* Fiber.join(fiber).pipe(Effect.timeout(Duration.seconds(15))) + assert.strictEqual(value, `${payload.id}:resolved`) + assert.strictEqual(yield* waitForDeferredWorkflowComplete(executionId), `${payload.id}:resolved`) + }) + .pipe(Effect.provide(clusterWorkflowLayer())), 30000) +}) + +const GetUserRpc = Rpc.make("AzureSqlGetUser", { + payload: { id: Schema.Number } +}) + +class AzureSqlRpcUser extends Schema.Class("AzureSqlRpcUser")({ + id: Schema.Number, + name: Schema.String +}) {} + +const AzureSqlRpcEntity = Entity + .make("AzureSqlRpcEntity", [ + Rpc.make("GetAzureSqlUser", { + success: AzureSqlRpcUser, + payload: { id: Schema.Number } + }), + Rpc.make("AzureSqlRequestWithKey", { + success: Schema.String, + payload: { key: Schema.String }, + primaryKey: ({ key }) => key + }) + ]) + .annotate(ClusterSchema.ShardGroup, () => testShardGroup("rpc")) + .annotateRpcs(ClusterSchema.Persisted, true) + +const AzureSqlRpcEntityLayer = AzureSqlRpcEntity.toLayer( + Effect.succeed( + AzureSqlRpcEntity.of({ + GetAzureSqlUser: (envelope) => + Effect.succeed(new AzureSqlRpcUser({ id: envelope.payload.id, name: `User ${envelope.payload.id}` })), + AzureSqlRequestWithKey: (envelope) => Effect.succeed(envelope.payload.key) + }) + ) +) + +const AzureSqlDeferred = DurableDeferred.make("ClusterAzureSql/Deferred", { success: Schema.String }) + +const AzureSqlDeferredWorkflow = Workflow + .make({ + name: "ClusterAzureSql/DeferredWorkflow", + payload: { id: Schema.String }, + success: Schema.String, + idempotencyKey: ({ id }) => id + }) + .annotate(ClusterSchema.ShardGroup, () => testShardGroup("wf")) + +const AzureSqlDeferredWorkflowLayer = AzureSqlDeferredWorkflow.toLayer(Effect.fnUntraced(function*({ id }) { + const value = yield* DurableDeferred.await(AzureSqlDeferred) + return `${id}:${value}` +})) + +class StreamRpc extends Rpc.make("AzureSqlStreamTest", { + success: RpcSchema.Stream(Schema.Void, Schema.Never), + payload: { id: Schema.String }, + primaryKey: (value) => value.id.toString() +}) {} + +const makeRequest = Effect.fnUntraced(function*(options?: { + readonly payload?: { readonly id: number } + readonly shardId?: ShardId.ShardId +}) { + const snowflake = yield* Snowflake.Generator + return new Message.OutgoingRequest({ + envelope: Envelope.makeRequest({ + requestId: snowflake.nextUnsafe(), + address: EntityAddress.make({ + shardId: options?.shardId ?? testShardId("df"), + entityType: EntityType.make("test"), + entityId: EntityId.make("1") + }), + tag: GetUserRpc._tag, + payload: options?.payload ?? { id: 123 }, + traceId: "noop", + spanId: "noop", + sampled: false, + headers: Headers.empty + }), + annotations: GetUserRpc.annotations, + context: Context.empty(), + rpc: GetUserRpc, + lastReceivedReply: Option.none(), + respond() { + return Effect.void + } + }) +}) + +const makeStreamRequest = Effect.fnUntraced(function*(id: string, shardId = testShardId("st")) { + const snowflake = yield* Snowflake.Generator + return new Message.OutgoingRequest({ + envelope: Envelope.makeRequest({ + requestId: snowflake.nextUnsafe(), + address: EntityAddress.make({ + shardId, + entityType: EntityType.make("test"), + entityId: EntityId.make("1") + }), + tag: StreamRpc._tag, + payload: StreamRpc.payloadSchema.make({ id }), + traceId: "noop", + spanId: "noop", + sampled: false, + headers: Headers.empty + }), + annotations: StreamRpc.annotations, + context: Context.empty(), + rpc: StreamRpc, + lastReceivedReply: Option.none(), + respond() { + return Effect.void + } + }) +}) + +const makeStreamReply = Effect.fnUntraced(function*(request: Message.OutgoingRequest) { + const snowflake = yield* Snowflake.Generator + return new Reply.ReplyWithContext({ + reply: new Reply.WithExit({ + id: snowflake.nextUnsafe(), + requestId: request.envelope.requestId, + exit: Exit.void + }), + context: request.context, + rpc: request.rpc + }) +}) + +const makeAckChunk = Effect.fnUntraced(function*( + request: Message.OutgoingRequest, + chunk: Reply.ReplyWithContext +) { + const snowflake = yield* Snowflake.Generator + return new Message.OutgoingEnvelope({ + envelope: new Envelope.AckChunk({ + id: snowflake.nextUnsafe(), + address: request.envelope.address, + requestId: chunk.reply.requestId, + replyId: chunk.reply.id + }), + rpc: request.rpc + }) +}) + +const makeChunkReply = Effect.fnUntraced(function*( + request: Message.OutgoingRequest, + sequence: number +) { + const snowflake = yield* Snowflake.Generator + return new Reply.ReplyWithContext({ + reply: new Reply.Chunk({ + id: snowflake.nextUnsafe(), + requestId: request.envelope.requestId, + sequence, + values: [undefined] + }), + context: request.context, + rpc: request.rpc + }) +}) + +const requestPayloadId = (message: Message.Incoming) => { + if (message.envelope._tag !== "Request") { + throw new Error("Expected Request envelope") + } + const envelope = message.envelope + assert(typeof envelope.payload === "object" && envelope.payload !== null) + assert("id" in envelope.payload) + assert.strictEqual(typeof envelope.payload.id, "number") + return envelope.payload.id +} + +const testShardId = (label: string, id = 1) => ShardId.make(testShardGroup(label), id) + +const testShardGroup = (label: string) => `az${testKey}${label}` + +const testRunnerAddress = (offset: number) => RunnerAddress.make("localhost", runnerPortBase + offset) + +const runnerStatus = ( + runners: ReadonlyArray, + address: RunnerAddress.RunnerAddress +) => runners.find(([runner]) => runner.address.host === address.host && runner.address.port === address.port) + +const clusterRpcLayer = (label: string) => { + const shardGroup = testShardGroup(label) + return AzureSqlRpcEntityLayer.pipe( + Layer.provideMerge(Sharding.layer), + Layer.provide(Runners.layerNoop), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(layerAzureSql({ + url: Redacted.make(azureSqlUrl ?? ""), + prefix: tablePrefix, + maxConnections: 16, + connectTimeout: Duration.seconds(15) + })), + Layer.provide(ShardingConfig.layer({ + runnerAddress: Option.some(testRunnerAddress(10)), + shardsPerGroup: 1, + availableShardGroups: [shardGroup], + assignedShardGroups: [shardGroup], + entityTerminationTimeout: 0, + entityMessagePollInterval: 50, + entityReplyPollInterval: 50, + refreshAssignmentsInterval: 0, + sendRetryInterval: 50 + })) + ) +} + +const clusterWorkflowLayer = () => + AzureSqlDeferredWorkflowLayer.pipe( + Layer.provideMerge( + ClusterWorkflowEngine.layer.pipe( + Layer.provideMerge(Sharding.layer), + Layer.provide(Runners.layerNoop), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(layerAzureSql({ + url: Redacted.make(azureSqlUrl ?? ""), + prefix: tablePrefix, + maxConnections: 16, + connectTimeout: Duration.seconds(15) + })), + Layer.provide(ShardingConfig.layer({ + runnerAddress: Option.some(testRunnerAddress(20)), + shardsPerGroup: 1, + availableShardGroups: [testShardGroup("wf")], + assignedShardGroups: [testShardGroup("wf")], + entityTerminationTimeout: 0, + entityMessagePollInterval: 50, + entityReplyPollInterval: 50, + refreshAssignmentsInterval: 0, + sendRetryInterval: 50 + })) + ) + ) + ) + +const waitForDeferredWorkflowSuspended = (executionId: string) => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + for (let i = 0; i < 100; i++) { + yield* sharding.pollStorage + const polled = yield* AzureSqlDeferredWorkflow.poll(executionId) + if (Option.isSome(polled) && polled.value._tag === "Suspended") return + yield* Effect.sleep(Duration.millis(100)) + } + return yield* Effect.fail(new Error(`Workflow ${executionId} did not suspend`)) + }) + +const waitForDeferredWorkflowComplete = (executionId: string) => + Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + for (let i = 0; i < 100; i++) { + yield* sharding.pollStorage + const polled = yield* AzureSqlDeferredWorkflow.poll(executionId) + if (Option.isSome(polled) && polled.value._tag === "Complete") { + const exit = polled.value.exit + assert(Exit.isSuccess(exit)) + return exit.value + } + yield* Effect.sleep(Duration.millis(100)) + } + return yield* Effect.fail(new Error(`Workflow ${executionId} did not complete`)) + }) + +const waitForShard = (sharding: Sharding.Sharding["Service"], shardId: ShardId.ShardId) => + Effect.gen(function*() { + for (let i = 0; i < 30; i++) { + if (sharding.hasShardId(shardId)) return + yield* Effect.sleep(Duration.millis(100)) + } + }) diff --git a/patches/effect@4.0.0-beta.74.patch b/patches/effect@4.0.0-beta.74.patch new file mode 100644 index 0000000000..027dd87605 --- /dev/null +++ b/patches/effect@4.0.0-beta.74.patch @@ -0,0 +1,87 @@ +diff --git a/src/unstable/cluster/SqlMessageStorage.ts b/src/unstable/cluster/SqlMessageStorage.ts +index 69319a0c0e93a90f8aa53d02d4fb5e4dbc0369e2..f98a7237d236b79122f87639334bd25075902fc5 100644 +--- a/src/unstable/cluster/SqlMessageStorage.ts ++++ b/src/unstable/cluster/SqlMessageStorage.ts +@@ -308,37 +308,16 @@ export const make: (options?: { + ON target.message_id = source.message_id + WHEN NOT MATCHED THEN + INSERT ${sql.insert(row)} +- OUTPUT +- inserted.id, +- CASE +- WHEN inserted.id IS NULL THEN ( +- SELECT r.id, r.kind, r.payload +- FROM ${repliesTableSql} r +- WHERE r.id = target.last_reply_id +- ) +- END as reply_id, +- CASE +- WHEN inserted.id IS NULL THEN ( +- SELECT r.kind +- FROM ${repliesTableSql} r +- WHERE r.id = target.last_reply_id +- ) +- END as reply_kind, +- CASE +- WHEN inserted.id IS NULL THEN ( +- SELECT r.payload +- FROM ${repliesTableSql} r +- WHERE r.id = target.last_reply_id +- ) +- END as reply_payload, +- CASE +- WHEN inserted.id IS NULL THEN ( +- SELECT r.sequence +- FROM ${repliesTableSql} r +- WHERE r.id = target.last_reply_id +- ) +- END as reply_sequence; +- `, ++ OUTPUT inserted.id; ++ `.pipe(Effect.flatMap((rows) => { ++ if (rows.length > 0) return Effect.succeed([]) ++ return sql` ++ SELECT m.id, r.id as reply_id, r.kind as reply_kind, r.payload as reply_payload, r.sequence as reply_sequence ++ FROM ${messagesTableSql} m ++ LEFT JOIN ${repliesTableSql} r ON r.id = m.last_reply_id ++ WHERE m.message_id = ${message_id} ++ ` ++ }), sql.withTransaction), + orElse: () => (row, message_id) => + sql` + SELECT m.id, r.id as reply_id, r.kind as reply_kind, r.payload as reply_payload, r.sequence as reply_sequence +@@ -401,6 +380,34 @@ export const make: (options?: { + ) + SELECT * FROM messages ORDER BY rowid ASC + `, ++ mssql: () => (shardIds: ReadonlyArray, now: number) => ++ sql` ++ SELECT m.*, r.id as reply_reply_id, r.kind as reply_kind, r.payload as reply_payload, r.sequence as reply_sequence ++ FROM ${messagesTableSql} m WITH (UPDLOCK, READPAST, ROWLOCK) ++ LEFT JOIN ${repliesTableSql} r ON r.id = m.last_reply_id ++ WHERE m.shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))}) ++ AND NOT EXISTS ( ++ SELECT 1 FROM ${repliesTableSql} ++ WHERE request_id = m.request_id ++ AND (kind = ${replyKindWithExit} OR acked = ${sqlFalse}) ++ ) ++ AND processed = ${sqlFalse} ++ AND (m.last_read IS NULL OR m.last_read < ${tenMinutesAgo}) ++ AND (m.deliver_at IS NULL OR m.deliver_at <= ${sql.literal(String(now))}) ++ ORDER BY m.rowid ASC ++ `.unprepared.pipe( ++ Effect.tap((rows) => { ++ if (rows.length === 0) { ++ return Effect.void ++ } ++ return sql` ++ UPDATE ${messagesTableSql} ++ SET last_read = ${sqlNow} ++ WHERE id IN (${sql.literal(rows.map((row) => row.id).join(","))}) ++ `.unprepared ++ }), ++ sql.withTransaction ++ ), + orElse: () => (shardIds: ReadonlyArray, now: number) => + sql` + SELECT m.*, r.id as reply_reply_id, r.kind as reply_kind, r.payload as reply_payload, r.sequence as reply_sequence diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb3cc7090c..8ad4096d68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ patchedDependencies: '@tanstack/query-core': hash: c98953cf4906c12283dbf72aa23b2066fccbe5186c91cf2ae01043004cf0e3f2 path: patches/@tanstack__query-core.patch + effect@4.0.0-beta.74: + hash: 5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e + path: patches/effect@4.0.0-beta.74.patch ts-plugin-sort-import-suggestions: hash: 4664506575d0f4e799401cb7140b6f15f34a6fe213da6314d68439f7e8d30561 path: patches/ts-plugin-sort-import-suggestions.patch @@ -60,10 +63,10 @@ importers: version: 0.86.2 '@effect/platform-node': specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.9.3) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(ioredis@5.9.3) '@effect/vitest': specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) '@tsconfig/strictest': specifier: ^2.0.8 version: 2.0.8 @@ -96,7 +99,7 @@ importers: version: 0.54.0 effect: specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) effect-app: specifier: workspace:* version: link:packages/effect-app @@ -165,10 +168,10 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.9.3) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(ioredis@5.9.3) effect: specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) js-yaml: specifier: 4.2.0 version: 4.2.0 @@ -205,7 +208,7 @@ importers: version: 4.4.0 effect: specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) nanoid: specifier: ^5.1.11 version: 5.1.11 @@ -357,7 +360,7 @@ importers: dependencies: '@effect/vitest': specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) '@faker-js/faker': specifier: ^8.4.1 version: 8.4.1 @@ -375,7 +378,7 @@ importers: version: 4.1.0 effect: specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) effect-app: specifier: workspace:* version: link:../effect-app @@ -404,9 +407,12 @@ importers: '@azure/service-bus': specifier: ^7.9.5 version: 7.9.5 + '@effect/sql-mssql': + specifier: 4.0.0-beta.74 + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e)) '@effect/sql-sqlite-node': specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e)) '@sentry/node': specifier: 10.55.0 version: 10.55.0 @@ -457,10 +463,10 @@ importers: dependencies: '@effect/atom-vue': specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) '@effect/platform-browser': specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e)) '@formatjs/intl': specifier: ^4.1.12 version: 4.1.12 @@ -481,7 +487,7 @@ importers: version: 5.4.4 effect: specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) effect-app: specifier: workspace:* version: link:../effect-app @@ -494,7 +500,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.74 - version: 4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) '@formatjs/icu-messageformat-parser': specifier: ^3.5.10 version: 3.5.10 @@ -536,7 +542,7 @@ importers: version: 1.32.0(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) effect: specifier: ^4.0.0-beta.74 - version: 4.0.0-beta.74 + version: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) effect-app: specifier: workspace:* version: link:../effect-app @@ -784,6 +790,10 @@ packages: resolution: {integrity: sha512-AWRj+yhw1lybutNcsHJ8syxWXnTLvc3CPwwdCwG1I0I71f25ZcBkxneTeoaB3X57+xl1nO+zJKUqfm0RhpGUFA==} engines: {node: '>=20.0.0'} + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + '@azure/keyvault-common@2.0.0': resolution: {integrity: sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==} engines: {node: '>=18.0.0'} @@ -796,6 +806,18 @@ packages: resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} + '@azure/msal-browser@5.11.0': + resolution: {integrity: sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.6.2': + resolution: {integrity: sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.2.2': + resolution: {integrity: sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==} + engines: {node: '>=20'} + '@azure/service-bus@7.9.5': resolution: {integrity: sha512-R5Af+4jtZZII2snLomaddMyElFtTCBRZp2qERPlP8PuISLU87eFYFM7xWzxjNd0yeiyQUBkamx/ZhOC8eWhCHA==} engines: {node: '>=18.0.0'} @@ -1068,6 +1090,11 @@ packages: effect: ^4.0.0-beta.74 ioredis: ^5.7.0 + '@effect/sql-mssql@4.0.0-beta.74': + resolution: {integrity: sha512-VVqZdLMHaXM2Vhyu/+v9kugfOVJH8zvoZkjfRw9aRijIB0XpzuQkd/CcDq0JDvGCs14XQOccJ15PBiYkQGVHVA==} + peerDependencies: + effect: ^4.0.0-beta.74 + '@effect/sql-sqlite-node@4.0.0-beta.74': resolution: {integrity: sha512-bgKblNG4ky4BjaiHPuyfC9FKF9zygWpCZ8L7d9T4RIq7TKU5o3kmdihL7awpGt5wRAqg1d8nvoM/NJyJNf7bXw==} peerDependencies: @@ -1681,6 +1708,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-joda/core@5.7.0': + resolution: {integrity: sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -2957,6 +2987,9 @@ packages: '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/redis@2.8.32': resolution: {integrity: sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==} @@ -3444,6 +3477,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -3660,6 +3697,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3689,6 +3729,9 @@ packages: resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4086,6 +4129,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -4334,6 +4380,10 @@ packages: event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4910,6 +4960,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -4982,12 +5035,22 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jssha@3.3.1: resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==} jstransformer@1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -5095,9 +5158,30 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -5338,6 +5422,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + native-duplexpair@1.0.0: + resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -5789,6 +5876,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -6089,6 +6180,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6249,6 +6343,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tedious@19.2.1: + resolution: {integrity: sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==} + engines: {node: '>=18.17'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -7202,6 +7300,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 5.11.0 + '@azure/msal-node': 5.2.2 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/keyvault-common@2.0.0': dependencies: '@azure/abort-controller': 2.1.2 @@ -7239,6 +7353,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/msal-browser@5.11.0': + dependencies: + '@azure/msal-common': 16.6.2 + + '@azure/msal-common@16.6.2': {} + + '@azure/msal-node@5.2.2': + dependencies: + '@azure/msal-common': 16.6.2 + jsonwebtoken: 9.0.3 + '@azure/service-bus@7.9.5': dependencies: '@azure/abort-controller': 1.1.0 @@ -7546,8 +7671,8 @@ snapshots: '@effect-app/cli@2.1.0-beta.27(ioredis@5.9.3)': dependencies: - '@effect/platform-node': 4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.9.3) - effect: 4.0.0-beta.74 + '@effect/platform-node': 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(ioredis@5.9.3) + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) js-yaml: 4.1.1 node-watch: 0.7.4 transitivePeerDependencies: @@ -7555,31 +7680,31 @@ snapshots: - ioredis - utf-8-validate - '@effect/atom-vue@4.0.0-beta.74(effect@4.0.0-beta.74)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))': + '@effect/atom-vue@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))': dependencies: - effect: 4.0.0-beta.74 + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) vue: 3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)) '@effect/language-service@0.86.2': {} - '@effect/platform-browser@4.0.0-beta.74(effect@4.0.0-beta.74)': + '@effect/platform-browser@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))': dependencies: - effect: 4.0.0-beta.74 + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) multipasta: 0.2.7 - '@effect/platform-node-shared@4.0.0-beta.74(effect@4.0.0-beta.74)': + '@effect/platform-node-shared@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.74 + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.74(effect@4.0.0-beta.74)(ioredis@5.9.3)': + '@effect/platform-node@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(ioredis@5.9.3)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.74(effect@4.0.0-beta.74) - effect: 4.0.0-beta.74 + '@effect/platform-node-shared': 4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e)) + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) ioredis: 5.9.3 mime: 4.1.0 undici: 8.3.0 @@ -7587,14 +7712,21 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-sqlite-node@4.0.0-beta.74(effect@4.0.0-beta.74)': + '@effect/sql-mssql@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))': + dependencies: + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) + tedious: 19.2.1 + transitivePeerDependencies: + - supports-color + + '@effect/sql-sqlite-node@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))': dependencies: better-sqlite3: 12.10.0 - effect: 4.0.0-beta.74 + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) - '@effect/vitest@4.0.0-beta.74(effect@4.0.0-beta.74)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.74(effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e))(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)))': dependencies: - effect: 4.0.0-beta.74 + effect: 4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e) vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)) '@emnapi/core@1.10.0': @@ -7984,6 +8116,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-joda/core@5.7.0': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -8920,6 +9054,10 @@ snapshots: dependencies: '@types/retry': 0.12.5 + '@types/readable-stream@4.0.23': + dependencies: + '@types/node': 25.9.1 + '@types/redis@2.8.32': dependencies: '@types/node': 25.9.1 @@ -9594,6 +9732,10 @@ snapshots: abbrev@2.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -9826,6 +9968,13 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bl@6.1.6: + dependencies: + '@types/readable-stream': 4.0.23 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.7.0 + bluebird@3.7.2: {} boolbase@1.0.0: {} @@ -9858,6 +10007,8 @@ snapshots: bson@7.2.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: optional: true @@ -10256,6 +10407,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@1.0.4: dependencies: '@one-ini/wasm': 0.1.1 @@ -10263,7 +10418,7 @@ snapshots: minimatch: 9.0.1 semver: 7.7.3 - effect@4.0.0-beta.74: + effect@4.0.0-beta.74(patch_hash=5dc2c5b29416458a38daeb127ed3ecab1c28292885d23e3bc8602cbf9ee2c60e): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.8.0 @@ -10671,6 +10826,8 @@ snapshots: stream-combiner: 0.0.4 through: 2.3.8 + event-target-shim@5.0.1: {} + events@3.3.0: {} expand-template@2.0.3: {} @@ -11259,6 +11416,8 @@ snapshots: js-cookie@3.0.5: {} + js-md4@0.3.2: {} + js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -11340,6 +11499,19 @@ snapshots: jsonify@0.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jssha@3.3.1: {} jstransformer@1.0.0: @@ -11347,6 +11519,17 @@ snapshots: is-promise: 2.2.2 promise: 7.3.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} keyv@4.5.4: @@ -11428,8 +11611,22 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.startcase@4.4.0: {} log-symbols@4.1.0: @@ -11639,6 +11836,8 @@ snapshots: napi-build-utils@2.0.0: {} + native-duplexpair@1.0.0: {} + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -12228,6 +12427,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -12607,6 +12814,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + stackback@0.0.2: {} standard-as-callback@2.1.0: {} @@ -12787,6 +12996,21 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tedious@19.2.1: + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/identity': 4.13.1 + '@azure/keyvault-keys': 4.10.0 + '@js-joda/core': 5.7.0 + '@types/node': 25.9.1 + bl: 6.1.6 + iconv-lite: 0.7.2 + js-md4: 0.3.2 + native-duplexpair: 1.0.0 + sprintf-js: 1.1.3 + transitivePeerDependencies: + - supports-color + term-size@2.2.1: {} terser-webpack-plugin@5.3.16(esbuild@0.28.0)(webpack@5.104.1(esbuild@0.28.0)): From 1212c3d58fef17c4791ff238054a7c7f2f959ed1 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Tue, 2 Jun 2026 20:43:21 +0200 Subject: [PATCH 2/2] test(infra): add Azure SQL cluster parity cases --- packages/infra/test/cluster-azure-sql.test.ts | 113 +++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/infra/test/cluster-azure-sql.test.ts b/packages/infra/test/cluster-azure-sql.test.ts index 7ec3fc2cf3..01b6ea4b2f 100644 --- a/packages/infra/test/cluster-azure-sql.test.ts +++ b/packages/infra/test/cluster-azure-sql.test.ts @@ -120,6 +120,87 @@ describe.skipIf(!azureSqlUrl)("ClusterAzureSql MessageStorage", () => { const error = yield* Effect.flip(storage.saveReply(yield* makeStreamReply(request))) assert.strictEqual(error._tag, "PersistenceError") + + const duplicate = yield* storage.saveRequest(yield* makeStreamRequest(`duplicate-with-exit/${testRunId}`)) + assert(duplicate._tag === "Duplicate" && Option.isSome(duplicate.lastReceivedReply)) + assert.strictEqual(duplicate.lastReceivedReply.value._tag, "WithExit") + assert.strictEqual((yield* storage.unprocessedMessagesById([request.envelope.requestId])).length, 0) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("fails on duplicate chunk sequence", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const request = yield* makeStreamRequest(`duplicate-chunk-sequence/${testRunId}`) + yield* storage.saveRequest(request) + yield* storage.saveReply(yield* makeChunkReply(request, 0)) + + const error = yield* Effect.flip(storage.saveReply(yield* makeChunkReply(request, 0))) + assert.strictEqual(error._tag, "PersistenceError") + assert.strictEqual((yield* storage.repliesFor([request])).length, 1) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("returns only the request by id after an acked chunk", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const request = yield* makeStreamRequest(`ack-by-id/${testRunId}`, testShardId("ak")) + yield* storage.saveRequest(request) + + const chunk = yield* makeChunkReply(request, 0) + yield* storage.saveReply(chunk) + yield* storage.saveEnvelope(yield* makeAckChunk(request, chunk)) + + const messages = yield* storage.unprocessedMessagesById([request.envelope.requestId]) + assert.strictEqual(messages.length, 1) + assert.strictEqual(messages[0]?.envelope._tag, "Request") + assert.strictEqual(messages[0]?.envelope.requestId, request.envelope.requestId) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("clears terminal replies and makes the request processable again", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const request = yield* makeStreamRequest(`clear-replies/${testRunId}`, testShardId("cr")) + yield* storage.saveRequest(request) + yield* storage.saveReply(yield* makeStreamReply(request)) + + assert.strictEqual((yield* storage.unprocessedMessagesById([request.envelope.requestId])).length, 0) + assert.strictEqual((yield* storage.repliesFor([request])).length, 1) + + yield* storage.clearReplies(request.envelope.requestId) + + const messages = yield* storage.unprocessedMessagesById([request.envelope.requestId]) + assert.strictEqual(messages.length, 1) + assert.strictEqual(messages[0]?.envelope._tag, "Request") + assert.strictEqual((yield* storage.repliesFor([request])).length, 0) + }) + .pipe(Effect.provide(layerFor())), 20000) + + it.effect("clears all message and reply state for an address", () => + Effect + .gen(function*() { + const storage = yield* MessageStorage.MessageStorage + const primaryKey = `clear-address/${testRunId}` + const request = yield* makeStreamRequest(primaryKey, testShardId("ca"), EntityId.make(`clear-${testRunId}`)) + yield* storage.saveRequest(request) + yield* storage.saveReply(yield* makeChunkReply(request, 0)) + yield* storage.saveReply(yield* makeStreamReply(request)) + + yield* storage.clearAddress(request.envelope.address) + + assert.strictEqual((yield* storage.unprocessedMessagesById([request.envelope.requestId])).length, 0) + assert.strictEqual((yield* storage.repliesForUnfiltered([request.envelope.requestId])).length, 0) + assert.isTrue(Option.isNone( + yield* storage.requestIdForPrimaryKey({ + address: request.envelope.address, + tag: request.envelope.tag, + id: primaryKey + }) + )) }) .pipe(Effect.provide(layerFor())), 20000) }) @@ -168,6 +249,30 @@ describe.skipIf(!azureSqlUrl)("ClusterAzureSql RunnerStorage", () => { assert.strictEqual(new Set(acquiredIds).size, shards.length) }) .pipe(Effect.provide(layerFor())), 20000) + + it.effect("acquires, refreshes, releases, and re-acquires shard locks", () => + Effect + .gen(function*() { + const storage = yield* RunnerStorage.RunnerStorage + const runnerAddress1 = testRunnerAddress(4) + const runnerAddress2 = testRunnerAddress(5) + const shards = [ + testShardId("rr", 1), + testShardId("rr", 2), + testShardId("rr", 3) + ] + + assert.deepStrictEqual((yield* storage.acquire(runnerAddress1, shards)).map((shard) => shard.id), [1, 2, 3]) + assert.deepStrictEqual((yield* storage.acquire(runnerAddress2, shards)).map((shard) => shard.id), []) + assert.deepStrictEqual((yield* storage.refresh(runnerAddress1, shards)).map((shard) => shard.id), [1, 2, 3]) + + yield* storage.release(runnerAddress1, testShardId("rr", 2)) + assert.deepStrictEqual((yield* storage.acquire(runnerAddress2, shards)).map((shard) => shard.id), [2]) + + yield* storage.releaseAll(runnerAddress1) + assert.deepStrictEqual((yield* storage.acquire(runnerAddress2, shards)).map((shard) => shard.id), [1, 2, 3]) + }) + .pipe(Effect.provide(layerFor())), 20000) }) describe.skipIf(!azureSqlUrl)("ClusterAzureSql Sharding RPC", () => { @@ -303,7 +408,11 @@ const makeRequest = Effect.fnUntraced(function*(options?: { }) }) -const makeStreamRequest = Effect.fnUntraced(function*(id: string, shardId = testShardId("st")) { +const makeStreamRequest = Effect.fnUntraced(function*( + id: string, + shardId = testShardId("st"), + entityId = EntityId.make("1") +) { const snowflake = yield* Snowflake.Generator return new Message.OutgoingRequest({ envelope: Envelope.makeRequest({ @@ -311,7 +420,7 @@ const makeStreamRequest = Effect.fnUntraced(function*(id: string, shardId = test address: EntityAddress.make({ shardId, entityType: EntityType.make("test"), - entityId: EntityId.make("1") + entityId }), tag: StreamRpc._tag, payload: StreamRpc.payloadSchema.make({ id }),