Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c6bb6d
WIP Schema in code #1207 + did:ad:frozen #1208
joepio Jun 2, 2026
ce650ad
Fix recursive-cargo deadlock in build.rs
joepio Jun 2, 2026
84ba418
Implement did:ad:frozen content-addressed schemas (server + client)
joepio Jun 2, 2026
71eaba1
Add Store.loadSchemaLock for offline, server-free schema availability
joepio Jun 2, 2026
edaeca3
Add Store.createSchemaPointer (signed latest-version pointer)
joepio Jun 2, 2026
703cb62
Port freeze_resources to Rust for cross-language schema authoring
joepio Jun 2, 2026
c244834
Update plan: Rust freeze_resources port done
joepio Jun 2, 2026
1301e9b
Add Rust freeze_schema authoring DSL (cross-language verified)
joepio Jun 2, 2026
112f3c6
Update plan: Rust freeze_schema authoring done
joepio Jun 2, 2026
fa05d5a
Add Store.freezeStructure — generic freeze of any resource graph
joepio Jun 2, 2026
6bdf1ba
Add "Freeze" context-menu action + dialog to the data-browser
joepio Jun 2, 2026
ec6149e
Extract Freeze dialog i18n strings into locale catalogs
joepio Jun 2, 2026
84aa4fb
Add frozen e2e spec + make frozen resources immutable in the UI
joepio Jun 2, 2026
9a38c10
Add a "Frozen" badge to the resource view for did:ad:frozen resources
joepio Jun 2, 2026
0a93e5e
Extract Frozen badge i18n strings
joepio Jun 2, 2026
4850cae
Add code-first schema example (@tomic/lib publish-schema.mjs)
joepio Jun 2, 2026
b8b7784
Update planning docs: did:ad:frozen full-stack + UI + e2e done
joepio Jun 2, 2026
e97b271
docs: add code-first schema tutorial for JS app builders
joepio Jun 2, 2026
424a9d9
planning: add Availability & incentives section for frozen schemas
joepio Jun 2, 2026
2efcd9f
Update schema in code
joepio Jun 2, 2026
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
85 changes: 85 additions & 0 deletions browser/cli/src/commands/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Agent } from '@tomic/lib';

const originalCwd = process.cwd();

describe('schema command', () => {
afterEach(() => {
process.chdir(originalCwd);
vi.restoreAllMocks();
});

it('registers a code schema and generates ontology bindings from the same Store', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);

const dir = await mkdtemp(path.join(tmpdir(), 'atomic-schema-cli-'));
const keys = await Agent.generateKeyPair();
const agentSecret = Agent.buildSecret(
keys.privateKey,
`did:ad:agent:${keys.publicKey}`,
);

await writeFile(
path.join(dir, 'atomic.config.json'),
JSON.stringify(
{
outputFolder: './src/ontologies',
ontologies: [],
agentSecret,
_ISLIB_: false,
},
null,
2,
),
);
await writeFile(
path.join(dir, 'todo-schema.mjs'),
`export default {
name: 'TodoApp',
version: '1.0.0',
classes: {
todo: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string', description: 'Task title' },
done: { type: 'boolean' }
}
}
}
};`,
);

process.chdir(dir);

const { schemaCommand } = await import('./schema.js');
await schemaCommand(['./todo-schema.mjs', '--local', '--generate', '--lock']);

const generated = await readFile(
path.join(dir, 'src/ontologies/todoApp.ts'),
'utf8',
);
const index = await readFile(
path.join(dir, 'src/ontologies/index.ts'),
'utf8',
);

expect(generated).toContain('export const todoapp');
expect(generated).toContain('classes:');
expect(generated).toContain('properties:');
expect(generated).toContain('interface PropTypeMapping');
expect(generated).toContain('did:ad:');
expect(index).toContain("from './todoapp.js'");

// The committed, self-verifying lockfile is written and verifies.
const { verifySchemaLock } = await import('@tomic/lib');
const lock = JSON.parse(
await readFile(path.join(dir, 'src/ontologies/TodoApp.schema.lock.json'), 'utf8'),
);
expect(verifySchemaLock(lock)).toEqual({ ok: true, errors: [] });
expect(lock.ontology).toMatch(/^did:ad:frozen:[0-9a-f]{64}$/);
});
});
249 changes: 249 additions & 0 deletions browser/cli/src/commands/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/* eslint-disable no-console */
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import chalk from 'chalk';
import {
resolveConfig as prettierResolveConfig,
format as prettierFormat,
} from 'prettier';
import {
buildSchemaLock,
verifySchemaLock,
type DefinedSchema,
type AtomicSchemaPackage,
type RegisteredSchema,
} from '@tomic/lib';
import { createConfiguredStore } from '../store.js';
import { atomicConfig } from '../config.js';
import { generateOntology } from '../generateOntology.js';
import { PropertyRecord } from '../PropertyRecord.js';
import { generateExternals } from '../generateExternals.js';
import { generateIndex } from '../generateIndex.js';

