Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
66 changes: 66 additions & 0 deletions src/bundle/canonicalize.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>;
const result: Record<string, unknown> = {};
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));
}
95 changes: 76 additions & 19 deletions src/bundle/exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<BundleMetadata>;
}
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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;
}

/**
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/bundle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 0 additions & 1 deletion src/core/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading