diff --git a/.changeset/cypher-codegen-typecheck-generated-output.md b/.changeset/cypher-codegen-typecheck-generated-output.md new file mode 100644 index 0000000..6ad02a8 --- /dev/null +++ b/.changeset/cypher-codegen-typecheck-generated-output.md @@ -0,0 +1,15 @@ +--- +"@evryg/effect-cypher-codegen": patch +--- + +Make generated query code type-check under Effect v4. + +- Row decoding no longer emits an untyped `Neo4jRecordToObject` schema transform. + `record.toObject()` is called in the Effect pipeline and each row is validated + directly with `Schema.Array(Row)`, so the row struct fully type-checks the + decoded shape. +- The generated `TemporalString` transform now carries explicit + `SchemaTransformation.transform` type parameters, matching its + `Schema.Unknown` source so it satisfies `decodeTo`'s getter signature. +- Single-file modules now annotate query parameters with their types instead of + relying on an implicit `any`, the same way the barrel output already did. diff --git a/packages/cypher-codegen/package.json b/packages/cypher-codegen/package.json index 51f5003..076b4c2 100644 --- a/packages/cypher-codegen/package.json +++ b/packages/cypher-codegen/package.json @@ -51,6 +51,7 @@ "@evryg/effect-vitest-neo4j": "workspace:^", "antlr-ng": "^1.0.10", "effect": "4.0.0-beta.78", - "neo4j-driver": "^6.0.1" + "neo4j-driver": "^6.0.1", + "typescript": "5.9.3" } } diff --git a/packages/cypher-codegen/src/Register.ts b/packages/cypher-codegen/src/Register.ts index b57822e..197a76f 100644 --- a/packages/cypher-codegen/src/Register.ts +++ b/packages/cypher-codegen/src/Register.ts @@ -26,11 +26,11 @@ registerHooks({ load(url, context, nextLoad) { if (context.format === "cypher" || url.endsWith(".cypher")) { const source = readFileSync(fileURLToPath(url), "utf-8").trim() - const columns = schema ? analyzeQuery(source, schema).columns : undefined + const analysis = schema ? analyzeQuery(source, schema) : undefined return { format: "module", shortCircuit: true, - source: generateModule(source, columns) + source: generateModule(source, analysis?.columns, analysis?.params) } } return nextLoad(url, context) diff --git a/packages/cypher-codegen/src/VitePlugin.ts b/packages/cypher-codegen/src/VitePlugin.ts index 87bf09f..918f273 100644 --- a/packages/cypher-codegen/src/VitePlugin.ts +++ b/packages/cypher-codegen/src/VitePlugin.ts @@ -13,8 +13,8 @@ export const cypherPlugin = (opts?: { schema?: GraphSchema }) => ({ transform(_code: string, id: string) { if (id.endsWith(".cypher")) { const content = readFileSync(id, "utf-8").trim() - const columns = opts?.schema ? analyzeQuery(content, opts.schema).columns : undefined - return { code: generateModule(content, columns), map: null } + const analysis = opts?.schema ? analyzeQuery(content, opts.schema) : undefined + return { code: generateModule(content, analysis?.columns, analysis?.params), map: null } } } }) diff --git a/packages/cypher-codegen/src/backend/CypherCodegen.node.unit.test.ts b/packages/cypher-codegen/src/backend/CypherCodegen.node.unit.test.ts index bca4407..7a88850 100644 --- a/packages/cypher-codegen/src/backend/CypherCodegen.node.unit.test.ts +++ b/packages/cypher-codegen/src/backend/CypherCodegen.node.unit.test.ts @@ -81,26 +81,28 @@ describe("generateModule with columns (typed codegen)", () => { expect(source).toContain("Schema.String") }) - it("emits Neo4jRecordToObject transform with toObject()", () => { + it("maps records through .toObject() in the query pipeline (no record-level schema transform)", () => { const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [ col("fqcn", S("String"), false) ]) - expect(source).toContain("Neo4jRecordToObject") - expect(source).toContain(".toObject()") + expect(source).not.toContain("Neo4jRecordToObject") + expect(source).toContain("records.map((r) => r.toObject())") }) - it("composes Neo4jRecordToObject with Row via decodeTo", () => { + it("decodes rows directly against the struct via Schema.Array(Row)", () => { const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [ col("fqcn", S("String"), false) ]) - expect(source).toContain("Schema.Array(Neo4jRecordToObject.pipe(Schema.decodeTo(Row)))") + expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(Row))") }) - it("passes decoder directly to Effect.map (no lambda)", () => { + it("maps records through .toObject() before decoding", () => { const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [ col("fqcn", S("String"), false) ]) - expect(source).toContain("Effect.map(neo4j.query(cypher), decodeRows)") + expect(source).toContain( + "Effect.map(neo4j.query(cypher), (records) => decodeRows(records.map((r) => r.toObject())))" + ) }) it("imports Neo4jInt from effect-neo4j for Long columns", () => { @@ -134,12 +136,23 @@ describe("generateModule with columns (typed codegen)", () => { }) it("handles params AND columns together", () => { - const source = generateModule("MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name", [ - col("name", S("String"), false) - ]) + const source = generateModule( + "MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name", + [col("name", S("String"), false)], + [{ name: "fqcn", type: "String", nullable: false }] + ) expect(source).toContain("{ fqcn }") expect(source).toContain("Schema.Struct") - expect(source).toContain("Neo4jRecordToObject.pipe(Schema.decodeTo(Row") + expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(Row))") + }) + + it("annotates module params with their types (no implicit any)", () => { + const source = generateModule( + "MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name", + [col("name", S("String"), false)], + [{ name: "fqcn", type: "String", nullable: false }] + ) + expect(source).toContain("export const query = ({ fqcn }: { fqcn: string }) =>") }) it("emits Neo4jValue for UnknownType columns (escape hatch)", () => { @@ -247,7 +260,7 @@ describe("generateBarrel — typed params", () => { expect(source).toContain("{ note }: { note: string | null }") }) - it("emits shared Neo4jRecordToObject transform once", () => { + it("does not emit a record-level schema transform (toObject() happens in the pipeline)", () => { const entry: BarrelEntry = { filename: "Foo.cypher", cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn", @@ -255,12 +268,11 @@ describe("generateBarrel — typed params", () => { params: [] } const source = generateBarrel([entry]) - const matches = source.match(/Neo4jRecordToObject = Schema\.Unknown\.pipe\(Schema\.decodeTo/g) - expect(matches).toHaveLength(1) - expect(source).toContain(".toObject()") + expect(source).not.toContain("Neo4jRecordToObject") + expect(source).toContain("records.map((r) => r.toObject())") }) - it("composes Neo4jRecordToObject with row schema via decodeTo", () => { + it("decodes rows directly against the row struct via Schema.Array(Row)", () => { const entry: BarrelEntry = { filename: "Foo.cypher", cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn", @@ -268,10 +280,10 @@ describe("generateBarrel — typed params", () => { params: [] } const source = generateBarrel([entry]) - expect(source).toContain("Schema.Array(Neo4jRecordToObject.pipe(Schema.decodeTo(fooQueryRow)))") + expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(fooQueryRow))") }) - it("passes decoder directly to Effect.map (no lambda)", () => { + it("maps records through .toObject() before decoding", () => { const entry: BarrelEntry = { filename: "Foo.cypher", cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn", @@ -279,7 +291,9 @@ describe("generateBarrel — typed params", () => { params: [] } const source = generateBarrel([entry]) - expect(source).toContain("Effect.map(neo4j.query(fooQueryCypher), decodeFooQuery)") + expect(source).toContain( + "Effect.map(neo4j.query(fooQueryCypher), (records) => decodeFooQuery(records.map((r) => r.toObject())))" + ) }) it("emits Neo4jValue for UnknownType columns in barrel", () => { @@ -319,7 +333,7 @@ describe("generateBarrel — no any in generated code", () => { }) describe("generateModule — no any in generated code", () => { - it("Neo4jRecordToObject decode param is not typed as any", () => { + it("typed row decoder does not emit an any-typed param", () => { const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [ col("fqcn", S("String"), false) ]) diff --git a/packages/cypher-codegen/src/backend/CypherCodegen.ts b/packages/cypher-codegen/src/backend/CypherCodegen.ts index bf95796..ceb30d4 100644 --- a/packages/cypher-codegen/src/backend/CypherCodegen.ts +++ b/packages/cypher-codegen/src/backend/CypherCodegen.ts @@ -142,21 +142,50 @@ function tsTypeFor(type: Neo4jType): string { } } +// ── Parameter signatures ── + +/** + * The parameter signature for a generated query function: the declaration + * (typed when param types are known, so the generated code never relies on an + * implicit `any`) and the call-site destructure. `undefined` when the query + * takes no parameters. + */ +function queryParams( + cypher: string, + params?: ReadonlyArray +): { readonly decl: string; readonly call: string } | undefined { + if (params && params.length > 0) { + const call = `{ ${params.map((p) => p.name).join(", ")} }` + const annotation = params + .map((p) => `${p.name}: ${tsTypeFor(p.type)}${p.nullable ? " | null" : ""}`) + .join("; ") + return { decl: `${call}: { ${annotation} }`, call } + } + const names = extractParams(cypher) + if (names.length === 0) return undefined + const destructure = `{ ${names.join(", ")} }` + return { decl: destructure, call: destructure } +} + // ── Module generation ── /** * @since 0.0.1 * @category codegen */ -export function generateModule(cypher: string, columns?: ReadonlyArray): string { +export function generateModule( + cypher: string, + columns?: ReadonlyArray, + params?: ReadonlyArray +): string { if (!columns || columns.length === 0) { - return generateUntypedModule(cypher) + return generateUntypedModule(cypher, params) } - return generateTypedModule(cypher, columns) + return generateTypedModule(cypher, columns, params) } -function generateUntypedModule(cypher: string): string { - const params = extractParams(cypher) +function generateUntypedModule(cypher: string, params?: ReadonlyArray): string { + const sig = queryParams(cypher, params) const lines = [ `import { Effect } from "effect";`, `import { Neo4jClient } from "@evryg/effect-neo4j";`, @@ -165,25 +194,30 @@ function generateUntypedModule(cypher: string): string { `` ] - if (params.length === 0) { + if (sig === undefined) { lines.push(`export const query = () =>`) lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(cypher));`) } else { - const destructure = `{ ${params.join(", ")} }` - lines.push(`export const query = (${destructure}) =>`) - lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(cypher, ${destructure}));`) + lines.push(`export const query = (${sig.decl}) =>`) + lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(cypher, ${sig.call}));`) } return lines.join("\n") + "\n" } -function generateTypedModule(cypher: string, columns: ReadonlyArray): string { +function generateTypedModule( + cypher: string, + columns: ReadonlyArray, + params?: ReadonlyArray +): string { // UnknownType columns emit Neo4jValue (escape hatch for unlabeled nodes) - const params = extractParams(cypher) + const sig = queryParams(cypher, params) const lines: Array = [] // Imports - lines.push(`import { Effect, Schema, SchemaTransformation } from "effect";`) + lines.push( + `import { Effect, Schema${needsTemporalString(columns) ? ", SchemaTransformation" : ""} } from "effect";` + ) const neo4jImports = neo4jSchemaImports(columns) if (neo4jImports.length > 0) { lines.push(`import { Neo4jClient, ${neo4jImports.join(", ")} } from "@evryg/effect-neo4j";`) @@ -196,20 +230,13 @@ function generateTypedModule(cypher: string, columns: ReadonlyArray (rec as { toObject(): Record }).toObject(), encode: (obj) => obj }),` - ) - lines.push(`));`) - lines.push(``) - // Temporal string transform (only if needed) if (needsTemporalString(columns)) { lines.push(`const TemporalString = Schema.Unknown.pipe(Schema.decodeTo(`) lines.push(` Schema.String,`) - lines.push(` SchemaTransformation.transform({ decode: (v) => (v).toString(), encode: (s) => s }),`) + lines.push( + ` SchemaTransformation.transform({ decode: (v) => (v as { toString(): string }).toString(), encode: (s) => s }),` + ) lines.push(`));`) lines.push(``) } @@ -222,26 +249,20 @@ function generateTypedModule(cypher: string, columns: ReadonlyArray`) - } else { - const destructure = `{ ${params.join(", ")} }` - lines.push(`export const query = (${destructure}) =>`) - } + lines.push(`export const query = (${sig?.decl ?? ""}) =>`) lines.push(` Effect.flatMap(Neo4jClient, (neo4j) =>`) - if (params.length === 0) { - lines.push(` Effect.map(neo4j.query(cypher), decodeRows));`) + if (sig === undefined) { + lines.push(` Effect.map(neo4j.query(cypher), (records) => decodeRows(records.map((r) => r.toObject()))));`) } else { - const destructure = `{ ${params.join(", ")} }` - lines.push(` Effect.map(neo4j.query(cypher, ${destructure}), decodeRows));`) + lines.push( + ` Effect.map(neo4j.query(cypher, ${sig.call}), (records) => decodeRows(records.map((r) => r.toObject()))));` + ) } lines.push(``) @@ -273,10 +294,10 @@ export interface BarrelEntry { export function generateBarrel(entries: ReadonlyArray): string { const lines: Array = [] - const anyHasColumns = entries.some((e) => e.columns.length > 0) + const anyNeedTemporal = entries.some((e) => needsTemporalString(e.columns)) lines.push(`// Auto-generated by cypher-codegen — do not edit`) - lines.push(`import { Effect, Schema${anyHasColumns ? ", SchemaTransformation" : ""} } from "effect"`) + lines.push(`import { Effect, Schema${anyNeedTemporal ? ", SchemaTransformation" : ""} } from "effect"`) // Collect all neo4j schema imports needed across all entries const allColumns = entries.flatMap((e) => e.columns) @@ -288,24 +309,12 @@ export function generateBarrel(entries: ReadonlyArray): string { } lines.push(``) - // Shared transforms (emit once) - if (anyHasColumns) { - lines.push(`const Neo4jRecordToObject = Schema.Unknown.pipe(Schema.decodeTo(`) - lines.push(` Schema.Unknown,`) - lines.push( - ` SchemaTransformation.transform({ decode: (rec) => (rec as { toObject(): Record }).toObject(), encode: (obj) => obj }),` - ) - lines.push(`))`) - lines.push(``) - } - - const anyNeedTemporal = entries.some((e) => needsTemporalString(e.columns)) - + // Shared temporal transform (emit once, only if needed) if (anyNeedTemporal) { lines.push(`const TemporalString = Schema.Unknown.pipe(Schema.decodeTo(`) lines.push(` Schema.String,`) lines.push( - ` SchemaTransformation.transform({ decode: (v) => (v as { toString(): string }).toString(), encode: (s: string) => s }),` + ` SchemaTransformation.transform({ decode: (v) => (v as { toString(): string }).toString(), encode: (s) => s }),` ) lines.push(`))`) lines.push(``) @@ -327,6 +336,8 @@ export function generateBarrel(entries: ReadonlyArray): string { lines.push(`const ${name}Cypher = ${JSON.stringify(entry.cypher)}`) lines.push(``) + const sig = queryParams(entry.cypher, entry.params) + if (hasColumns) { const decodeName = `decode${name.charAt(0).toUpperCase() + name.slice(1)}` @@ -338,33 +349,26 @@ export function generateBarrel(entries: ReadonlyArray): string { lines.push(``) lines.push(`export type ${name.charAt(0).toUpperCase() + name.slice(1)}Row = typeof ${name}Row.Type`) lines.push(``) - lines.push(`const ${decodeName} = Schema.decodeUnknownSync(`) - lines.push(` Schema.Array(Neo4jRecordToObject.pipe(Schema.decodeTo(${name}Row)))`) - lines.push(`)`) + lines.push(`const ${decodeName} = Schema.decodeUnknownSync(Schema.Array(${name}Row))`) lines.push(``) - if (entry.params.length === 0) { - lines.push(`export const ${name} = () =>`) - lines.push(` Effect.flatMap(Neo4jClient, (neo4j) =>`) - lines.push(` Effect.map(neo4j.query(${name}Cypher), ${decodeName}))`) + lines.push(`export const ${name} = (${sig?.decl ?? ""}) =>`) + lines.push(` Effect.flatMap(Neo4jClient, (neo4j) =>`) + if (sig === undefined) { + lines.push( + ` Effect.map(neo4j.query(${name}Cypher), (records) => ${decodeName}(records.map((r) => r.toObject()))))` + ) } else { - const destructure = `{ ${entry.params.map((p) => p.name).join(", ")} }` - const typeAnnotation = entry.params.map((p) => `${p.name}: ${tsTypeFor(p.type)}${p.nullable ? " | null" : ""}`) - .join("; ") - lines.push(`export const ${name} = (${destructure}: { ${typeAnnotation} }) =>`) - lines.push(` Effect.flatMap(Neo4jClient, (neo4j) =>`) - lines.push(` Effect.map(neo4j.query(${name}Cypher, ${destructure}), ${decodeName}))`) + lines.push( + ` Effect.map(neo4j.query(${name}Cypher, ${sig.call}), (records) => ${decodeName}(records.map((r) => r.toObject()))))` + ) } } else { - if (entry.params.length === 0) { - lines.push(`export const ${name} = () =>`) + lines.push(`export const ${name} = (${sig?.decl ?? ""}) =>`) + if (sig === undefined) { lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(${name}Cypher))`) } else { - const destructure = `{ ${entry.params.map((p) => p.name).join(", ")} }` - const typeAnnotation = entry.params.map((p) => `${p.name}: ${tsTypeFor(p.type)}${p.nullable ? " | null" : ""}`) - .join("; ") - lines.push(`export const ${name} = (${destructure}: { ${typeAnnotation} }) =>`) - lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(${name}Cypher, ${destructure}))`) + lines.push(` Effect.flatMap(Neo4jClient, (neo4j) => neo4j.query(${name}Cypher, ${sig.call}))`) } } diff --git a/packages/cypher-codegen/src/backend/CypherCodegen.typecheck.node.unit.test.ts b/packages/cypher-codegen/src/backend/CypherCodegen.typecheck.node.unit.test.ts new file mode 100644 index 0000000..9197678 --- /dev/null +++ b/packages/cypher-codegen/src/backend/CypherCodegen.typecheck.node.unit.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "@effect/vitest" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import ts from "typescript" +import type { ResolvedColumn, ResolvedParam } from "../frontend/QueryAnalyzer.js" +import { type CypherType, ListType, MapType, ScalarType, UnknownType } from "../types/CypherType.js" +import { type BarrelEntry, generateBarrel, generateModule } from "./CypherCodegen.js" + +// Type-checks generated query source against the repo's real compiler options. +// +// The unit tests in CypherCodegen.node.unit.test.ts only assert on the emitted +// *string*; they never compile it. The regression these guards exist for — +// generated `queries.ts` failing to type-check under Effect v4 — therefore could +// not surface as a string assertion. These tests close that gap by running the +// TypeScript checker over the generator's output and asserting zero diagnostics. + +const here = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(here, "../../../..") +const virtualPath = path.join(here, "__codegen_typecheck__.ts") + +const compilerOptions = (): ts.CompilerOptions => { + const configFile = ts.readConfigFile(path.join(repoRoot, "tsconfig.base.json"), ts.sys.readFile) + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot) + return { + ...parsed.options, + // Single in-memory file: drop build-mode flags and emit. tsBuildInfoFile is + // omitted rather than set to undefined (exactOptionalPropertyTypes forbids it). + composite: false, + incremental: false, + noEmit: true, + skipLibCheck: true, + types: [] + } +} + +const typeCheck = (source: string): ReadonlyArray => { + const options = compilerOptions() + const host = ts.createCompilerHost(options, true) + const getSourceFile = host.getSourceFile.bind(host) + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => + path.resolve(fileName) === virtualPath + ? ts.createSourceFile(fileName, source, languageVersion, true) + : getSourceFile(fileName, languageVersion, onError, shouldCreate) + + const program = ts.createProgram([virtualPath], options, host) + const sourceFile = program.getSourceFile(virtualPath)! + return [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)] + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")) +} + +// ── Helpers ── + +const scalar = (t: string): ScalarType => new ScalarType({ scalarType: t as ScalarType["scalarType"] }) + +const col = (name: string, type: CypherType, nullable: boolean): ResolvedColumn => ({ name, type, nullable }) + +const param = (name: string, type: ResolvedParam["type"], nullable: boolean): ResolvedParam => ({ + name, + type, + nullable +}) + +// Columns covering every schema-emitting branch: scalars, Neo4jInt (Long), +// nullable, temporal (TemporalString + SchemaTransformation), list, map, and +// the Neo4jValue escape hatch. +const allColumns: ReadonlyArray = [ + col("fqcn", scalar("String"), false), + col("count", scalar("Long"), false), + col("ratio", scalar("Double"), false), + col("active", scalar("Boolean"), true), + col("createdAt", scalar("DateTime"), false), + col("tags", ListType(scalar("String")), false), + col("meta", MapType([{ name: "label", value: scalar("String") }]), false), + col("blob", new UnknownType(), false) +] + +describe("generated source type-checks under Effect v4", () => { + it("generateModule output compiles with no type errors", () => { + const source = generateModule( + "MATCH (c:Class {fqcn: $fqcn}) WHERE c.count > $min RETURN c.fqcn AS fqcn", + allColumns, + [param("fqcn", "String", false), param("min", "Long", true)] + ) + expect(typeCheck(source)).toEqual([]) + }) + + it("generateBarrel output compiles with no type errors", () => { + const entries: ReadonlyArray = [ + { + filename: "Classes.cypher", + cypher: "MATCH (c:Class) WHERE c.label = $label RETURN c.fqcn AS fqcn, count(c) AS count", + columns: [col("fqcn", scalar("String"), false), col("count", scalar("Long"), false)], + params: [param("label", "String", false)] + }, + { + filename: "Raw.cypher", + cypher: "MATCH (c:Class) RETURN c", + columns: [], + params: [] + }, + { + filename: "Events.cypher", + cypher: "MATCH (e:Event) RETURN e.at AS at", + columns: [col("at", scalar("DateTime"), false)], + params: [] + } + ] + expect(typeCheck(generateBarrel(entries))).toEqual([]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbf5862..9ef01c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: neo4j-driver: specifier: ^6.0.1 version: 6.0.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 publishDirectory: dist packages/demo: @@ -282,7 +285,7 @@ importers: dependencies: vitest: specifier: ^3.2.4 - version: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) + version: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.13.0)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) devDependencies: '@evryg/effect-neo4j': specifier: workspace:^ @@ -7531,6 +7534,14 @@ snapshots: '@types/node': 24.12.2 optional: true + '@inquirer/confirm@5.1.21(@types/node@24.13.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.0) + '@inquirer/type': 3.0.10(@types/node@24.13.0) + optionalDependencies: + '@types/node': 24.13.0 + optional: true + '@inquirer/core@10.3.2(@types/node@24.12.2)': dependencies: '@inquirer/ansi': 1.0.2 @@ -7545,6 +7556,20 @@ snapshots: '@types/node': 24.12.2 optional: true + '@inquirer/core@10.3.2(@types/node@24.13.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.0 + optional: true + '@inquirer/external-editor@1.0.3(@types/node@24.12.2)': dependencies: chardet: 2.1.1 @@ -7560,6 +7585,11 @@ snapshots: '@types/node': 24.12.2 optional: true + '@inquirer/type@3.0.10(@types/node@24.13.0)': + optionalDependencies: + '@types/node': 24.13.0 + optional: true + '@ioredis/commands@1.10.0': {} '@isaacs/cliui@9.0.0': {} @@ -8154,16 +8184,16 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/browser@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3))(vitest@3.2.4)': + '@vitest/browser@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) + vitest: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) ws: 8.20.0 optionalDependencies: playwright: 1.58.2 @@ -8172,18 +8202,17 @@ snapshots: - msw - utf-8-validate - vite - optional: true - '@vitest/browser@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4)': + '@vitest/browser@3.2.4(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + vitest: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.13.0)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) ws: 8.20.0 optionalDependencies: playwright: 1.58.2 @@ -8192,6 +8221,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/coverage-v8@3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4)': dependencies: @@ -8222,23 +8252,23 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@24.12.2)(typescript@5.9.3) - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) - '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.10(@types/node@24.12.2)(typescript@5.9.3) - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + msw: 2.12.10(@types/node@24.13.0)(typescript@5.9.3) + vite: 6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -10599,6 +10629,32 @@ snapshots: - '@types/node' optional: true + msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.13.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.1 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + multipasta@0.2.7: {} mute-stream@2.0.0: @@ -12043,13 +12099,13 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-node@3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -12064,13 +12120,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -12085,7 +12141,7 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3): + vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -12098,9 +12154,9 @@ snapshots: fsevents: 2.3.3 terser: 5.32.0 tsx: 4.17.0 - yaml: 2.8.3 + yaml: 2.9.0 - vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): + vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -12109,17 +12165,17 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.13.0 fsevents: 2.3.3 terser: 5.32.0 tsx: 4.17.0 yaml: 2.9.0 - vitest@3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3): + vitest@3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12137,13 +12193,13 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 4.0.3 '@types/node': 24.12.2 - '@vitest/browser': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.8.3))(vitest@3.2.4) + '@vitest/browser': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4) transitivePeerDependencies: - jiti - less @@ -12158,11 +12214,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): + vitest@3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.13.0)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12180,13 +12236,13 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 4.0.3 - '@types/node': 24.12.2 - '@vitest/browser': 3.2.4(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.12.2)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4) + '@types/node': 24.13.0 + '@vitest/browser': 3.2.4(msw@2.12.10(@types/node@24.13.0)(typescript@5.9.3))(playwright@1.58.2)(vite@6.4.2(@types/node@24.13.0)(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0))(vitest@3.2.4) transitivePeerDependencies: - jiti - less