diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..786eb8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Test + run: npm test + - name: Build (emits dist/index.d.ts) + run: npm run build + # `npm run build` runs `check:dts`, which fails the job if the declaration + # file was not produced (tsup exits 0 even when the DTS phase fails, so + # this explicit assertion is what actually gates the type contract). + - name: Assert declaration file shipped + run: test -f dist/index.d.ts diff --git a/package-lock.json b/package-lock.json index fb58932..7571fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "core-graph", + "name": "graphbase", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "core-graph", + "name": "graphbase", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 94212f1..c3d0d0b 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,12 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts", + "build": "tsup src/index.ts --format esm,cjs --dts && npm run check:dts", + "check:dts": "node -e \"require('node:fs').accessSync('dist/index.d.ts'); console.log('dist/index.d.ts present')\"", "test": "vitest run", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit", - "lint": "eslint src --ext .ts" + "typecheck": "tsc --noEmit" }, "keywords": ["graph", "database", "typescript", "schema"], "author": "", diff --git a/src/bundle/canonicalize.ts b/src/bundle/canonicalize.ts new file mode 100644 index 0000000..078930d --- /dev/null +++ b/src/bundle/canonicalize.ts @@ -0,0 +1,66 @@ +/** + * @file Canonical serialization helpers for deterministic bundle export + * @description Produces a byte-stable, insertion-order-independent representation + * of arbitrary JSON-compatible values by recursively sorting object keys. + * + * @remarks + * This is a deliberately small, self-contained local canonicalizer. It is the + * seam intended to be replaced by the spine's `canon v1` serializer once that + * interface is frozen (see `roadmaps/_SPINE.md` S1). Keep it isolated so the + * swap is a one-file change. + * + * Scope: object key ordering only. Array element order is meaningful and is + * preserved; callers are responsible for sorting domain arrays (nodes, edges) + * with explicit comparators before canonicalization. + * + * @module bundle/canonicalize + */ + +/** + * Recursively produce a canonical clone of a JSON-compatible value with all + * object keys inserted in sorted (code-unit) order. + * + * @param value - Any JSON-serializable value + * @returns A new value with deterministic object-key ordering. Arrays preserve + * their element order; primitives are returned unchanged. + * + * @remarks + * - Object keys are sorted using the default string comparison (UTF-16 code + * units), matching `Array.prototype.sort` defaults so the order is stable + * across runs and platforms. + * - `undefined` object values are dropped (consistent with `JSON.stringify`). + * - Input is never mutated; a fresh structure is always returned. This also + * serves as a defensive clone at the bundle boundary. + */ +export function canonicalize(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item)) as unknown as T; + } + + if (value !== null && typeof value === 'object') { + const source = value as Record; + const result: Record = {}; + const keys = Object.keys(source).sort(); + for (const key of keys) { + const v = source[key]; + if (v === undefined) { + continue; + } + result[key] = canonicalize(v); + } + return result as unknown as T; + } + + return value; +} + +/** + * Serialize a value to a canonical JSON string: deterministic regardless of + * the input object-key insertion order. + * + * @param value - Any JSON-serializable value + * @returns A canonical JSON string suitable for hashing / content-addressing + */ +export function canonicalStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} diff --git a/src/bundle/exporter.ts b/src/bundle/exporter.ts index f69e78d..7edc780 100644 --- a/src/bundle/exporter.ts +++ b/src/bundle/exporter.ts @@ -7,8 +7,10 @@ import type { Storage } from '../storage/index.js'; import type { SchemaRegistry } from '../schema/index.js'; +import type { Node, Edge } from '../types/index.js'; import type { Bundle, BundleMetadata, BundleSchemas } from './format.js'; import { BUNDLE_VERSION } from './format.js'; +import { canonicalize } from './canonicalize.js'; /** * Options for exporting a bundle. @@ -30,7 +32,13 @@ export interface ExportOptions { /** * Metadata to include in the bundle. - * If createdAt is not provided, it will be auto-generated. + * + * @remarks + * Metadata is canonicalized like the rest of the bundle but is **not** + * auto-populated. In particular, no wall-clock `createdAt` is injected — a + * timestamp, if needed, is caller-supplied so that the exported bytes are + * deterministic and content-addressable. Any `createdAt` you pass is + * preserved verbatim; if you omit it, the field is simply absent. */ metadata?: Partial; } @@ -43,13 +51,18 @@ export interface ExportOptions { * @returns A bundle containing all graph data * * @remarks - * The export process: - * 1. Collects all nodes from storage - * 2. Collects all edges from storage - * 3. Optionally includes schemas from the registry - * 4. Adds metadata with auto-generated timestamp if needed + * The export process is **deterministic** — two semantically identical graphs + * built in different insertion orders export to byte-identical bundles: + * 1. Collects all nodes from storage and sorts them by `id` + * 2. Collects all edges from storage and sorts them by `(source, type, target, id)` + * 3. Optionally includes schemas from the registry, sorted by type key + * 4. Includes caller-supplied metadata verbatim (no wall-clock injected) + * 5. Recursively canonicalizes all object-key ordering (including nested + * property and schema shapes) * - * The resulting bundle is a plain object that can be serialized to JSON. + * The export also defensively clones every value, so mutating the returned + * bundle never affects stored graph state. The resulting bundle is a plain + * object that can be serialized to JSON and content-addressed by hashing. * * @example * ```typescript @@ -75,9 +88,10 @@ export interface ExportOptions { export function exportBundle(storage: Storage, options: ExportOptions = {}): Bundle { const { schemaRegistry, metadata } = options; - // Export nodes and edges from storage - const nodes = storage.getAllNodes(); - const edges = storage.getAllEdges(); + // Export nodes and edges from storage, then sort into canonical order so the + // result is independent of Map insertion order. + const nodes = [...storage.getAllNodes()].sort(compareNodes); + const edges = [...storage.getAllEdges()].sort(compareEdges); // Build the bundle const bundle: Bundle = { @@ -94,16 +108,56 @@ export function exportBundle(storage: Storage, options: ExportOptions = {}): Bun } } - // Add metadata if provided + // Include caller-supplied metadata verbatim. No wall-clock timestamp is + // injected: the hashed envelope must not depend on when it was produced. if (metadata) { - bundle.metadata = { - ...metadata, - // Auto-generate createdAt if not provided - createdAt: metadata.createdAt ?? new Date().toISOString(), - }; + bundle.metadata = { ...metadata }; } - return bundle; + // Recursively canonicalize object-key ordering across the whole bundle + // (nested property and schema shapes included). This is also a defensive + // deep clone, so the returned bundle shares no references with storage. + return canonicalize(bundle); +} + +/** + * Total-order comparator for nodes: by `id`. + * + * @remarks + * Node ids are unique within a graph, so this is a strict total order with no + * tie-breaking ambiguity. + * + * @internal + */ +function compareNodes(a: Node, b: Node): number { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * Total-order comparator for edges: by `(source, type, target, id)`. + * + * @remarks + * Edge ids are unique, so `id` is the final, deterministic tie-breaker; the + * leading fields group semantically related edges for readable output. + * + * @internal + */ +function compareEdges(a: Edge, b: Edge): number { + return ( + cmp(a.source, b.source) || + cmp(a.type, b.type) || + cmp(a.target, b.target) || + cmp(a.id, b.id) + ); +} + +/** + * Stable string comparator returning -1 / 0 / 1. + * + * @internal + */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; } /** @@ -115,8 +169,11 @@ export function exportBundle(storage: Storage, options: ExportOptions = {}): Bun * @internal */ function exportSchemas(registry: SchemaRegistry): BundleSchemas | undefined { - const nodeTypes = registry.listNodeTypes(); - const edgeTypes = registry.listEdgeTypes(); + // Sort type keys so schema export order is independent of registration order. + // (The final canonicalize pass would also sort these, but sorting here keeps + // exportSchemas self-consistent and the intent explicit.) + const nodeTypes = [...registry.listNodeTypes()].sort(); + const edgeTypes = [...registry.listEdgeTypes()].sort(); // Return undefined if no schemas registered if (nodeTypes.length === 0 && edgeTypes.length === 0) { diff --git a/src/bundle/index.ts b/src/bundle/index.ts index 6f30db5..2143214 100644 --- a/src/bundle/index.ts +++ b/src/bundle/index.ts @@ -68,6 +68,9 @@ export { BUNDLE_VERSION } from './format.js'; export { exportBundle } from './exporter.js'; export type { ExportOptions } from './exporter.js'; +// Export canonical-serialization helpers (deterministic, hashable bytes) +export { canonicalize, canonicalStringify } from './canonicalize.js'; + // Export importer export { importBundle, BundleImportError } from './importer.js'; export type { ImportOptions, ImportResult } from './importer.js'; diff --git a/src/core/Graph.ts b/src/core/Graph.ts index 2eb0a80..d7ec5f6 100644 --- a/src/core/Graph.ts +++ b/src/core/Graph.ts @@ -15,7 +15,6 @@ import { findEdges as opsFindEdges, traverse as opsTraverse, findPath as opsFindPath, - TraverseDirection, } from '../operations/index.js'; import type { TraverseOptions, FindPathOptions } from '../operations/index.js'; import type { Bundle, ExportOptions as BundleExportOptions, ImportOptions as BundleImportOptions, ImportResult } from '../bundle/index.js'; diff --git a/src/index.ts b/src/index.ts index f72e0de..bf49045 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,11 @@ export { export type { TraverseOptions, FindPathOptions } from './operations/index.js'; // Bundle +// NOTE: canonicalize/canonicalStringify are intentionally NOT re-exported at the +// package root. They remain available from './bundle' for internal use, but the +// canonical-serialization surface is provisional pending the spine's `canon v1` +// (and the narrow CanonicalGraphBundle envelope planned in P2), so we avoid +// committing to them as a root-level public contract here. export { exportBundle, importBundle, diff --git a/tests/unit/bundle.test.ts b/tests/unit/bundle.test.ts index 19bf306..13dbdeb 100644 --- a/tests/unit/bundle.test.ts +++ b/tests/unit/bundle.test.ts @@ -174,11 +174,19 @@ describe('Bundle module', () => { expect(bundle.metadata?.description).toBe('A bundle for testing'); }); - it('should auto-generate createdAt in metadata if not provided', () => { + it('should NOT auto-generate createdAt (export must be deterministic)', () => { + // A wall-clock timestamp would make the hashed envelope non-deterministic, + // so createdAt is never injected — it is caller-supplied or absent. const bundle = exportBundle(storage, { metadata: { name: 'Test' } }); - expect(bundle.metadata?.createdAt).toBeDefined(); - // Should be a valid ISO date string - expect(() => new Date(bundle.metadata!.createdAt!)).not.toThrow(); + expect(bundle.metadata?.name).toBe('Test'); + expect(bundle.metadata?.createdAt).toBeUndefined(); + }); + + it('should preserve caller-supplied createdAt verbatim', () => { + const bundle = exportBundle(storage, { + metadata: { name: 'Test', createdAt: '2025-12-30T00:00:00Z' }, + }); + expect(bundle.metadata?.createdAt).toBe('2025-12-30T00:00:00Z'); }); it('should preserve node properties types', () => { @@ -649,6 +657,195 @@ describe('Bundle module', () => { }); }); + describe('Deterministic export (canonical, byte-stable)', () => { + /** + * Build the SAME logical graph two ways, differing in: + * - the order nodes/edges are added to storage, AND + * - the key-insertion order of nested property objects (and a schema). + * A correct canonical export must yield byte-identical JSON for both. + * + * NOTE: this test is designed to FAIL if recursive key canonicalization is + * removed — the nested property key orders differ between the two builds, so + * a shallow (array-only) sort would still leave the bytes divergent. + */ + + function buildOrderA(): { storage: MemoryStorage; registry: SchemaRegistry } { + const s = new MemoryStorage(); + const r = new SchemaRegistry(); + + // Nodes added in one order, with one key order for nested objects. + s.addNode({ + id: 'n1', + type: 'Person', + properties: { + name: 'Alice', + address: { city: 'Paris', zip: '75001', street: 'Rue A' }, + tags: ['x', 'y', 'z'], + meta: { a: 1, b: 2 }, + }, + }); + s.addNode({ + id: 'n2', + type: 'Person', + properties: { + name: 'Bob', + scores: [{ k: 1, v: 2 }, { k: 3, v: 4 }], + }, + }); + s.addNode({ id: 'n3', type: 'Person', properties: { name: 'Carol' } }); + + // Edges added in one order, with nested edge property key order. + s.addEdge({ + id: 'e2', + type: 'KNOWS', + source: 'n2', + target: 'n3', + properties: { since: '2021', weight: 0.5 }, + }); + s.addEdge({ + id: 'e1', + type: 'KNOWS', + source: 'n1', + target: 'n2', + properties: { weight: 0.9, since: '2020' }, + }); + + // Schemas registered in one order, with one key order. + r.registerNodeSchema('Person', { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name'], + }); + r.registerEdgeSchema('KNOWS', { + type: 'object', + properties: { since: { type: 'string' } }, + }); + + return { storage: s, registry: r }; + } + + function buildOrderB(): { storage: MemoryStorage; registry: SchemaRegistry } { + const s = new MemoryStorage(); + const r = new SchemaRegistry(); + + // SAME logical graph, but everything inserted in a DIFFERENT order, + // including the key-insertion order of every nested object. + s.addNode({ id: 'n3', type: 'Person', properties: { name: 'Carol' } }); + s.addNode({ + id: 'n2', + type: 'Person', + properties: { + scores: [{ v: 2, k: 1 }, { v: 4, k: 3 }], + name: 'Bob', + }, + }); + s.addNode({ + id: 'n1', + type: 'Person', + properties: { + meta: { b: 2, a: 1 }, + tags: ['x', 'y', 'z'], + address: { zip: '75001', street: 'Rue A', city: 'Paris' }, + name: 'Alice', + }, + }); + + s.addEdge({ + id: 'e1', + type: 'KNOWS', + source: 'n1', + target: 'n2', + properties: { since: '2020', weight: 0.9 }, + }); + s.addEdge({ + id: 'e2', + type: 'KNOWS', + source: 'n2', + target: 'n3', + properties: { weight: 0.5, since: '2021' }, + }); + + // Schemas registered in the OPPOSITE order, different key order. + r.registerEdgeSchema('KNOWS', { + type: 'object', + properties: { since: { type: 'string' } }, + }); + r.registerNodeSchema('Person', { + type: 'object', + required: ['name'], + properties: { age: { type: 'number' }, name: { type: 'string' } }, + }); + + return { storage: s, registry: r }; + } + + it('produces byte-identical JSON across two different insertion orders', () => { + const a = buildOrderA(); + const b = buildOrderB(); + + const bundleA = exportBundle(a.storage, { schemaRegistry: a.registry }); + const bundleB = exportBundle(b.storage, { schemaRegistry: b.registry }); + + const jsonA = JSON.stringify(bundleA); + const jsonB = JSON.stringify(bundleB); + + expect(jsonA).toBe(jsonB); + }); + + it('sorts nodes by id regardless of insertion order', () => { + const { storage: s } = buildOrderB(); + const bundle = exportBundle(s); + expect(bundle.nodes.map((n) => n.id)).toEqual(['n1', 'n2', 'n3']); + }); + + it('sorts edges by (source, type, target, id) regardless of insertion order', () => { + const { storage: s } = buildOrderA(); + const bundle = exportBundle(s); + expect(bundle.edges.map((e) => e.id)).toEqual(['e1', 'e2']); + }); + + it('canonicalizes nested property object key order', () => { + const { storage: a } = buildOrderA(); + const { storage: b } = buildOrderB(); + const nodeA = exportBundle(a).nodes.find((n) => n.id === 'n1')!; + const nodeB = exportBundle(b).nodes.find((n) => n.id === 'n1')!; + // Same nested object, keys emitted in sorted order for both. + expect(JSON.stringify(nodeA)).toBe(JSON.stringify(nodeB)); + expect(Object.keys(nodeA.properties.address as object)).toEqual([ + 'city', + 'street', + 'zip', + ]); + }); + + it('is byte-stable through export -> import -> export', () => { + const { storage: s, registry: r } = buildOrderA(); + const first = exportBundle(s, { schemaRegistry: r }); + + const s2 = new MemoryStorage(); + const r2 = new SchemaRegistry(); + importBundle(s2, first, { schemaRegistry: r2 }); + const second = exportBundle(s2, { schemaRegistry: r2 }); + + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + }); + + it('does not inject a wall-clock timestamp into the hashed envelope', () => { + const { storage: s } = buildOrderA(); + const bundle = exportBundle(s, { metadata: { name: 'g' } }); + expect(bundle.metadata?.createdAt).toBeUndefined(); + }); + + it('does not mutate caller property objects (defensive clone)', () => { + const s = new MemoryStorage(); + const props = { b: 2, a: 1 }; + s.addNode({ id: 'n1', type: 'T', properties: props }); + exportBundle(s); + // Original object key order is untouched. + expect(Object.keys(props)).toEqual(['b', 'a']); + }); + }); + describe('Edge cases', () => { it('should handle empty schema registries', () => { const bundle = exportBundle(storage, { schemaRegistry: new SchemaRegistry() });