type SchemaModule = Record<string, unknown>;
type SchemaInput = DefinedSchema | AtomicSchemaPackage;

interface SchemaCommandOptions {
exportName: string;
save: boolean;
addToConfig: boolean;
generateBindings: boolean;
lock: boolean;
}

export const schemaCommand = async (args: string[]) => {
const [schemaPath, ...rest] = args;

if (!schemaPath) {
console.error(
chalk.red('ERROR: Missing schema module path.'),
'\nUsage: ad-generate schema ./schema.js [--export todoSchema] [--local] [--generate]',
);
process.exit(1);
}

const opts = parseOptions(rest);
const schema = await loadSchema(schemaPath, opts.exportName);
const store = await createConfiguredStore();

if (opts.save) {
store.setServerConnected(true);
}

const registered = await store.registerSchema(schema, { save: opts.save });

if (opts.addToConfig) {
await addOntologyToConfig(registered.ontology.subject);
}

if (opts.generateBindings) {
await writeOntologyBindings(registered, store);
}

if (opts.lock) {
await writeSchemaLock(schema);
}

printRegisteredSchema(registered, opts.save);
};

async function writeSchemaLock(schema: SchemaInput): Promise<void> {
const lock = buildSchemaLock(schema);
const verification = verifySchemaLock(lock);

if (!verification.ok) {
throw new Error(
`Refusing to write an invalid schema lock:\n${verification.errors.join('\n')}`,
);
}

const outputFolder = path.resolve(process.cwd(), atomicConfig.outputFolder);
const filePath = path.join(outputFolder, `${lock.name}.schema.lock.json`);
await fs.mkdir(outputFolder, { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(lock, null, 2)}\n`);

console.log(chalk.blue('Wrote lockfile'), chalk.cyan(filePath));
}

function parseOptions(args: string[]): SchemaCommandOptions {
let exportName = 'default';
let save = true;
let addToConfig = false;
let generateBindings = false;
let lock = false;

for (let i = 0; i < args.length; i++) {
const arg = args[i];

if (arg === '--export' || arg === '-e') {
exportName = args[i + 1];
i++;
continue;
}

if (arg === '--local') {
save = false;
continue;
}

if (arg === '--add-to-config') {
addToConfig = true;
continue;
}

if (arg === '--generate') {
generateBindings = true;
continue;
}

if (arg === '--lock') {
lock = true;
continue;
}

throw new Error(`Unknown schema command option: ${arg}`);
}

if (!exportName) {
throw new Error('Missing value for --export');
}

return { exportName, save, addToConfig, generateBindings, lock };
}

async function loadSchema(
schemaPath: string,
exportName: string,
): Promise<SchemaInput> {
const absolutePath = path.resolve(process.cwd(), schemaPath);
const moduleUrl = pathToFileURL(absolutePath).href;
const module = (await import(moduleUrl)) as SchemaModule;
const value = module[exportName];

if (!isSchemaInput(value)) {
throw new Error(
`Export ${exportName} from ${schemaPath} is not a defineSchema() result or schema package.`,
);
}

return value;
}

function isSchemaInput(value: unknown): value is SchemaInput {
if (typeof value !== 'object' || value === null) {
return false;
}

if ('schemaHash' in value && 'normalized' in value) {
return true;
}

return 'name' in value && 'classes' in value;
}

function printRegisteredSchema(
registered: RegisteredSchema,
saved: boolean,
): void {
console.log(chalk.green(saved ? 'Published schema' : 'Registered schema'));
console.log(`${chalk.blue('Ontology:')} ${registered.ontology.subject}`);
console.log(`${chalk.blue('Hash:')} ${registered.model.ontology.schemaHash}`);
console.log(
`${chalk.blue('Classes:')} ${Object.keys(registered.classes).length}`,
);
console.log(
`${chalk.blue('Properties:')} ${Object.keys(registered.properties).length}`,
);
}

async function addOntologyToConfig(subject: string): Promise<void> {
const configPath = path.resolve(process.cwd(), './atomic.config.json');
const raw = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as { ontologies?: string[] };
const ontologies = config.ontologies ?? [];

if (!ontologies.includes(subject)) {
config.ontologies = [...ontologies, subject];
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
}

console.log(`${chalk.blue('Config:')} added ${subject}`);
}

async function writeOntologyBindings(
registered: RegisteredSchema,
store: Awaited<ReturnType<typeof createConfiguredStore>>,
): Promise<void> {
const propertyRecord = new PropertyRecord();
const ontology = await generateOntology(
registered.ontology.subject,
propertyRecord,
store,
);

await writeGeneratedFile(ontology);

const missingProps = propertyRecord.getMissingProperties();

if (missingProps.length > 0) {
const externalsContent = await generateExternals(missingProps, store);
await writeGeneratedFile({
filename: 'externals.ts',
content: externalsContent,
});
}

await writeGeneratedFile(
generateIndex(
[registered.ontology.subject],
missingProps.length > 0,
store,
),
);
}

async function writeGeneratedFile({
filename,
content,
}: {
filename: string;
content: string;
}): Promise<void> {
const outputFolder = path.resolve(process.cwd(), atomicConfig.outputFolder);
const filePath = path.join(outputFolder, filename);
await fs.mkdir(outputFolder, { recursive: true });

let formatted = content;
const prettierConfig = await prettierResolveConfig(filePath);

if (prettierConfig) {
formatted = await prettierFormat(content, {
...prettierConfig,
parser: 'typescript',
});
}

await fs.writeFile(filePath, formatted);
console.log(chalk.blue('Wrote to'), chalk.cyan(filePath));
}
2 changes: 2 additions & 0 deletions browser/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface AtomicConfig {
* If left empty the public agent is used.
*/
agentSecret?: string;
/** Server used for relative subjects and publishing code-first schemas. */
serverUrl?: string;
/** The list of subjects of your ontologies */

ontologies: string[];
Expand Down
15 changes: 9 additions & 6 deletions browser/cli/src/generateBaseObject.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Resource, type Core, core } from '@tomic/lib';
import { Resource, type Core, core, type Store } from '@tomic/lib';
import { store } from './store.js';
import { camelCaseify, dedupe } from './utils.js';
import chalk from 'chalk';
Expand All @@ -13,6 +13,7 @@ type BaseObject = {

export const generateBaseObject = async (
ontology: Resource<Core.Ontology>,
activeStore: Store = store,
): Promise<[string, ReverseMapping]> => {
if (ontology.error) {
throw ontology.error;
Expand All @@ -23,9 +24,9 @@ export const generateBaseObject = async (
const name = camelCaseify(ontology.props.shortname);

const baseObj = {
classes: await listToObj(classes, 'classes'),
properties: await listToObj(properties, 'properties'),
__classDefs: await createClassDefs(classes),
classes: await listToObj(classes, 'classes', activeStore),
properties: await listToObj(properties, 'properties', activeStore),
__classDefs: await createClassDefs(classes, activeStore),
};

const objStr = `export const ${name} = {
Expand All @@ -40,10 +41,11 @@ export const generateBaseObject = async (
const listToObj = async (
list: string[],
type: string,
activeStore: Store,
): Promise<Record<string, string>> => {
const entries = await Promise.all(
list.map(async subject => {
const resource = await store.getResource(subject);
const resource = await activeStore.getResource(subject);

return [camelCaseify(resource.get(core.properties.shortname)), subject];
}),
Expand Down Expand Up @@ -76,9 +78,10 @@ const listToObj = async (

const createClassDefs = async (
classes: string[],
activeStore: Store,
): Promise<Record<string, string[]>> => {
const classResources = await Promise.all(
classes.map(async c => await store.getResource(c)),
classes.map(async c => await activeStore.getResource(c)),
);

const entries = classResources.map(resource => {
Expand Down
Loading
Loading