From acc441d8166e910e502f36d853afd5a6e17a29c5 Mon Sep 17 00:00:00 2001 From: Giorgi Shavtvalishvili Date: Tue, 21 Jul 2026 00:04:54 +0400 Subject: [PATCH 1/3] Support versioned types as collection schemas --- builder/index.js | 43 ++++++++-- test/helpers/helpers.js | 5 ++ test/versions.js | 172 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 5 deletions(-) diff --git a/builder/index.js b/builder/index.js index e6cece7..db8f0ef 100644 --- a/builder/index.js +++ b/builder/index.js @@ -44,6 +44,8 @@ class DBType { let current = schema for (let i = 0; i < components.length; i++) { + // key fields of a versioned schema resolve through its latest version + if (current.isVersioned) current = current.versions[current.versions.length - 1].type const field = current.fieldsByName.get(components[i]) if (!field) throw new Error('Could not resolve path: ' + path) current = field.type @@ -116,6 +118,31 @@ class Collection extends DBType { const fields = [] const type = '/hyperdb#' + this.id + // derive a value variant per version so stored values keep their version dispatch and maps + if (schema.isVersioned && !parents.has(schema)) { + parents.add(schema) + let external = false + const versions = [] + for (const v of schema.versions) { + const derived = this._deriveValueSchema( + v.type, + prefix, + primaryKeySet, + new Set([...parents]) + ) + if (derived.external) external = true + versions.push({ version: v.version, type: derived.fqn, map: v.map }) + } + if (!external) return { external: false, fqn: getFQN(schema.namespace, schema.name) } + this.builder.schema.register({ + derived: true, + namespace: schema.namespace, + name: schema.name + type, + versions + }) + return { external: true, fqn: getFQN(schema.namespace, schema.name + type) } + } + if (!schema.isStruct || parents.has(schema)) return { external: false, fqn: schema.name } parents.add(schema) @@ -420,7 +447,9 @@ class Builder { const dbJsonPath = p.join(p.resolve(dbDir), DB_JSON_FILE_NAME) const codePath = p.join(p.resolve(dbDir), CODE_FILE_NAME) - fs.writeFileSync(messagesPath, hyperdb.schema.toCode({ esm }), { encoding: 'utf-8' }) + fs.writeFileSync(messagesPath, hyperdb.schema.toCode({ esm, filename: messagesPath }), { + encoding: 'utf-8' + }) fs.writeFileSync(dbJsonPath, JSON.stringify(hyperdb.toJSON(), null, 2), { encoding: 'utf-8' }) fs.writeFileSync(codePath, generateCode(hyperdb, { directory: dbDir, esm }), { encoding: 'utf-8' @@ -456,10 +485,14 @@ function getFQN(namespace, name) { function resolvePathToType(name, schema) { const parts = name.split('.') - let field = schema.fieldsByName.get(parts[0]) - - for (let i = 1; i < parts.length && field; i++) { - field = field.type.fieldsByName.get(parts[i]) + let field = null + let current = schema + for (let i = 0; i < parts.length; i++) { + // key fields of a versioned schema resolve through its latest version + if (current.isVersioned) current = current.versions[current.versions.length - 1].type + field = current.fieldsByName.get(parts[i]) + if (!field) return null + current = field.type } return field diff --git a/test/helpers/helpers.js b/test/helpers/helpers.js index 4ad3ea4..d537271 100644 --- a/test/helpers/helpers.js +++ b/test/helpers/helpers.js @@ -25,3 +25,8 @@ exports.mapMiddleAge = (record, context) => { if (record.age > 40 && record.age < 60) return [record.age] return [] } + +exports.thingV1ToV2 = (record) => { + const { title, ...rest } = record + return { ...rest, version: 2, name: title } +} diff --git a/test/versions.js b/test/versions.js index b087184..3fcf77b 100644 --- a/test/versions.js +++ b/test/versions.js @@ -35,6 +35,36 @@ test.bee('define versionField on collection', async function ({ build }, t) { await dbVersions.close() }) +test.bee('versioned collection schema maps old rows on read', async function ({ build }, t) { + const dir = await tmp(t, { dir: path.join(__dirname, 'fixtures/tmp') }) + + const db = await build(createVersionedDB, { dir }) + await db.insert('@example/things', { version: 1, id: 'a', title: 'hello' }) + await db.flush() + await db.close() + + // build() re-requires the generated index.js but not messages.js - refresh it by hand + delete require.cache[require.resolve(path.join(dir, 'hyperdb/messages.js'))] + + const db2 = await build(createVersionedDBWithV2, { dir }) + const row = await db2.get('@example/things', { id: 'a' }) + await db2.close() + + t.alike(row, { version: 2, id: 'a', name: 'hello' }) +}) + +test.bee('key path through a nested versioned field resolves', async function ({ build }, t) { + const dir = await tmp(t, { dir: path.join(__dirname, 'fixtures/tmp') }) + + const db = await build(createNestedVersionedDB, { dir }) + await db.insert('@example/wrappers', { thing: { version: 1, id: 'a', title: 'hello' } }) + await db.flush() + const row = await db.get('@example/wrappers', { thing: { id: 'a' } }) + await db.close() + + t.alike(row, { thing: { version: 1, id: 'a', title: 'hello' } }) +}) + function createExampleDB(HyperDB, Hyperschema, paths) { const schema = Hyperschema.from(paths.schema) const example = schema.namespace('example') @@ -141,3 +171,145 @@ function createExampleDBWithVersions(HyperDB, Hyperschema, paths) { HyperDB.toDisk(db) } + +function registerThingV1(example) { + example.register({ + name: 'thing-v1', + fields: [ + { + name: 'id', + type: 'string', + required: true + }, + { + name: 'title', + type: 'string', + required: true + } + ] + }) +} + +function createVersionedDB(HyperDB, Hyperschema, paths) { + const schema = Hyperschema.from(paths.schema) + const example = schema.namespace('example') + + registerThingV1(example) + + example.register({ + name: 'thing', + versions: [ + { + version: 1, + type: '@example/thing-v1' + } + ] + }) + + Hyperschema.toDisk(schema) + + const db = HyperDB.from(paths.schema, paths.db) + const exampleDB = db.namespace('example') + + exampleDB.collections.register({ + name: 'things', + schema: '@example/thing', + key: ['id'] + }) + + HyperDB.toDisk(db) +} + +function createVersionedDBWithV2(HyperDB, Hyperschema, paths) { + const schema = Hyperschema.from(paths.schema) + const example = schema.namespace('example') + + example.require(paths.helpers) + + registerThingV1(example) + + example.register({ + name: 'thing-v2', + fields: [ + { + name: 'id', + type: 'string', + required: true + }, + { + name: 'name', + type: 'string', + required: true + } + ] + }) + + example.register({ + name: 'thing', + versions: [ + { + version: 1, + type: '@example/thing-v1', + map: 'thingV1ToV2' + }, + { + version: 2, + type: '@example/thing-v2' + } + ] + }) + + Hyperschema.toDisk(schema) + + const db = HyperDB.from(paths.schema, paths.db) + const exampleDB = db.namespace('example') + + exampleDB.collections.register({ + name: 'things', + schema: '@example/thing', + key: ['id'] + }) + + HyperDB.toDisk(db) +} + +function createNestedVersionedDB(HyperDB, Hyperschema, paths) { + const schema = Hyperschema.from(paths.schema) + const example = schema.namespace('example') + + registerThingV1(example) + + example.register({ + name: 'thing', + versions: [ + { + version: 1, + type: '@example/thing-v1' + } + ] + }) + + example.register({ + name: 'wrapper', + fields: [ + { + name: 'thing', + type: '@example/thing', + required: true + } + ] + }) + + Hyperschema.toDisk(schema) + + const db = HyperDB.from(paths.schema, paths.db) + const exampleDB = db.namespace('example') + + exampleDB.collections.register({ + name: 'wrappers', + schema: '@example/wrapper', + key: ['thing.id'] + }) + + HyperDB.toDisk(db) +} From 8211652753e02e101e3bdf437c60db3a123fd167 Mon Sep 17 00:00:00 2001 From: Sean Zellmer Date: Thu, 30 Jul 2026 11:56:13 -0500 Subject: [PATCH 2/3] Add test for error when resolving bad key in collection Covers why the `if (!field) return null` is necessary in `resolvePathToType()`. --- test/basic.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/basic.js b/test/basic.js index 6ffbbab..d911f22 100644 --- a/test/basic.js +++ b/test/basic.js @@ -513,6 +513,52 @@ test('nested keys', { bee2: false }, async function ({ create, bee }, t) { await db.close() }) +test('collection - register - missing nested keys', async function ({ build }, t) { + t.plan(1) + const db = await build(createDB) + await db.close() + + function createDB(HyperDB, Hyperschema, paths) { + const schema = Hyperschema.from(paths.schema) + const example = schema.namespace('db') + + example.register({ + name: 'bar', + fields: [ + { + name: 'baz', + type: 'string', + required: true + } + ] + }) + + example.register({ + name: 'foo', + fields: [ + { + name: 'bar', + type: '@db/bar', + required: true + } + ] + }) + + Hyperschema.toDisk(schema) + + const db = HyperDB.from(paths.schema, paths.db) + const exampleDB = db.namespace('db') + + t.exception(() => exampleDB.collections.register({ + name: 'nonexistents', + schema: '@db/foo', + key: ['bar.nonexistent'] + }), /Field not found: bar\.nonexistent/, 'Throws when given bad key') + + HyperDB.toDisk(db) + } +}) + test('undo mutation without deletion', async function ({ create }, t) { const db = await create() From 35c58f814b9fe6142902508146df01ecc41434d3 Mon Sep 17 00:00:00 2001 From: Sean Zellmer Date: Thu, 30 Jul 2026 14:07:02 -0500 Subject: [PATCH 3/3] Lint `test/basic.js` --- test/basic.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/basic.js b/test/basic.js index d911f22..a7b6e8c 100644 --- a/test/basic.js +++ b/test/basic.js @@ -549,11 +549,16 @@ test('collection - register - missing nested keys', async function ({ build }, t const db = HyperDB.from(paths.schema, paths.db) const exampleDB = db.namespace('db') - t.exception(() => exampleDB.collections.register({ - name: 'nonexistents', - schema: '@db/foo', - key: ['bar.nonexistent'] - }), /Field not found: bar\.nonexistent/, 'Throws when given bad key') + t.exception( + () => + exampleDB.collections.register({ + name: 'nonexistents', + schema: '@db/foo', + key: ['bar.nonexistent'] + }), + /Field not found: bar\.nonexistent/, + 'Throws when given bad key' + ) HyperDB.toDisk(db) }