From 7aeff7dae5bb688e896712a2bb63e3653f3c893a Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 9 Nov 2026 14:27:48 +0000 Subject: [PATCH] refactor: restructure zod, testing, mcp, migrate packages to multi-file DDD - Remove stale package-level LICENSE.md files (central root LICENSE.md applies) - Split each package into domain-based directories with index.ts barrel files - Replace any export * with explicit named exports in barrels - Fix tsup dts build for composite tsconfig: dts: false + tsc declaration step - All tests pass, typechecks clean, lint green across all four packages --- packages/mcp/LICENSE.md | 202 -------- packages/mcp/package.json | 5 +- packages/mcp/src/config.ts | 10 + packages/mcp/src/index.ts | 252 +--------- packages/mcp/src/resources/index.ts | 5 + .../mcp/src/resources/resource-handler.ts | 40 ++ packages/mcp/src/server/index.ts | 4 + packages/mcp/src/server/mcp-server.ts | 86 ++++ packages/mcp/src/tools/index.ts | 6 + packages/mcp/src/tools/tool-error.ts | 10 + packages/mcp/src/tools/tool-handler.ts | 128 ++++++ packages/mcp/tsup.config.ts | 2 +- packages/migrate/LICENSE.md | 202 -------- packages/migrate/package.json | 5 +- packages/migrate/src/index.ts | 430 +----------------- packages/migrate/src/model/index.ts | 4 + packages/migrate/src/model/migration.ts | 18 + packages/migrate/src/runner/index.ts | 5 + .../migrate/src/runner/migration-options.ts | 19 + .../migrate/src/runner/migration-runner.ts | 188 ++++++++ packages/migrate/src/schema/index.ts | 5 + packages/migrate/src/schema/schema-diff.ts | 168 +++++++ packages/migrate/src/template/index.ts | 5 + .../src/template/migration-template.ts | 39 ++ packages/migrate/src/version/index.ts | 4 + packages/migrate/src/version/version.ts | 6 + packages/migrate/tsup.config.ts | 2 +- packages/testing/LICENSE.md | 202 -------- packages/testing/package.json | 5 +- packages/testing/src/driver/index.ts | 4 + packages/testing/src/driver/mock-driver.ts | 168 +++++++ packages/testing/src/fixtures/index.ts | 5 + .../testing/src/fixtures/test-fixtures.ts | 58 +++ packages/testing/src/generators/index.ts | 12 + packages/testing/src/generators/random.ts | 39 ++ packages/testing/src/index.ts | 316 +------------ .../testing/src/matchers/graph-matchers.ts | 38 ++ packages/testing/src/matchers/index.ts | 4 + packages/testing/tsup.config.ts | 2 +- packages/zod/LICENSE.md | 202 -------- packages/zod/package.json | 5 +- packages/zod/src/index.ts | 207 +-------- packages/zod/src/schema/factory.ts | 46 ++ packages/zod/src/schema/index.ts | 6 + packages/zod/src/schema/property.ts | 71 +++ packages/zod/src/schema/types.ts | 23 + packages/zod/src/validation/hooks.ts | 43 ++ packages/zod/src/validation/index.ts | 5 + packages/zod/src/validation/validate.ts | 34 ++ packages/zod/tsup.config.ts | 2 +- 50 files changed, 1376 insertions(+), 1971 deletions(-) delete mode 100644 packages/mcp/LICENSE.md create mode 100644 packages/mcp/src/config.ts create mode 100644 packages/mcp/src/resources/index.ts create mode 100644 packages/mcp/src/resources/resource-handler.ts create mode 100644 packages/mcp/src/server/index.ts create mode 100644 packages/mcp/src/server/mcp-server.ts create mode 100644 packages/mcp/src/tools/index.ts create mode 100644 packages/mcp/src/tools/tool-error.ts create mode 100644 packages/mcp/src/tools/tool-handler.ts delete mode 100644 packages/migrate/LICENSE.md create mode 100644 packages/migrate/src/model/index.ts create mode 100644 packages/migrate/src/model/migration.ts create mode 100644 packages/migrate/src/runner/index.ts create mode 100644 packages/migrate/src/runner/migration-options.ts create mode 100644 packages/migrate/src/runner/migration-runner.ts create mode 100644 packages/migrate/src/schema/index.ts create mode 100644 packages/migrate/src/schema/schema-diff.ts create mode 100644 packages/migrate/src/template/index.ts create mode 100644 packages/migrate/src/template/migration-template.ts create mode 100644 packages/migrate/src/version/index.ts create mode 100644 packages/migrate/src/version/version.ts delete mode 100644 packages/testing/LICENSE.md create mode 100644 packages/testing/src/driver/index.ts create mode 100644 packages/testing/src/driver/mock-driver.ts create mode 100644 packages/testing/src/fixtures/index.ts create mode 100644 packages/testing/src/fixtures/test-fixtures.ts create mode 100644 packages/testing/src/generators/index.ts create mode 100644 packages/testing/src/generators/random.ts create mode 100644 packages/testing/src/matchers/graph-matchers.ts create mode 100644 packages/testing/src/matchers/index.ts delete mode 100644 packages/zod/LICENSE.md create mode 100644 packages/zod/src/schema/factory.ts create mode 100644 packages/zod/src/schema/index.ts create mode 100644 packages/zod/src/schema/property.ts create mode 100644 packages/zod/src/schema/types.ts create mode 100644 packages/zod/src/validation/hooks.ts create mode 100644 packages/zod/src/validation/index.ts create mode 100644 packages/zod/src/validation/validate.ts diff --git a/packages/mcp/LICENSE.md b/packages/mcp/LICENSE.md deleted file mode 100644 index d645695..0000000 --- a/packages/mcp/LICENSE.md +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a5236cd..fc69a45 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -20,8 +20,7 @@ ], "sideEffects": false, "files": [ - "dist", - "LICENSE.md" + "dist" ], "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -36,7 +35,7 @@ }, "scripts": { "clean": "rm -rf dist .turbo coverage", - "build": "tsup", + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --composite false --incremental false", "dev": "tsup --watch", "test": "vitest run", "test:ci": "vitest run --reporter=verbose", diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts new file mode 100644 index 0000000..9d36505 --- /dev/null +++ b/packages/mcp/src/config.ts @@ -0,0 +1,10 @@ +// @graph-om/mcp/src/config.ts +// MCP server configuration + +import type { AnyDriverConfig, DriverAdapter } from '@graph-om/sdk'; + +export interface MCPConfig { + driver: DriverAdapter | AnyDriverConfig; + name?: string; + version?: string; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index ee4ac61..beb3ad6 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,245 +1,17 @@ // @graph-om/mcp/src/index.ts // Graph-OM MCP (Model Context Protocol) server -// Provides graph database operations through MCP protocol - -import type { DriverAdapter, DriverConfig } from '@graph-om/sdk'; -import { type AnyDriverConfig, createDriver } from '@graph-om/sdk'; -import { errorResponse, raiseGraphError, toPublic } from '@graph-om/sdk/errors'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - type CallToolResult, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; - -export interface MCPConfig { - driver: DriverAdapter | DriverConfig; - name?: string; - version?: string; -} - -/** A failed tool call: an MCP tool result flagged as an error, with client-safe JSON. */ -function toToolError(error: unknown): CallToolResult { - const { body } = errorResponse(toPublic(error)); - return { isError: true, content: [{ type: 'text', text: body }] }; -} - -export class GraphOMMCPServer { - private server: Server; - private driver: DriverAdapter; - - constructor(config: MCPConfig) { - this.driver = - 'runQuery' in config.driver - ? (config.driver as DriverAdapter) - : createDriver(config.driver as AnyDriverConfig); - - this.server = new Server( - { - name: config.name ?? 'graph-om', - version: config.version ?? '1.0.0', - }, - { - capabilities: { - tools: {}, - resources: {}, - }, - }, - ); - - this.setupHandlers(); - } - - private setupHandlers(): void { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'run_query', - description: 'Execute a Cypher query against the graph database', - inputSchema: { - type: 'object', - properties: { - cypher: { type: 'string', description: 'Cypher query to execute' }, - params: { type: 'object', description: 'Query parameters' }, - }, - required: ['cypher'], - }, - }, - { - name: 'get_schema', - description: 'Get the graph schema information', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'list_nodes', - description: 'List nodes with optional filtering', - inputSchema: { - type: 'object', - properties: { - label: { type: 'string', description: 'Node label to filter by' }, - limit: { type: 'number', description: 'Maximum number of nodes to return' }, - }, - }, - }, - { - name: 'list_relationships', - description: 'List relationships with optional filtering', - inputSchema: { - type: 'object', - properties: { - type: { type: 'string', description: 'Relationship type to filter by' }, - limit: { type: 'number', description: 'Maximum number of relationships to return' }, - }, - }, - }, - ], - })); - - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - return await this.callTool(request.params.name, request.params.arguments); - } catch (error) { - return toToolError(error); - } - }); - - this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [ - { - uri: 'graph://schema', - name: 'Graph Schema', - description: 'Graph database schema', - mimeType: 'application/json', - }, - ], - })); - - this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const { uri } = request.params; - - if (uri === 'graph://schema') { - return { - contents: [ - { - uri, - mimeType: 'application/json', - text: JSON.stringify( - { message: 'Schema introspection not yet implemented' }, - null, - 2, - ), - }, - ], - }; - } - - raiseGraphError(`Unknown resource: ${uri}`, { - code: 'not_found', - statusCode: 404, - metadata: { uri }, - }); - }); - } - - private async callTool( - name: string, - args: Record | undefined, - ): Promise { - switch (name) { - case 'run_query': { - const { cypher, params } = args as { cypher: string; params?: Record }; - const result = await this.driver.runQuery(cypher, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_schema': { - // This would need to be implemented based on the driver's capabilities - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { message: 'Schema introspection not yet implemented' }, - null, - 2, - ), - }, - ], - }; - } - - case 'list_nodes': { - const { label, limit = 100 } = args as { label?: string; limit?: number }; - const cypher = label - ? `MATCH (n:${label}) RETURN n LIMIT ${limit}` - : `MATCH (n) RETURN n LIMIT ${limit}`; - const result = await this.driver.runQuery(cypher); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'list_relationships': { - const { type, limit = 100 } = args as { type?: string; limit?: number }; - const cypher = type - ? `MATCH ()-[r:${type}]->() RETURN r LIMIT ${limit}` - : `MATCH ()-[r]->() RETURN r LIMIT ${limit}`; - const result = await this.driver.runQuery(cypher); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - default: - raiseGraphError(`Unknown tool: ${name}`, { - code: 'not_found', - statusCode: 404, - metadata: { tool: name }, - }); - } - } - - async start(): Promise { - // Connect the database first, so tool calls do not fail with driver_not_connected - await this.driver.connect(); - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error('Graph-OM MCP Server running on stdio'); - } - - async stop(): Promise { - await this.server.close(); - await this.driver.disconnect(); - } -} - -export async function createMCPServer(config: MCPConfig): Promise { - const server = new GraphOMMCPServer(config); - return server; -} +// Barrel export - manually selects and re-exports public API +// Re-exports from MCP SDK export { Server } from '@modelcontextprotocol/sdk/server/index.js'; export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +// Config +export type { MCPConfig } from './config'; +export type { ResourceDefinition } from './resources/index'; +// Resources domain +export { RESOURCE_DEFINITIONS, readResource } from './resources/index'; +// Server domain +export { createMCPServer, GraphOMMCPServer } from './server/mcp-server'; +export type { ToolDefinition } from './tools/index'; +// Tools domain +export { callTool, TOOL_DEFINITIONS, toToolError } from './tools/index'; diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts new file mode 100644 index 0000000..e42fb37 --- /dev/null +++ b/packages/mcp/src/resources/index.ts @@ -0,0 +1,5 @@ +// @graph-om/mcp/src/resources/index.ts +// Resources domain barrel + +export type { ResourceDefinition } from './resource-handler'; +export { RESOURCE_DEFINITIONS, readResource } from './resource-handler'; diff --git a/packages/mcp/src/resources/resource-handler.ts b/packages/mcp/src/resources/resource-handler.ts new file mode 100644 index 0000000..88b8868 --- /dev/null +++ b/packages/mcp/src/resources/resource-handler.ts @@ -0,0 +1,40 @@ +// @graph-om/mcp/src/resources/resource-handler.ts +// MCP resource definitions and read-resource dispatch logic + +import { raiseGraphError } from '@graph-om/sdk/errors'; + +export interface ResourceDefinition { + uri: string; + name: string; + description: string; + mimeType: string; +} + +export const RESOURCE_DEFINITIONS: ResourceDefinition[] = [ + { + uri: 'graph://schema', + name: 'Graph Schema', + description: 'Graph database schema', + mimeType: 'application/json', + }, +]; + +export async function readResource( + uri: string, +): Promise> { + if (uri === 'graph://schema') { + return [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ message: 'Schema introspection not yet implemented' }, null, 2), + }, + ]; + } + + raiseGraphError(`Unknown resource: ${uri}`, { + code: 'not_found', + statusCode: 404, + metadata: { uri }, + }); +} diff --git a/packages/mcp/src/server/index.ts b/packages/mcp/src/server/index.ts new file mode 100644 index 0000000..098e2d7 --- /dev/null +++ b/packages/mcp/src/server/index.ts @@ -0,0 +1,4 @@ +// @graph-om/mcp/src/server/index.ts +// Server domain barrel + +export { createMCPServer, GraphOMMCPServer } from './mcp-server'; diff --git a/packages/mcp/src/server/mcp-server.ts b/packages/mcp/src/server/mcp-server.ts new file mode 100644 index 0000000..5158da2 --- /dev/null +++ b/packages/mcp/src/server/mcp-server.ts @@ -0,0 +1,86 @@ +// @graph-om/mcp/src/server/mcp-server.ts +// Graph-OM MCP server class and factory + +import type { AnyDriverConfig, DriverAdapter } from '@graph-om/sdk'; +import { createDriver } from '@graph-om/sdk'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { MCPConfig } from '../config'; +import { RESOURCE_DEFINITIONS, readResource } from '../resources/index'; +import { callTool, TOOL_DEFINITIONS, toToolError } from '../tools/index'; + +export class GraphOMMCPServer { + private server: Server; + private driver: DriverAdapter; + + constructor(config: MCPConfig) { + this.driver = + 'runQuery' in config.driver + ? (config.driver as DriverAdapter) + : createDriver(config.driver as AnyDriverConfig); + + this.server = new Server( + { + name: config.name ?? 'graph-om', + version: config.version ?? '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + }, + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOL_DEFINITIONS, + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + return await callTool(this.driver, request.params.name, request.params.arguments); + } catch (error) { + return toToolError(error); + } + }); + + this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: RESOURCE_DEFINITIONS, + })); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + return { + contents: await readResource(uri), + }; + }); + } + + async start(): Promise { + // Connect the database first, so tool calls do not fail with driver_not_connected + await this.driver.connect(); + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Graph-OM MCP Server running on stdio'); + } + + async stop(): Promise { + await this.server.close(); + await this.driver.disconnect(); + } +} + +export async function createMCPServer(config: MCPConfig): Promise { + const server = new GraphOMMCPServer(config); + return server; +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000..5c0290c --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,6 @@ +// @graph-om/mcp/src/tools/index.ts +// Tools domain barrel + +export { toToolError } from './tool-error'; +export type { ToolDefinition } from './tool-handler'; +export { callTool, TOOL_DEFINITIONS } from './tool-handler'; diff --git a/packages/mcp/src/tools/tool-error.ts b/packages/mcp/src/tools/tool-error.ts new file mode 100644 index 0000000..92f8cff --- /dev/null +++ b/packages/mcp/src/tools/tool-error.ts @@ -0,0 +1,10 @@ +// @graph-om/mcp/src/tools/tool-error.ts +// Converts errors into client-safe MCP tool error results + +import { errorResponse, toPublic } from '@graph-om/sdk/errors'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export function toToolError(error: unknown): CallToolResult { + const { body } = errorResponse(toPublic(error)); + return { isError: true, content: [{ type: 'text', text: body }] }; +} diff --git a/packages/mcp/src/tools/tool-handler.ts b/packages/mcp/src/tools/tool-handler.ts new file mode 100644 index 0000000..0581de6 --- /dev/null +++ b/packages/mcp/src/tools/tool-handler.ts @@ -0,0 +1,128 @@ +// @graph-om/mcp/src/tools/tool-handler.ts +// MCP tool definitions and call-tool dispatch logic + +import type { DriverAdapter } from '@graph-om/sdk'; +import { raiseGraphError } from '@graph-om/sdk/errors'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + name: 'run_query', + description: 'Execute a Cypher query against the graph database', + inputSchema: { + type: 'object', + properties: { + cypher: { type: 'string', description: 'Cypher query to execute' }, + params: { type: 'object', description: 'Query parameters' }, + }, + required: ['cypher'], + }, + }, + { + name: 'get_schema', + description: 'Get the graph schema information', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'list_nodes', + description: 'List nodes with optional filtering', + inputSchema: { + type: 'object', + properties: { + label: { type: 'string', description: 'Node label to filter by' }, + limit: { type: 'number', description: 'Maximum number of nodes to return' }, + }, + }, + }, + { + name: 'list_relationships', + description: 'List relationships with optional filtering', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', description: 'Relationship type to filter by' }, + limit: { type: 'number', description: 'Maximum number of relationships to return' }, + }, + }, + }, +]; + +export async function callTool( + driver: DriverAdapter, + name: string, + args: Record | undefined, +): Promise { + switch (name) { + case 'run_query': { + const { cypher, params } = args as { cypher: string; params?: Record }; + const result = await driver.runQuery(cypher, params); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'get_schema': { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ message: 'Schema introspection not yet implemented' }, null, 2), + }, + ], + }; + } + + case 'list_nodes': { + const { label, limit = 100 } = args as { label?: string; limit?: number }; + const cypher = label + ? `MATCH (n:${label}) RETURN n LIMIT ${limit}` + : `MATCH (n) RETURN n LIMIT ${limit}`; + const result = await driver.runQuery(cypher); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_relationships': { + const { type, limit = 100 } = args as { type?: string; limit?: number }; + const cypher = type + ? `MATCH ()-[r:${type}]->() RETURN r LIMIT ${limit}` + : `MATCH ()-[r]->() RETURN r LIMIT ${limit}`; + const result = await driver.runQuery(cypher); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + default: + raiseGraphError(`Unknown tool: ${name}`, { + code: 'not_found', + statusCode: 404, + metadata: { tool: name }, + }); + } +} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts index 880e389..caf86c5 100644 --- a/packages/mcp/tsup.config.ts +++ b/packages/mcp/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['cjs', 'esm'], - dts: true, + dts: false, splitting: false, sourcemap: true, clean: true, diff --git a/packages/migrate/LICENSE.md b/packages/migrate/LICENSE.md deleted file mode 100644 index d645695..0000000 --- a/packages/migrate/LICENSE.md +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 9ff24ec..9775c13 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -20,8 +20,7 @@ ], "sideEffects": false, "files": [ - "dist", - "LICENSE.md" + "dist" ], "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -36,7 +35,7 @@ }, "scripts": { "clean": "rm -rf dist .turbo coverage", - "build": "tsup", + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --composite false --incremental false", "dev": "tsup --watch", "test": "vitest run", "test:ci": "vitest run --reporter=verbose", diff --git a/packages/migrate/src/index.ts b/packages/migrate/src/index.ts index 669da05..6cecc0e 100644 --- a/packages/migrate/src/index.ts +++ b/packages/migrate/src/index.ts @@ -1,416 +1,18 @@ // @graph-om/migrate/src/index.ts // Graph-OM migration system - schema versioning and database migrations - -import type { DriverAdapter, GraphSchemaRegistry } from '@graph-om/sdk'; -import { QueryCompiler } from '@graph-om/sdk'; -import { raiseGraphError } from '@graph-om/sdk/errors'; - -export interface MigrationOptions { - migrationsDir?: string; - tableName?: string; - driver: DriverAdapter; - schema?: GraphSchemaRegistry; -} - -export interface MigrationStatus { - version: string; - name: string; - applied: boolean; - appliedAt?: Date; - checksum?: string; -} - -export interface CreateMigrationOptions { - name: string; - schema?: GraphSchemaRegistry; - migrationsDir?: string; -} - -export interface Migration { - version: string; - name: string; - timestamp: number; - up: (compiler: QueryCompiler) => string[]; - down: (compiler: QueryCompiler) => string[]; -} - -export interface SchemaDiff { - addedNodes: string[]; - removedNodes: string[]; - modifiedNodes: Map; - addedRelations: string[]; - removedRelations: string[]; -} - -export interface NodeDiff { - addedProperties: string[]; - removedProperties: string[]; - modifiedProperties: Map; - addedIndexes: Array<{ fields: string[]; unique?: boolean }>; - removedIndexes: Array<{ fields: string[]; unique?: boolean }>; - addedConstraints: Array<{ fields: string[]; type: 'UNIQUE' | 'EXISTS' }>; - removedConstraints: Array<{ fields: string[]; type: 'UNIQUE' | 'EXISTS' }>; -} - -export interface PropertyDiff { - typeChanged?: boolean; - oldType?: string; - newType?: string; - nullableChanged?: boolean; - arrayChanged?: boolean; - primaryChanged?: boolean; - uniqueChanged?: boolean; - defaultChanged?: boolean; -} - -export class MigrationRunner { - private driver: DriverAdapter; - private compiler: QueryCompiler; - private migrationsDir: string; - private tableName: string; - private schema?: GraphSchemaRegistry; - - constructor(options: MigrationOptions) { - this.driver = options.driver; - this.compiler = new QueryCompiler(); - this.migrationsDir = options.migrationsDir ?? './migrations'; - this.tableName = options.tableName ?? '_graph_om_migrations'; - this.schema = options.schema; - } - - async initialize(): Promise { - await this.driver.connect(); - - // Create migrations table if it doesn't exist - const _createTableCypher = ` - CREATE TABLE IF NOT EXISTS ${this.tableName} ( - version STRING PRIMARY KEY, - name STRING NOT NULL, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - checksum STRING - ) - `; - - // For graph databases, we use a node instead of a table - await this.driver.runQuery( - ` - MERGE (m:Migration {table: $tableName}) - ON CREATE SET m.createdAt = datetime() - `, - { tableName: this.tableName }, - ); - } - - async migrate(migrations: Migration[]): Promise { - await this.initialize(); - - const applied = await this.getAppliedMigrations(); - const pending = migrations.filter((m) => !applied.has(m.version)); - - const appliedMigrations: Migration[] = []; - - for (const migration of pending) { - await this.applyMigration(migration); - appliedMigrations.push(migration); - } - - return appliedMigrations; - } - - async rollback(migrations: Migration[], steps: number): Promise { - await this.initialize(); - - const applied = await this.getAppliedMigrations(); - const definitions = new Map(migrations.map((m) => [m.version, m])); - - // Resolve every definition up front so a missing one cannot leave the graph half-reverted - const toRollback = Array.from(applied.keys()) - .sort((a, b) => Number(b) - Number(a)) - .slice(0, steps) - .map((version) => { - const migration = definitions.get(version); - if (!migration) { - raiseGraphError( - `Cannot roll back migration ${version}: no matching definition provided`, - { - code: 'migration_failed', - statusCode: 500, - metadata: { version }, - }, - ); - } - return migration; - }); - - for (const migration of toRollback) { - await this.revertMigration(migration); - } - - return toRollback; - } - - async status(migrations: Migration[]): Promise { - await this.initialize(); - - const applied = await this.getAppliedMigrations(); - - return migrations.map((migration) => ({ - version: migration.version, - name: migration.name, - applied: applied.has(migration.version), - appliedAt: applied.get(migration.version)?.appliedAt, - checksum: applied.get(migration.version)?.checksum, - })); - } - - private async applyMigration(migration: Migration): Promise { - const upQueries = migration.up(this.compiler); - - for (const query of upQueries) { - await this.driver.runQuery(query); - } - - // Record the migration - await this.driver.runQuery( - ` - MERGE (m:Migration {version: $version}) - SET m.name = $name, - m.appliedAt = datetime(), - m.checksum = $checksum - `, - { - version: migration.version, - name: migration.name, - checksum: this.generateChecksum(migration), - }, - ); - } - - private async revertMigration(migration: Migration): Promise { - const downQueries = migration.down(this.compiler); - - for (const query of downQueries.reverse()) { - await this.driver.runQuery(query); - } - - // Remove the migration record - await this.driver.runQuery( - ` - MATCH (m:Migration {version: $version}) - DELETE m - `, - { version: migration.version }, - ); - } - - private async getAppliedMigrations(): Promise< - Map - > { - const result = await this.driver.runQuery(` - MATCH (m:Migration) - RETURN m.version as version, m.name as name, m.appliedAt as appliedAt, m.checksum as checksum - ORDER BY m.version - `); - - const applied = new Map(); - - for (const record of result.records) { - const version = record.get('version'); - const appliedAt = record.get('appliedAt'); - const checksum = record.get('checksum'); - - if (version) { - applied.set(version as string, { - appliedAt: new Date(appliedAt as string), - checksum: (checksum as string) ?? '', - }); - } - } - - return applied; - } - - private generateChecksum(migration: Migration): string { - // Simple checksum based on migration content - const content = `${migration.version}:${migration.name}:${migration.up.toString()}:${migration.down.toString()}`; - let hash = 0; - for (let i = 0; i < content.length; i++) { - const char = content.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - return Math.abs(hash).toString(16); - } -} - -export function generateVersion(): string { - return Date.now().toString(); -} - -export function createMigrationTemplate(options: CreateMigrationOptions): string { - return `// Migration: ${options.name} -// Version: ${generateVersion} -// Created: ${new Date().toISOString()} - -import { QueryCompiler } from '@graph-om/sdk'; -import type { Migration } from '@graph-om/migrate'; - -export const migration: Migration = { - version: '${generateVersion()}', - name: '${options.name}', - timestamp: ${Date.now()}, - - up: (compiler: QueryCompiler) => { - // Add your up migration Cypher statements here - // Example: return compiler.compileCreateIndex('User', ['email'], { unique: true }).cypher; - return []; - }, - - down: (compiler: QueryCompiler) => { - // Add your down migration Cypher statements here - // Example: return compiler.compileDropIndex('User', 'email').cypher; - return []; - }, -}; -`; -} - -export function diffSchemas( - oldSchema: GraphSchemaRegistry, - newSchema: GraphSchemaRegistry, -): SchemaDiff { - const oldNodes = new Set(Object.keys(oldSchema)); - const newNodes = new Set(Object.keys(newSchema)); - - const addedNodes = [...newNodes].filter((n) => !oldNodes.has(n)); - const removedNodes = [...oldNodes].filter((n) => !newNodes.has(n)); - const commonNodes = [...newNodes].filter((n) => oldNodes.has(n)); - - const modifiedNodes = new Map(); - - for (const nodeName of commonNodes) { - const oldNode = oldSchema[nodeName]; - const newNode = newSchema[nodeName]; - - if (!oldNode || !newNode) continue; - - const oldProps = new Set(Object.keys(oldNode.properties)); - const newProps = new Set(Object.keys(newNode.properties)); - - const addedProperties = [...newProps].filter((p) => !oldProps.has(p)); - const removedProperties = [...oldProps].filter((p) => !newProps.has(p)); - const commonProperties = [...newProps].filter((p) => oldProps.has(p)); - - const modifiedProperties = new Map(); - - for (const propName of commonProperties) { - const oldProp = oldNode.properties[propName]; - const newProp = newNode.properties[propName]; - if (!oldProp || !newProp) continue; - - const diff: PropertyDiff = {}; - let hasChanges = false; - - if (oldProp.type !== newProp.type) { - diff.typeChanged = true; - diff.oldType = oldProp.type; - diff.newType = newProp.type; - hasChanges = true; - } - - if (oldProp.nullable !== newProp.nullable) { - diff.nullableChanged = true; - hasChanges = true; - } - - if (oldProp.array !== newProp.array) { - diff.arrayChanged = true; - hasChanges = true; - } - - if (oldProp.primary !== newProp.primary) { - diff.primaryChanged = true; - hasChanges = true; - } - - if (oldProp.unique !== newProp.unique) { - diff.uniqueChanged = true; - hasChanges = true; - } - - if (oldProp.default !== newProp.default) { - diff.defaultChanged = true; - hasChanges = true; - } - - if (hasChanges) { - modifiedProperties.set(propName, diff); - } - } - - if (addedProperties.length > 0 || removedProperties.length > 0 || modifiedProperties.size > 0) { - modifiedNodes.set(nodeName, { - addedProperties, - removedProperties, - modifiedProperties, - addedIndexes: [], - removedIndexes: [], - addedConstraints: [], - removedConstraints: [], - }); - } - } - - return { - addedNodes, - removedNodes, - modifiedNodes, - addedRelations: [], - removedRelations: [], - }; -} - -export function generateMigrationFromDiff(diff: SchemaDiff, name: string): Migration { - return { - version: generateVersion(), - name, - timestamp: Date.now(), - - up: (_compiler: QueryCompiler) => { - const statements: string[] = []; - - // Add new nodes - for (const nodeName of diff.addedNodes) { - // This would need the actual schema to generate proper CREATE statements - statements.push(`// CREATE NODE ${nodeName}`); - } - - // Add new properties - for (const [nodeName, nodeDiff] of diff.modifiedNodes) { - for (const propName of nodeDiff.addedProperties) { - statements.push(`// ADD PROPERTY ${nodeName}.${propName}`); - } - } - - return statements; - }, - - down: (_compiler: QueryCompiler) => { - const statements: string[] = []; - - // Remove nodes - for (const nodeName of diff.removedNodes) { - statements.push(`// DROP NODE ${nodeName}`); - } - - return statements; - }, - }; -} - -export function loadMigrations(_migrationsDir: string): Migration[] { - raiseGraphError( - 'loadMigrations() cannot import migration files yet. Import each migration module explicitly and pass the list to createMigrationRunner().migrate().', - { code: 'unsupported_option', statusCode: 400 }, - ); -} +// Barrel export - manually selects and re-exports public API + +// Model domain +export type { CreateMigrationOptions, Migration } from './model/migration'; +export type { MigrationOptions, MigrationStatus } from './runner/migration-options'; +// Runner domain +export { MigrationRunner } from './runner/migration-runner'; +export type { NodeDiff, PropertyDiff, SchemaDiff } from './schema/schema-diff'; +// Schema domain +export { diffSchemas, generateMigrationFromDiff } from './schema/schema-diff'; +export type { CreateMigrationTemplateOptions } from './template/migration-template'; + +// Template domain +export { createMigrationTemplate } from './template/migration-template'; +// Version domain +export { generateVersion } from './version/version'; diff --git a/packages/migrate/src/model/index.ts b/packages/migrate/src/model/index.ts new file mode 100644 index 0000000..105b4b2 --- /dev/null +++ b/packages/migrate/src/model/index.ts @@ -0,0 +1,4 @@ +// @graph-om/migrate/src/model/index.ts +// Model domain barrel + +export type { CreateMigrationOptions, Migration } from './migration'; diff --git a/packages/migrate/src/model/migration.ts b/packages/migrate/src/model/migration.ts new file mode 100644 index 0000000..e1d6285 --- /dev/null +++ b/packages/migrate/src/model/migration.ts @@ -0,0 +1,18 @@ +// @graph-om/migrate/src/model/migration.ts +// Migration model interfaces + +import type { GraphSchemaRegistry, QueryCompiler } from '@graph-om/sdk'; + +export interface Migration { + version: string; + name: string; + timestamp: number; + up: (compiler: QueryCompiler) => string[]; + down: (compiler: QueryCompiler) => string[]; +} + +export interface CreateMigrationOptions { + name: string; + schema?: GraphSchemaRegistry; + migrationsDir?: string; +} diff --git a/packages/migrate/src/runner/index.ts b/packages/migrate/src/runner/index.ts new file mode 100644 index 0000000..cb76439 --- /dev/null +++ b/packages/migrate/src/runner/index.ts @@ -0,0 +1,5 @@ +// @graph-om/migrate/src/runner/index.ts +// Runner domain barrel + +export type { MigrationOptions, MigrationStatus } from './migration-options'; +export { MigrationRunner } from './migration-runner'; diff --git a/packages/migrate/src/runner/migration-options.ts b/packages/migrate/src/runner/migration-options.ts new file mode 100644 index 0000000..fac69df --- /dev/null +++ b/packages/migrate/src/runner/migration-options.ts @@ -0,0 +1,19 @@ +// @graph-om/migrate/src/runner/migration-options.ts +// Migration runner options and status types + +import type { DriverAdapter, GraphSchemaRegistry } from '@graph-om/sdk'; + +export interface MigrationOptions { + migrationsDir?: string; + tableName?: string; + driver: DriverAdapter; + schema?: GraphSchemaRegistry; +} + +export interface MigrationStatus { + version: string; + name: string; + applied: boolean; + appliedAt?: Date; + checksum?: string; +} diff --git a/packages/migrate/src/runner/migration-runner.ts b/packages/migrate/src/runner/migration-runner.ts new file mode 100644 index 0000000..cee89aa --- /dev/null +++ b/packages/migrate/src/runner/migration-runner.ts @@ -0,0 +1,188 @@ +// @graph-om/migrate/src/runner/migration-runner.ts +// Migration execution engine - applies, rolls back, and reports migration status + +import type { DriverAdapter } from '@graph-om/sdk'; +import { QueryCompiler } from '@graph-om/sdk'; +import { raiseGraphError } from '@graph-om/sdk/errors'; +import type { Migration } from '../model/migration'; +import type { MigrationOptions, MigrationStatus } from './migration-options'; + +export class MigrationRunner { + private driver: DriverAdapter; + private compiler: QueryCompiler; + private migrationsDir: string; + private tableName: string; + private schema?: import('@graph-om/sdk').GraphSchemaRegistry; + + constructor(options: MigrationOptions) { + this.driver = options.driver; + this.compiler = new QueryCompiler(); + this.migrationsDir = options.migrationsDir ?? './migrations'; + this.tableName = options.tableName ?? '_graph_om_migrations'; + this.schema = options.schema; + } + + async initialize(): Promise { + await this.driver.connect(); + + // Create migrations table if it doesn't exist + const _createTableCypher = ` + CREATE TABLE IF NOT EXISTS ${this.tableName} ( + version STRING PRIMARY KEY, + name STRING NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + checksum STRING + ) + `; + + // For graph databases, we use a node instead of a table + await this.driver.runQuery( + ` + MERGE (m:Migration {table: $tableName}) + ON CREATE SET m.createdAt = datetime() + `, + { tableName: this.tableName }, + ); + } + + async migrate(migrations: Migration[]): Promise { + await this.initialize(); + + const applied = await this.getAppliedMigrations(); + const pending = migrations.filter((m) => !applied.has(m.version)); + + const appliedMigrations: Migration[] = []; + + for (const migration of pending) { + await this.applyMigration(migration); + appliedMigrations.push(migration); + } + + return appliedMigrations; + } + + async rollback(migrations: Migration[], steps: number): Promise { + await this.initialize(); + + const applied = await this.getAppliedMigrations(); + const definitions = new Map(migrations.map((m) => [m.version, m])); + + // Resolve every definition up front so a missing one cannot leave the graph half-reverted + const toRollback = Array.from(applied.keys()) + .sort((a, b) => Number(b) - Number(a)) + .slice(0, steps) + .map((version) => { + const migration = definitions.get(version); + if (!migration) { + raiseGraphError( + `Cannot roll back migration ${version}: no matching definition provided`, + { + code: 'migration_failed', + statusCode: 500, + metadata: { version }, + }, + ); + } + return migration; + }); + + for (const migration of toRollback) { + await this.revertMigration(migration); + } + + return toRollback; + } + + async status(migrations: Migration[]): Promise { + await this.initialize(); + + const applied = await this.getAppliedMigrations(); + + return migrations.map((migration) => ({ + version: migration.version, + name: migration.name, + applied: applied.has(migration.version), + appliedAt: applied.get(migration.version)?.appliedAt, + checksum: applied.get(migration.version)?.checksum, + })); + } + + private async applyMigration(migration: Migration): Promise { + const upQueries = migration.up(this.compiler); + + for (const query of upQueries) { + await this.driver.runQuery(query); + } + + // Record the migration + await this.driver.runQuery( + ` + MERGE (m:Migration {version: $version}) + SET m.name = $name, + m.appliedAt = datetime(), + m.checksum = $checksum + `, + { + version: migration.version, + name: migration.name, + checksum: this.generateChecksum(migration), + }, + ); + } + + private async revertMigration(migration: Migration): Promise { + const downQueries = migration.down(this.compiler); + + for (const query of downQueries.reverse()) { + await this.driver.runQuery(query); + } + + // Remove the migration record + await this.driver.runQuery( + ` + MATCH (m:Migration {version: $version}) + DELETE m + `, + { version: migration.version }, + ); + } + + private async getAppliedMigrations(): Promise< + Map + > { + const result = await this.driver.runQuery(` + MATCH (m:Migration) + RETURN m.version as version, m.name as name, m.appliedAt as appliedAt, m.checksum as checksum + ORDER BY m.version + `); + + const applied = new Map(); + + for (const record of result.records) { + const version = record.get('version'); + const appliedAt = record.get('appliedAt'); + const checksum = record.get('checksum'); + + if (version) { + applied.set(version as string, { + appliedAt: new Date(appliedAt as string), + checksum: (checksum as string) ?? '', + }); + } + } + + return applied; + } + + private generateChecksum(migration: Migration): string { + // Simple checksum based on migration content + const content = `${migration.version}:${migration.name}:${migration.up.toString()}:${migration.down.toString()}`; + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(16); + } +} diff --git a/packages/migrate/src/schema/index.ts b/packages/migrate/src/schema/index.ts new file mode 100644 index 0000000..374bcf2 --- /dev/null +++ b/packages/migrate/src/schema/index.ts @@ -0,0 +1,5 @@ +// @graph-om/migrate/src/schema/index.ts +// Schema domain barrel + +export type { NodeDiff, PropertyDiff, SchemaDiff } from './schema-diff'; +export { diffSchemas, generateMigrationFromDiff } from './schema-diff'; diff --git a/packages/migrate/src/schema/schema-diff.ts b/packages/migrate/src/schema/schema-diff.ts new file mode 100644 index 0000000..6d3a762 --- /dev/null +++ b/packages/migrate/src/schema/schema-diff.ts @@ -0,0 +1,168 @@ +// @graph-om/migrate/src/schema/schema-diff.ts +// Schema diffing and migration generation from diffs + +import type { GraphSchemaRegistry, QueryCompiler } from '@graph-om/sdk'; +import type { Migration } from '../model/migration'; +import { generateVersion } from '../version/version'; + +export interface SchemaDiff { + addedNodes: string[]; + removedNodes: string[]; + modifiedNodes: Map; + addedRelations: string[]; + removedRelations: string[]; +} + +export interface NodeDiff { + addedProperties: string[]; + removedProperties: string[]; + modifiedProperties: Map; + addedIndexes: Array<{ fields: string[]; unique?: boolean }>; + removedIndexes: Array<{ fields: string[]; unique?: boolean }>; + addedConstraints: Array<{ fields: string[]; type: 'UNIQUE' | 'EXISTS' }>; + removedConstraints: Array<{ fields: string[]; type: 'UNIQUE' | 'EXISTS' }>; +} + +export interface PropertyDiff { + typeChanged?: boolean; + oldType?: string; + newType?: string; + nullableChanged?: boolean; + arrayChanged?: boolean; + primaryChanged?: boolean; + uniqueChanged?: boolean; + defaultChanged?: boolean; +} + +export function diffSchemas( + oldSchema: GraphSchemaRegistry, + newSchema: GraphSchemaRegistry, +): SchemaDiff { + const oldNodes = new Set(Object.keys(oldSchema)); + const newNodes = new Set(Object.keys(newSchema)); + + const addedNodes = [...newNodes].filter((n) => !oldNodes.has(n)); + const removedNodes = [...oldNodes].filter((n) => !newNodes.has(n)); + const commonNodes = [...newNodes].filter((n) => oldNodes.has(n)); + + const modifiedNodes = new Map(); + + for (const nodeName of commonNodes) { + const oldNode = oldSchema[nodeName]; + const newNode = newSchema[nodeName]; + + if (!oldNode || !newNode) continue; + + const oldProps = new Set(Object.keys(oldNode.properties)); + const newProps = new Set(Object.keys(newNode.properties)); + + const addedProperties = [...newProps].filter((p) => !oldProps.has(p)); + const removedProperties = [...oldProps].filter((p) => !newProps.has(p)); + const commonProperties = [...newProps].filter((p) => oldProps.has(p)); + + const modifiedProperties = new Map(); + + for (const propName of commonProperties) { + const oldProp = oldNode.properties[propName]; + const newProp = newNode.properties[propName]; + if (!oldProp || !newProp) continue; + + const diff: PropertyDiff = {}; + let hasChanges = false; + + if (oldProp.type !== newProp.type) { + diff.typeChanged = true; + diff.oldType = oldProp.type; + diff.newType = newProp.type; + hasChanges = true; + } + + if (oldProp.nullable !== newProp.nullable) { + diff.nullableChanged = true; + hasChanges = true; + } + + if (oldProp.array !== newProp.array) { + diff.arrayChanged = true; + hasChanges = true; + } + + if (oldProp.primary !== newProp.primary) { + diff.primaryChanged = true; + hasChanges = true; + } + + if (oldProp.unique !== newProp.unique) { + diff.uniqueChanged = true; + hasChanges = true; + } + + if (oldProp.default !== newProp.default) { + diff.defaultChanged = true; + hasChanges = true; + } + + if (hasChanges) { + modifiedProperties.set(propName, diff); + } + } + + if (addedProperties.length > 0 || removedProperties.length > 0 || modifiedProperties.size > 0) { + modifiedNodes.set(nodeName, { + addedProperties, + removedProperties, + modifiedProperties, + addedIndexes: [], + removedIndexes: [], + addedConstraints: [], + removedConstraints: [], + }); + } + } + + return { + addedNodes, + removedNodes, + modifiedNodes, + addedRelations: [], + removedRelations: [], + }; +} + +export function generateMigrationFromDiff(diff: SchemaDiff, name: string): Migration { + return { + version: generateVersion(), + name, + timestamp: Date.now(), + + up: (_compiler: QueryCompiler) => { + const statements: string[] = []; + + // Add new nodes + for (const nodeName of diff.addedNodes) { + // This would need the actual schema to generate proper CREATE statements + statements.push(`// CREATE NODE ${nodeName}`); + } + + // Add new properties + for (const [nodeName, nodeDiff] of diff.modifiedNodes) { + for (const propName of nodeDiff.addedProperties) { + statements.push(`// ADD PROPERTY ${nodeName}.${propName}`); + } + } + + return statements; + }, + + down: (_compiler: QueryCompiler) => { + const statements: string[] = []; + + // Remove nodes + for (const nodeName of diff.removedNodes) { + statements.push(`// DROP NODE ${nodeName}`); + } + + return statements; + }, + }; +} diff --git a/packages/migrate/src/template/index.ts b/packages/migrate/src/template/index.ts new file mode 100644 index 0000000..fd053e8 --- /dev/null +++ b/packages/migrate/src/template/index.ts @@ -0,0 +1,5 @@ +// @graph-om/migrate/src/template/index.ts +// Template domain barrel + +export type { CreateMigrationTemplateOptions } from './migration-template'; +export { createMigrationTemplate } from './migration-template'; diff --git a/packages/migrate/src/template/migration-template.ts b/packages/migrate/src/template/migration-template.ts new file mode 100644 index 0000000..09701bf --- /dev/null +++ b/packages/migrate/src/template/migration-template.ts @@ -0,0 +1,39 @@ +// @graph-om/migrate/src/template/migration-template.ts +// Migration file template generation + +import type { GraphSchemaRegistry } from '@graph-om/sdk'; +import { generateVersion } from '../version/version'; + +export interface CreateMigrationTemplateOptions { + name: string; + schema?: GraphSchemaRegistry; + migrationsDir?: string; +} + +export function createMigrationTemplate(options: CreateMigrationTemplateOptions): string { + return `// Migration: ${options.name} +// Version: ${generateVersion()} +// Created: ${new Date().toISOString()} + +import { QueryCompiler } from '@graph-om/sdk'; +import type { Migration } from '@graph-om/migrate'; + +export const migration: Migration = { + version: '${generateVersion()}', + name: '${options.name}', + timestamp: ${Date.now()}, + + up: (compiler: QueryCompiler) => { + // Add your up migration Cypher statements here + // Example: return compiler.compileCreateIndex('User', ['email'], { unique: true }).cypher; + return []; + }, + + down: (compiler: QueryCompiler) => { + // Add your down migration Cypher statements here + // Example: return compiler.compileDropIndex('User', 'email').cypher; + return []; + }, +}; +`; +} diff --git a/packages/migrate/src/version/index.ts b/packages/migrate/src/version/index.ts new file mode 100644 index 0000000..f6a1dc7 --- /dev/null +++ b/packages/migrate/src/version/index.ts @@ -0,0 +1,4 @@ +// @graph-om/migrate/src/version/index.ts +// Version domain barrel + +export { generateVersion } from './version'; diff --git a/packages/migrate/src/version/version.ts b/packages/migrate/src/version/version.ts new file mode 100644 index 0000000..12f348a --- /dev/null +++ b/packages/migrate/src/version/version.ts @@ -0,0 +1,6 @@ +// @graph-om/migrate/src/version/version.ts +// Migration version generation + +export function generateVersion(): string { + return Date.now().toString(); +} diff --git a/packages/migrate/tsup.config.ts b/packages/migrate/tsup.config.ts index 0cc6fc9..ba04678 100644 --- a/packages/migrate/tsup.config.ts +++ b/packages/migrate/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['cjs', 'esm'], - dts: true, + dts: false, splitting: false, sourcemap: true, clean: true, diff --git a/packages/testing/LICENSE.md b/packages/testing/LICENSE.md deleted file mode 100644 index d645695..0000000 --- a/packages/testing/LICENSE.md +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/testing/package.json b/packages/testing/package.json index f33b020..26f7fb5 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -20,8 +20,7 @@ ], "sideEffects": false, "files": [ - "dist", - "LICENSE.md" + "dist" ], "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -36,7 +35,7 @@ }, "scripts": { "clean": "rm -rf dist .turbo coverage", - "build": "tsup", + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --composite false --incremental false", "dev": "tsup --watch", "test": "vitest run", "test:ci": "vitest run --reporter=verbose", diff --git a/packages/testing/src/driver/index.ts b/packages/testing/src/driver/index.ts new file mode 100644 index 0000000..27088c4 --- /dev/null +++ b/packages/testing/src/driver/index.ts @@ -0,0 +1,4 @@ +// @graph-om/testing/src/driver/index.ts +// Mock driver domain barrel + +export { createMockDriver, MockDriverAdapter } from './mock-driver'; diff --git a/packages/testing/src/driver/mock-driver.ts b/packages/testing/src/driver/mock-driver.ts new file mode 100644 index 0000000..a877357 --- /dev/null +++ b/packages/testing/src/driver/mock-driver.ts @@ -0,0 +1,168 @@ +// @graph-om/testing/src/driver/mock-driver.ts +// Mock DriverAdapter implementation for testing without a live database + +import type { + DriverAdapter, + DriverCapabilities, + QueryRecord, + QueryResult, + TransactionContext, +} from '@graph-om/sdk'; + +export class MockDriverAdapter implements DriverAdapter { + private data: Map>> = new Map(); + private queryLog: Array<{ cypher: string; params?: Record }> = []; + private shouldThrow = false; + private throwError: Error | null = null; + + setShouldThrow(shouldThrow: boolean, error?: Error): void { + this.shouldThrow = shouldThrow; + this.throwError = error ?? new Error('Mock driver error'); + } + + async connect(): Promise { + if (this.shouldThrow) throw this.throwError; + } + + async disconnect(): Promise { + this.data.clear(); + this.queryLog = []; + } + + async runQuery(cypher: string, params?: Record): Promise { + this.queryLog.push({ cypher, params }); + + if (this.shouldThrow) throw this.throwError; + + // Simple mock implementation + if (cypher.includes('MATCH (n:') && cypher.includes('RETURN n')) { + const labelMatch = cypher.match(/MATCH \(n:(\w+)\)/); + const label = labelMatch?.[1] ?? 'Node'; + const limitMatch = cypher.match(/LIMIT\s+(\d+)/); + const limit = limitMatch?.[1] ? parseInt(limitMatch[1], 10) : 100; + + const nodes = this.data.get(label) ?? new Map(); + const records = Array.from(nodes.values()) + .slice(0, limit) + .map((node) => this.createRecord(node)); + + return { + records, + summary: { + query: { text: cypher, parameters: params ?? {} }, + counters: {}, + notifications: [], + resultAvailableAfter: 0, + resultConsumedAfter: 0, + database: { name: 'mock' }, + }, + keys: ['n'], + }; + } + + if (cypher.includes('CREATE')) { + return { + records: [], + summary: { + query: { text: cypher, parameters: params ?? {} }, + counters: { nodesCreated: 1 }, + notifications: [], + resultAvailableAfter: 0, + resultConsumedAfter: 0, + database: { name: 'mock' }, + }, + keys: [], + }; + } + + if (cypher.includes('DELETE') || cypher.includes('DETACH DELETE')) { + return { + records: [], + summary: { + query: { text: cypher, parameters: params ?? {} }, + counters: { nodesDeleted: 1 }, + notifications: [], + resultAvailableAfter: 0, + resultConsumedAfter: 0, + database: { name: 'mock' }, + }, + keys: [], + }; + } + + return { + records: [], + summary: { + query: { text: cypher, parameters: params ?? {} }, + counters: {}, + notifications: [], + resultAvailableAfter: 0, + resultConsumedAfter: 0, + database: { name: 'mock' }, + }, + keys: [], + }; + } + + async runInTransaction(work: (txc: TransactionContext) => Promise): Promise { + return work(this.createTransactionContext()); + } + + async runInReadTransaction(work: (txc: TransactionContext) => Promise): Promise { + return work(this.createTransactionContext()); + } + + async verifyConnectivity(): Promise { + return true; + } + + async getServerInfo(): Promise<{ address: string; version: string; protocol: string }> { + return { + address: 'mock://localhost', + version: 'mock', + protocol: 'mock', + }; + } + + getCapabilities(): DriverCapabilities { + return { + transactions: true, + readReplicas: false, + boltProtocol: false, + httpProtocol: false, + vectorSearch: false, + fullTextSearch: false, + geospatial: false, + procedures: [], + }; + } + + private createTransactionContext(): TransactionContext { + return { + run: async (query: string, params?: Record): Promise => { + return this.runQuery(query, params); + }, + }; + } + + getQueryCount(cypher: string): number { + return this.queryLog.filter((q) => q.cypher === cypher).length; + } + + mockQuery(_cypher: string, _records: unknown[], _params?: Record): void { + // This would be used to mock specific query responses + } + + private createRecord(data: Record): QueryRecord { + const obj = { ...data }; + return { + keys: Object.keys(obj), + get: (key: string) => obj[key], + toObject: () => ({ ...obj }), + }; + } +} + +export function createMockDriver(): MockDriverAdapter { + return new MockDriverAdapter(); +} diff --git a/packages/testing/src/fixtures/index.ts b/packages/testing/src/fixtures/index.ts new file mode 100644 index 0000000..25b6c8b --- /dev/null +++ b/packages/testing/src/fixtures/index.ts @@ -0,0 +1,5 @@ +// @graph-om/testing/src/fixtures/index.ts +// Fixtures domain barrel + +export type { TestFixture } from './test-fixtures'; +export { createTestFixtures, generateTestData } from './test-fixtures'; diff --git a/packages/testing/src/fixtures/test-fixtures.ts b/packages/testing/src/fixtures/test-fixtures.ts new file mode 100644 index 0000000..2ed6b36 --- /dev/null +++ b/packages/testing/src/fixtures/test-fixtures.ts @@ -0,0 +1,58 @@ +// @graph-om/testing/src/fixtures/test-fixtures.ts +// Test fixtures and data generation helpers + +export interface TestFixture { + name: string; + data: Record[]; +} + +export function createTestFixtures(): { + createUserFactory: () => (index: number) => Record; + createPostFactory: () => (index: number) => Record; + createRelationFactory: () => ( + fromId: string, + toId: string, + type: string, + ) => Record; +} { + return { + createUserFactory: () => { + const _counter = 0; + return (index: number) => ({ + id: `user-${index}`, + name: `User ${index}`, + email: `user${index}@example.com`, + age: 20 + (index % 50), + createdAt: new Date(), + }); + }, + createPostFactory: () => { + const _counter = 0; + return (index: number) => ({ + id: `post-${index}`, + title: `Post ${index}`, + content: `Content for post ${index}`, + published: index % 2 === 0, + createdAt: new Date(), + }); + }, + createRelationFactory: () => { + return (fromId: string, toId: string, type: string) => ({ + from: fromId, + to: toId, + type, + createdAt: new Date(), + }); + }, + }; +} + +export function generateTestData({ + count, + factory, +}: { + count: number; + factory: (index: number) => T; +}): T[] { + return Array.from({ length: count }, (_, i) => factory(i)); +} diff --git a/packages/testing/src/generators/index.ts b/packages/testing/src/generators/index.ts new file mode 100644 index 0000000..9477eef --- /dev/null +++ b/packages/testing/src/generators/index.ts @@ -0,0 +1,12 @@ +// @graph-om/testing/src/generators/index.ts +// Generators domain barrel + +export { + generateRandomBoolean, + generateRandomDate, + generateRandomEmail, + generateRandomFloat, + generateRandomInt, + generateRandomString, + generateRandomUuid, +} from './random'; diff --git a/packages/testing/src/generators/random.ts b/packages/testing/src/generators/random.ts new file mode 100644 index 0000000..e8fc82f --- /dev/null +++ b/packages/testing/src/generators/random.ts @@ -0,0 +1,39 @@ +// @graph-om/testing/src/generators/random.ts +// Random data generators for test data + +export function generateRandomString(length = 10): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function generateRandomEmail(): string { + return `${generateRandomString(8)}@example.com`; +} + +export function generateRandomDate(start = new Date(2020, 0, 1), end = new Date()): Date { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); +} + +export function generateRandomInt(min = 0, max = 100): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function generateRandomFloat(min = 0, max = 100): number { + return Math.random() * (max - min) + min; +} + +export function generateRandomBoolean(): boolean { + return Math.random() > 0.5; +} + +export function generateRandomUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index fc23e5f..6b6137c 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -1,301 +1,8 @@ // @graph-om/testing/src/index.ts // Testing utilities for Graph-OM - mock driver, test helpers, fixtures +// Barrel export - manually selects and re-exports public API -import type { - DriverAdapter, - DriverCapabilities, - QueryRecord, - QueryResult, - TransactionContext, -} from '@graph-om/sdk'; - -export class MockDriverAdapter implements DriverAdapter { - private data: Map>> = new Map(); - private queryLog: Array<{ cypher: string; params?: Record }> = []; - private shouldThrow = false; - private throwError: Error | null = null; - - setShouldThrow(shouldThrow: boolean, error?: Error): void { - this.shouldThrow = shouldThrow; - this.throwError = error ?? new Error('Mock driver error'); - } - - async connect(): Promise { - if (this.shouldThrow) throw this.throwError; - } - - async disconnect(): Promise { - this.data.clear(); - this.queryLog = []; - } - - async runQuery(cypher: string, params?: Record): Promise { - this.queryLog.push({ cypher, params }); - - if (this.shouldThrow) throw this.throwError; - - // Simple mock implementation - if (cypher.includes('MATCH (n:') && cypher.includes('RETURN n')) { - const labelMatch = cypher.match(/MATCH \(n:(\w+)\)/); - const label = labelMatch?.[1] ?? 'Node'; - const limitMatch = cypher.match(/LIMIT\s+(\d+)/); - const limit = limitMatch?.[1] ? parseInt(limitMatch[1], 10) : 100; - - const nodes = this.data.get(label) ?? new Map(); - const records = Array.from(nodes.values()) - .slice(0, limit) - .map((node) => this.createRecord(node)); - - return { - records, - summary: { - query: { text: cypher, parameters: params ?? {} }, - counters: {}, - notifications: [], - resultAvailableAfter: 0, - resultConsumedAfter: 0, - database: { name: 'mock' }, - }, - keys: ['n'], - }; - } - - if (cypher.includes('CREATE')) { - return { - records: [], - summary: { - query: { text: cypher, parameters: params ?? {} }, - counters: { nodesCreated: 1 }, - notifications: [], - resultAvailableAfter: 0, - resultConsumedAfter: 0, - database: { name: 'mock' }, - }, - keys: [], - }; - } - - if (cypher.includes('DELETE') || cypher.includes('DETACH DELETE')) { - return { - records: [], - summary: { - query: { text: cypher, parameters: params ?? {} }, - counters: { nodesDeleted: 1 }, - notifications: [], - resultAvailableAfter: 0, - resultConsumedAfter: 0, - database: { name: 'mock' }, - }, - keys: [], - }; - } - - return { - records: [], - summary: { - query: { text: cypher, parameters: params ?? {} }, - counters: {}, - notifications: [], - resultAvailableAfter: 0, - resultConsumedAfter: 0, - database: { name: 'mock' }, - }, - keys: [], - }; - } - - async runInTransaction(work: (txc: TransactionContext) => Promise): Promise { - return work(this.createTransactionContext()); - } - - async runInReadTransaction(work: (txc: TransactionContext) => Promise): Promise { - return work(this.createTransactionContext()); - } - - async verifyConnectivity(): Promise { - return true; - } - - async getServerInfo(): Promise<{ address: string; version: string; protocol: string }> { - return { - address: 'mock://localhost', - version: 'mock', - protocol: 'mock', - }; - } - - getCapabilities(): DriverCapabilities { - return { - transactions: true, - readReplicas: false, - boltProtocol: false, - httpProtocol: false, - vectorSearch: false, - fullTextSearch: false, - geospatial: false, - procedures: [], - }; - } - - private createTransactionContext(): TransactionContext { - return { - run: async (query: string, params?: Record): Promise => { - return this.runQuery(query, params); - }, - }; - } - - getQueryCount(cypher: string): number { - return this.queryLog.filter((q) => q.cypher === cypher).length; - } - - mockQuery(_cypher: string, _records: unknown[], _params?: Record): void { - // This would be used to mock specific query responses - } - - private createRecord(data: Record): QueryRecord { - const obj = { ...data }; - return { - keys: Object.keys(obj), - get: (key: string) => obj[key], - toObject: () => ({ ...obj }), - }; - } -} - -export function createMockDriver(): MockDriverAdapter { - return new MockDriverAdapter(); -} - -// Vitest/Jest matchers for Graph-OM -type MatchableNode = { label?: unknown; relations?: Record } | null | undefined; - -export const graphMatchers = { - toBeValidNode: (node: MatchableNode, expectedLabel?: string) => { - if (!node || typeof node !== 'object') { - return { pass: false, message: () => 'Expected a valid node object' }; - } - if (expectedLabel && node.label !== expectedLabel) { - return { - pass: false, - message: () => `Expected node label ${expectedLabel}, got ${node.label}`, - }; - } - return { pass: true, message: () => 'Valid node' }; - }, - - toHaveRelation: (node: MatchableNode, relationName: string) => { - if (!node?.relations?.[relationName]) { - return { pass: false, message: () => `Expected node to have relation ${relationName}` }; - } - return { pass: true, message: () => 'Has relation' }; - }, - - toMatchCypherPattern: (cypher: string, pattern: string | RegExp) => { - const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; - const matches = regex.test(cypher); - return { - pass: matches, - message: () => `Expected Cypher to ${matches ? 'not ' : ''}match pattern`, - }; - }, -}; - -// Test fixtures -export interface TestFixture { - name: string; - data: Record[]; -} - -export function createTestFixtures(): { - createUserFactory: () => (index: number) => Record; - createPostFactory: () => (index: number) => Record; - createRelationFactory: () => ( - fromId: string, - toId: string, - type: string, - ) => Record; -} { - return { - createUserFactory: () => { - const _counter = 0; - return (index: number) => ({ - id: `user-${index}`, - name: `User ${index}`, - email: `user${index}@example.com`, - age: 20 + (index % 50), - createdAt: new Date(), - }); - }, - createPostFactory: () => { - const _counter = 0; - return (index: number) => ({ - id: `post-${index}`, - title: `Post ${index}`, - content: `Content for post ${index}`, - published: index % 2 === 0, - createdAt: new Date(), - }); - }, - createRelationFactory: () => { - return (fromId: string, toId: string, type: string) => ({ - from: fromId, - to: toId, - type, - createdAt: new Date(), - }); - }, - }; -} - -export function generateTestData({ - count, - factory, -}: { - count: number; - factory: (index: number) => T; -}): T[] { - return Array.from({ length: count }, (_, i) => factory(i)); -} - -export function generateRandomString(length = 10): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - -export function generateRandomEmail(): string { - return `${generateRandomString(8)}@example.com`; -} - -export function generateRandomDate(start = new Date(2020, 0, 1), end = new Date()): Date { - return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -} - -export function generateRandomInt(min = 0, max = 100): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -export function generateRandomFloat(min = 0, max = 100): number { - return Math.random() * (max - min) + min; -} - -export function generateRandomBoolean(): boolean { - return Math.random() > 0.5; -} - -export function generateRandomUuid(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -// Export types +// Type re-exports from @graph-om/sdk export type { DriverAdapter, DriverCapabilities, @@ -309,3 +16,22 @@ export type { TransactionContext, WhereCondition, } from '@graph-om/sdk'; +// Driver domain +export { createMockDriver, MockDriverAdapter } from './driver/index'; + +// Fixtures domain +export type { TestFixture } from './fixtures/index'; +export { createTestFixtures, generateTestData } from './fixtures/index'; + +// Generators domain +export { + generateRandomBoolean, + generateRandomDate, + generateRandomEmail, + generateRandomFloat, + generateRandomInt, + generateRandomString, + generateRandomUuid, +} from './generators/index'; +// Matchers domain +export { graphMatchers } from './matchers/index'; diff --git a/packages/testing/src/matchers/graph-matchers.ts b/packages/testing/src/matchers/graph-matchers.ts new file mode 100644 index 0000000..4257830 --- /dev/null +++ b/packages/testing/src/matchers/graph-matchers.ts @@ -0,0 +1,38 @@ +// @graph-om/testing/src/matchers/graph-matchers.ts +// Vitest/Jest matchers for Graph-OM + +type MatchableNode = { label?: unknown; relations?: Record } | null | undefined; + +export const graphMatchers = { + toBeValidNode: (node: MatchableNode, expectedLabel?: string) => { + if (!node || typeof node !== 'object') { + return { pass: false, message: () => 'Expected a valid node object' }; + } + if (expectedLabel && node.label !== expectedLabel) { + return { + pass: false, + message: () => `Expected node label ${expectedLabel}, got ${node.label}`, + }; + } + return { pass: true, message: () => 'Valid node' }; + }, + + toHaveRelation: (node: MatchableNode, relationName: string) => { + if (!node?.relations?.[relationName]) { + return { + pass: false, + message: () => `Expected node to have relation ${relationName}`, + }; + } + return { pass: true, message: () => 'Has relation' }; + }, + + toMatchCypherPattern: (cypher: string, pattern: string | RegExp) => { + const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; + const matches = regex.test(cypher); + return { + pass: matches, + message: () => `Expected Cypher to ${matches ? 'not ' : ''}match pattern`, + }; + }, +}; diff --git a/packages/testing/src/matchers/index.ts b/packages/testing/src/matchers/index.ts new file mode 100644 index 0000000..de60cc1 --- /dev/null +++ b/packages/testing/src/matchers/index.ts @@ -0,0 +1,4 @@ +// @graph-om/testing/src/matchers/index.ts +// Matchers domain barrel + +export { graphMatchers } from './graph-matchers'; diff --git a/packages/testing/tsup.config.ts b/packages/testing/tsup.config.ts index ed1b7b5..3b0d98a 100644 --- a/packages/testing/tsup.config.ts +++ b/packages/testing/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['cjs', 'esm'], - dts: true, + dts: false, splitting: false, sourcemap: true, clean: true, diff --git a/packages/zod/LICENSE.md b/packages/zod/LICENSE.md deleted file mode 100644 index d645695..0000000 --- a/packages/zod/LICENSE.md +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/zod/package.json b/packages/zod/package.json index 0dce74a..1e624e8 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -21,8 +21,7 @@ ], "sideEffects": false, "files": [ - "dist", - "LICENSE.md" + "dist" ], "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -37,7 +36,7 @@ }, "scripts": { "clean": "rm -rf dist .turbo coverage", - "build": "tsup", + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --composite false --incremental false", "dev": "tsup --watch", "test": "vitest run", "test:ci": "vitest run --reporter=verbose", diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 41ccf3d..75dd27d 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,200 +1,11 @@ // @graph-om/zod/src/index.ts // Zod validation integration for Graph-OM - -import type { - CreateInput, - NodeConfig, - PropertyConfig, - PropertyType, - UpdateInput, -} from '@graph-om/sdk'; -import { z } from 'zod'; - -type PropertyTypeToZodType = T extends 'string' - ? z.ZodString - : T extends 'number' - ? z.ZodNumber - : T extends 'boolean' - ? z.ZodBoolean - : T extends 'date' - ? z.ZodDate - : T extends 'bigint' - ? z.ZodBigInt - : T extends 'float' - ? z.ZodNumber - : T extends 'uuid' - ? z.ZodString - : T extends 'json' - ? z.ZodUnknown - : z.ZodTypeAny; - -function propertyTypeToZod( - type: T, - config: PropertyConfig, -): PropertyTypeToZodType { - let schema: z.ZodTypeAny; - - switch (type) { - case 'string': - schema = z.string(); - break; - case 'number': - schema = z.number(); - break; - case 'boolean': - schema = z.boolean(); - break; - case 'date': - schema = z.date(); - break; - case 'bigint': - schema = z.bigint(); - break; - case 'float': - schema = z.number(); - break; - case 'uuid': - schema = z.string().uuid(); - break; - case 'json': - schema = z.unknown(); - break; - default: - schema = z.unknown(); - } - - if (config.array) { - schema = z.array(schema); - } - - if (config.nullable) { - schema = schema.nullable(); - } - - if (config.default !== undefined) { - const defaultValue = - typeof config.default === 'function' ? (config.default as () => unknown)() : config.default; - schema = schema.default(defaultValue); - } - - return schema as PropertyTypeToZodType; -} - -function propertiesToZodShape( - properties: Record, -): Record { - const shape: Record = {}; - - for (const [key, config] of Object.entries(properties)) { - const propConfig = config as PropertyConfig; - shape[key] = propertyTypeToZod(propConfig.type as PropertyType, propConfig); - } - - return shape; -} - -export function toZodSchema( - nodeConfig: NodeConfig>, -): z.ZodObject> { - const shape = propertiesToZodShape(nodeConfig.properties); - return z.object(shape); -} - -export function toCreateSchema( - nodeConfig: NodeConfig>, -): z.ZodObject> { - const shape: Record = {}; - - for (const [key, config] of Object.entries(nodeConfig.properties)) { - const propConfig = config as PropertyConfig; - const schema = propertyTypeToZod(propConfig.type as PropertyType, propConfig); - if (propConfig.nullable && propConfig.default === undefined) { - shape[key] = schema.optional(); - } else { - shape[key] = schema; - } - } - - return z.object(shape); -} - -export function toUpdateSchema( - nodeConfig: NodeConfig>, -): z.ZodObject> { - const shape: Record = {}; - - for (const [key, config] of Object.entries(nodeConfig.properties)) { - const propConfig = config as PropertyConfig; - if (propConfig.primary) continue; - const schema = propertyTypeToZod(propConfig.type, propConfig); - shape[key] = schema.optional(); - } - - return z.object(shape); -} - -export function validateCreate( - nodeConfig: NodeConfig>, - data: unknown, -): - | { success: true; data: CreateInput> } - | { success: false; error: z.ZodError } { - const schema = toCreateSchema(nodeConfig); - const result = schema.safeParse(data); - if (result.success) { - return { success: true, data: result.data as CreateInput> }; - } - return { success: false, error: result.error }; -} - -export function validateUpdate( - nodeConfig: NodeConfig>, - data: unknown, -): - | { success: true; data: UpdateInput> } - | { success: false; error: z.ZodError } { - const schema = toUpdateSchema(nodeConfig); - const result = schema.safeParse(data); - if (result.success) { - return { success: true, data: result.data as UpdateInput> }; - } - return { success: false, error: result.error }; -} - -export function validationHooks( - nodeConfig: NodeConfig>, - options?: { - beforeCreate?: boolean; - beforeUpdate?: boolean; - }, -): { - beforeCreate?: (data: unknown) => CreateInput>; - beforeUpdate?: (data: unknown) => UpdateInput>; -} { - const hooks: { - beforeCreate?: (data: unknown) => CreateInput>; - beforeUpdate?: (data: unknown) => UpdateInput>; - } = {}; - - if (options?.beforeCreate !== false) { - hooks.beforeCreate = (data: unknown) => { - const result = validateCreate(nodeConfig, data); - if (!result.success) { - throw result.error; - } - return result.data; - }; - } - - if (options?.beforeUpdate !== false) { - hooks.beforeUpdate = (data: unknown) => { - const result = validateUpdate(nodeConfig, data); - if (!result.success) { - throw result.error; - } - return result.data; - }; - } - - return hooks; -} +// Barrel export - manually selects and re-exports public API + +// Schema generation domain +export { toCreateSchema, toUpdateSchema, toZodSchema } from './schema/factory'; +export { propertiesToZodShape, propertyTypeToZod } from './schema/property'; +export type { PropertyTypeToZodType } from './schema/types'; +export { validationHooks } from './validation/hooks'; +// Validation domain +export { validateCreate, validateUpdate } from './validation/validate'; diff --git a/packages/zod/src/schema/factory.ts b/packages/zod/src/schema/factory.ts new file mode 100644 index 0000000..a2cb57f --- /dev/null +++ b/packages/zod/src/schema/factory.ts @@ -0,0 +1,46 @@ +// @graph-om/zod/src/schema/factory.ts +// Zod schema factory functions for node configs + +import type { NodeConfig, PropertyConfig, PropertyType } from '@graph-om/sdk'; +import { z } from 'zod'; +import { propertiesToZodShape, propertyTypeToZod } from './property'; + +export function toZodSchema( + nodeConfig: NodeConfig>, +): z.ZodObject> { + const shape = propertiesToZodShape(nodeConfig.properties); + return z.object(shape); +} + +export function toCreateSchema( + nodeConfig: NodeConfig>, +): z.ZodObject> { + const shape: Record = {}; + + for (const [key, config] of Object.entries(nodeConfig.properties)) { + const propConfig = config as PropertyConfig; + const schema = propertyTypeToZod(propConfig.type as PropertyType, propConfig); + if (propConfig.nullable && propConfig.default === undefined) { + shape[key] = schema.optional(); + } else { + shape[key] = schema; + } + } + + return z.object(shape); +} + +export function toUpdateSchema( + nodeConfig: NodeConfig>, +): z.ZodObject> { + const shape: Record = {}; + + for (const [key, config] of Object.entries(nodeConfig.properties)) { + const propConfig = config as PropertyConfig; + if (propConfig.primary) continue; + const schema = propertyTypeToZod(propConfig.type, propConfig); + shape[key] = schema.optional(); + } + + return z.object(shape); +} diff --git a/packages/zod/src/schema/index.ts b/packages/zod/src/schema/index.ts new file mode 100644 index 0000000..a561eb8 --- /dev/null +++ b/packages/zod/src/schema/index.ts @@ -0,0 +1,6 @@ +// @graph-om/zod/src/schema/index.ts +// Schema generation domain barrel + +export { toCreateSchema, toUpdateSchema, toZodSchema } from './factory'; +export { propertiesToZodShape, propertyTypeToZod } from './property'; +export type { PropertyTypeToZodType } from './types'; diff --git a/packages/zod/src/schema/property.ts b/packages/zod/src/schema/property.ts new file mode 100644 index 0000000..01f6060 --- /dev/null +++ b/packages/zod/src/schema/property.ts @@ -0,0 +1,71 @@ +// @graph-om/zod/src/schema/property.ts +// Property-level Zod schema conversion + +import type { PropertyConfig, PropertyType } from '@graph-om/sdk'; +import { z } from 'zod'; +import type { PropertyTypeToZodType } from './types'; + +export function propertyTypeToZod( + type: T, + config: PropertyConfig, +): PropertyTypeToZodType { + let schema: z.ZodTypeAny; + + switch (type) { + case 'string': + schema = z.string(); + break; + case 'number': + schema = z.number(); + break; + case 'boolean': + schema = z.boolean(); + break; + case 'date': + schema = z.date(); + break; + case 'bigint': + schema = z.bigint(); + break; + case 'float': + schema = z.number(); + break; + case 'uuid': + schema = z.string().uuid(); + break; + case 'json': + schema = z.unknown(); + break; + default: + schema = z.unknown(); + } + + if (config.array) { + schema = z.array(schema); + } + + if (config.nullable) { + schema = schema.nullable(); + } + + if (config.default !== undefined) { + const defaultValue = + typeof config.default === 'function' ? (config.default as () => unknown)() : config.default; + schema = schema.default(defaultValue); + } + + return schema as PropertyTypeToZodType; +} + +export function propertiesToZodShape( + properties: Record, +): Record { + const shape: Record = {}; + + for (const [key, config] of Object.entries(properties)) { + const propConfig = config as PropertyConfig; + shape[key] = propertyTypeToZod(propConfig.type as PropertyType, propConfig); + } + + return shape; +} diff --git a/packages/zod/src/schema/types.ts b/packages/zod/src/schema/types.ts new file mode 100644 index 0000000..34677b2 --- /dev/null +++ b/packages/zod/src/schema/types.ts @@ -0,0 +1,23 @@ +// @graph-om/zod/src/schema/types.ts +// Type aliases for Zod schema generation + +import type { PropertyType } from '@graph-om/sdk'; +import type { z } from 'zod'; + +export type PropertyTypeToZodType = T extends 'string' + ? z.ZodString + : T extends 'number' + ? z.ZodNumber + : T extends 'boolean' + ? z.ZodBoolean + : T extends 'date' + ? z.ZodDate + : T extends 'bigint' + ? z.ZodBigInt + : T extends 'float' + ? z.ZodNumber + : T extends 'uuid' + ? z.ZodString + : T extends 'json' + ? z.ZodUnknown + : z.ZodTypeAny; diff --git a/packages/zod/src/validation/hooks.ts b/packages/zod/src/validation/hooks.ts new file mode 100644 index 0000000..ca8a3e9 --- /dev/null +++ b/packages/zod/src/validation/hooks.ts @@ -0,0 +1,43 @@ +// @graph-om/zod/src/validation/hooks.ts +// Validation hooks for create and update lifecycle events + +import type { CreateInput, NodeConfig, PropertyConfig, UpdateInput } from '@graph-om/sdk'; +import { validateCreate, validateUpdate } from './validate'; + +export function validationHooks( + nodeConfig: NodeConfig>, + options?: { + beforeCreate?: boolean; + beforeUpdate?: boolean; + }, +): { + beforeCreate?: (data: unknown) => CreateInput>; + beforeUpdate?: (data: unknown) => UpdateInput>; +} { + const hooks: { + beforeCreate?: (data: unknown) => CreateInput>; + beforeUpdate?: (data: unknown) => UpdateInput>; + } = {}; + + if (options?.beforeCreate !== false) { + hooks.beforeCreate = (data: unknown) => { + const result = validateCreate(nodeConfig, data); + if (!result.success) { + throw result.error; + } + return result.data; + }; + } + + if (options?.beforeUpdate !== false) { + hooks.beforeUpdate = (data: unknown) => { + const result = validateUpdate(nodeConfig, data); + if (!result.success) { + throw result.error; + } + return result.data; + }; + } + + return hooks; +} diff --git a/packages/zod/src/validation/index.ts b/packages/zod/src/validation/index.ts new file mode 100644 index 0000000..dec2920 --- /dev/null +++ b/packages/zod/src/validation/index.ts @@ -0,0 +1,5 @@ +// @graph-om/zod/src/validation/index.ts +// Validation domain barrel + +export { validationHooks } from './hooks'; +export { validateCreate, validateUpdate } from './validate'; diff --git a/packages/zod/src/validation/validate.ts b/packages/zod/src/validation/validate.ts new file mode 100644 index 0000000..b4877f1 --- /dev/null +++ b/packages/zod/src/validation/validate.ts @@ -0,0 +1,34 @@ +// @graph-om/zod/src/validation/validate.ts +// Validation functions for create and update operations + +import type { CreateInput, NodeConfig, PropertyConfig, UpdateInput } from '@graph-om/sdk'; +import type { z } from 'zod'; +import { toCreateSchema, toUpdateSchema } from '../schema/factory'; + +export function validateCreate( + nodeConfig: NodeConfig>, + data: unknown, +): + | { success: true; data: CreateInput> } + | { success: false; error: z.ZodError } { + const schema = toCreateSchema(nodeConfig); + const result = schema.safeParse(data); + if (result.success) { + return { success: true, data: result.data as CreateInput> }; + } + return { success: false, error: result.error }; +} + +export function validateUpdate( + nodeConfig: NodeConfig>, + data: unknown, +): + | { success: true; data: UpdateInput> } + | { success: false; error: z.ZodError } { + const schema = toUpdateSchema(nodeConfig); + const result = schema.safeParse(data); + if (result.success) { + return { success: true, data: result.data as UpdateInput> }; + } + return { success: false, error: result.error }; +} diff --git a/packages/zod/tsup.config.ts b/packages/zod/tsup.config.ts index 2801a96..59db98e 100644 --- a/packages/zod/tsup.config.ts +++ b/packages/zod/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['cjs', 'esm'], - dts: true, + dts: false, splitting: false, sourcemap: true, clean: true,