From 2a23aa3d21186c7095f8017fd765cc7f0ee580fe Mon Sep 17 00:00:00 2001 From: Cameron Tofer Date: Tue, 13 Jan 2026 12:29:08 -0700 Subject: [PATCH] fix enum key encoding for strings: true enums When an enum with `strings: true` was used as part of a collection key, inserting a record would crash with "Invalid number " because the codegen always generated IndexEncoder.UINT for enum key components, regardless of the strings flag. This meant string values like "pending" were passed to UINT.encode() which expects integers. Now checks keyType.strings and uses IndexEncoder.STRING when true. Co-Authored-By: Claude Opus 4.5 --- builder/codegen.js | 2 +- test/basic.js | 21 +++ test/fixtures/builders/8.js | 50 ++++++ test/fixtures/generate.js | 1 + test/fixtures/generated/8/hyperdb/db.json | 23 +++ test/fixtures/generated/8/hyperdb/index.js | 115 +++++++++++++ test/fixtures/generated/8/hyperdb/messages.js | 160 ++++++++++++++++++ .../fixtures/generated/8/hyperschema/index.js | 141 +++++++++++++++ .../generated/8/hyperschema/schema.json | 51 ++++++ 9 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/builders/8.js create mode 100644 test/fixtures/generated/8/hyperdb/db.json create mode 100644 test/fixtures/generated/8/hyperdb/index.js create mode 100644 test/fixtures/generated/8/hyperdb/messages.js create mode 100644 test/fixtures/generated/8/hyperschema/index.js create mode 100644 test/fixtures/generated/8/hyperschema/schema.json diff --git a/builder/codegen.js b/builder/codegen.js index 71a1815..c98e456 100644 --- a/builder/codegen.js +++ b/builder/codegen.js @@ -437,7 +437,7 @@ function generateIndexKeyEncoding(type) { const component = type.keyEncoding[i] const keyType = type.builder.schema.types.get(`@${type.namespace}/${component}`) - if (keyType?.isEnum) str += ' IndexEncoder.UINT' + if (keyType?.isEnum) str += keyType.strings ? ' IndexEncoder.STRING' : ' IndexEncoder.UINT' else str += ' ' + IndexTypeMap.get(component) if (i !== type.keyEncoding.length - 1) str += ',\n' diff --git a/test/basic.js b/test/basic.js index 2fa9373..006b3b3 100644 --- a/test/basic.js +++ b/test/basic.js @@ -607,3 +607,24 @@ test('enum as key type', async function ({ create, bee }, t) { await db.close() }) + +test('enum with strings: true as key type', async function ({ create }, t) { + const db = await create({ fixture: 8 }) + + await db.insert('@db/tasks', { project: 'alpha', status: 'pending', name: 'task1' }) + await db.insert('@db/tasks', { project: 'alpha', status: 'active', name: 'task2' }) + await db.insert('@db/tasks', { project: 'beta', status: 'completed', name: 'task3' }) + await db.flush() + + { + const task = await db.get('@db/tasks', { project: 'alpha', status: 'pending', name: 'task1' }) + t.alike(task, { project: 'alpha', status: 'pending', name: 'task1' }) + } + + { + const all = await db.find('@db/tasks').toArray() + t.is(all.length, 3) + } + + await db.close() +}) diff --git a/test/fixtures/builders/8.js b/test/fixtures/builders/8.js new file mode 100644 index 0000000..1f34056 --- /dev/null +++ b/test/fixtures/builders/8.js @@ -0,0 +1,50 @@ +const HyperDB = require('../../../builder') +const Hyperschema = require('hyperschema') +const path = require('path') + +const SCHEMA_DIR = path.join(__dirname, '../generated/8/hyperschema') +const DB_DIR = path.join(__dirname, '../generated/8/hyperdb') + +const schema = Hyperschema.from(SCHEMA_DIR) + +const dbSchema = schema.namespace('db') + +dbSchema.register({ + name: 'status', + enum: ['pending', 'active', 'completed'], + strings: true +}) + +dbSchema.register({ + name: 'task', + fields: [ + { + name: 'project', + type: 'string', + required: true + }, + { + name: 'status', + type: '@db/status', + required: true + }, + { + name: 'name', + type: 'string', + required: true + } + ] +}) + +Hyperschema.toDisk(schema) + +const db = HyperDB.from(SCHEMA_DIR, DB_DIR) +const testDb = db.namespace('db') + +testDb.collections.register({ + name: 'tasks', + schema: '@db/task', + key: ['project', 'status', 'name'] +}) + +HyperDB.toDisk(db) diff --git a/test/fixtures/generate.js b/test/fixtures/generate.js index fece4cd..50006a6 100644 --- a/test/fixtures/generate.js +++ b/test/fixtures/generate.js @@ -5,3 +5,4 @@ require('./builders/4.js') require('./builders/5.js') require('./builders/6.js') require('./builders/7.js') +require('./builders/8.js') diff --git a/test/fixtures/generated/8/hyperdb/db.json b/test/fixtures/generated/8/hyperdb/db.json new file mode 100644 index 0000000..7a9e047 --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/db.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "offset": 0, + "schema": [ + { + "name": "tasks", + "namespace": "db", + "id": 0, + "type": 1, + "version": 1, + "versionField": null, + "indexes": [], + "schema": "@db/task", + "derived": false, + "key": [ + "project", + "status", + "name" + ], + "trigger": null + } + ] +} \ No newline at end of file diff --git a/test/fixtures/generated/8/hyperdb/index.js b/test/fixtures/generated/8/hyperdb/index.js new file mode 100644 index 0000000..014dc4e --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/index.js @@ -0,0 +1,115 @@ +// This file is autogenerated by the hyperdb compiler +/* eslint-disable camelcase */ + +const { IndexEncoder, c, b4a } = require('hyperdb/runtime') +const { version, getEncoding, setVersion } = require('./messages.js') + +const versions = { schema: version, db: 1 } + +// '@db/tasks' collection key +const collection0_key = new IndexEncoder([ + IndexEncoder.STRING, + IndexEncoder.STRING, + IndexEncoder.STRING +], { prefix: 0 }) + +function collection0_indexify (record) { + const arr = [] + + const a0 = record.project + if (a0 === undefined) return arr + arr.push(a0) + + const a1 = record.status + if (a1 === undefined) return arr + arr.push(a1) + + const a2 = record.name + if (a2 === undefined) return arr + arr.push(a2) + + return arr +} + +// '@db/tasks' value encoding +const collection0_enc = getEncoding('@db/task/hyperdb#0') + +// '@db/tasks' reconstruction function +function collection0_reconstruct (schemaVersion, keyBuf, valueBuf) { + const key = collection0_key.decode(keyBuf) + setVersion(schemaVersion) + const state = { start: 0, end: valueBuf.byteLength, buffer: valueBuf } + const type = c.uint.decode(state) + if (type !== 0) throw new Error('Unknown collection type: ' + type) + collection0.decodedVersion = c.uint.decode(state) + const record = collection0_enc.decode(state) + record.project = key[0] + record.status = key[1] + record.name = key[2] + return record +} +// '@db/tasks' key reconstruction function +function collection0_reconstruct_key (keyBuf) { + const key = collection0_key.decode(keyBuf) + return { + project: key[0], + status: key[1], + name: key[2] + } +} + +// '@db/tasks' +const collection0 = { + name: '@db/tasks', + id: 0, + version: 1, + encodeKey (record) { + const key = [record.project, record.status, record.name] + return collection0_key.encode(key) + }, + encodeKeyRange ({ gt, lt, gte, lte } = {}) { + return collection0_key.encodeRange({ + gt: gt ? collection0_indexify(gt) : null, + lt: lt ? collection0_indexify(lt) : null, + gte: gte ? collection0_indexify(gte) : null, + lte: lte ? collection0_indexify(lte) : null + }) + }, + encodeValue (schemaVersion, collectionVersion, record) { + setVersion(schemaVersion) + const state = { start: 0, end: 2, buffer: null } + collection0_enc.preencode(state, record) + state.buffer = b4a.allocUnsafe(state.end) + state.buffer[state.start++] = 0 + state.buffer[state.start++] = collectionVersion + collection0_enc.encode(state, record) + return state.buffer + }, + trigger: null, + reconstruct: collection0_reconstruct, + reconstructKey: collection0_reconstruct_key, + indexes: [], + decodedVersion: 0 +} + +const collections = [ + collection0 +] + +const indexes = [ +] + +module.exports = { versions, collections, indexes, resolveCollection, resolveIndex } + +function resolveCollection (name) { + switch (name) { + case '@db/tasks': return collection0 + default: return null + } +} + +function resolveIndex (name) { + switch (name) { + default: return null + } +} diff --git a/test/fixtures/generated/8/hyperdb/messages.js b/test/fixtures/generated/8/hyperdb/messages.js new file mode 100644 index 0000000..f70d7a0 --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/messages.js @@ -0,0 +1,160 @@ +// This file is autogenerated by the hyperschema compiler +// Schema Version: 1 +/* eslint-disable camelcase */ +/* eslint-disable quotes */ +/* eslint-disable space-before-function-paren */ + +const { c } = require('hyperschema/runtime') + +const VERSION = 1 + +// eslint-disable-next-line no-unused-vars +let version = VERSION + +const encoding0_enum = { + pending: 'pending', + active: 'active', + completed: 'completed' +} + +// @db/status enum +const encoding0 = { + preencode (state, m) { + state.end++ // max enum is 3 so always one byte + }, + encode (state, m) { + switch (m) { + case 'pending': + c.uint.encode(state, 1) + break + case 'active': + c.uint.encode(state, 2) + break + case 'completed': + c.uint.encode(state, 3) + break + default: + throw new Error('Unknown enum') + } + }, + decode (state) { + switch (c.uint.decode(state)) { + case 1: + return 'pending' + case 2: + return 'active' + case 3: + return 'completed' + default: return null + } + } +} + +// @db/task +const encoding1 = { + preencode(state, m) { + c.string.preencode(state, m.project) + encoding0.preencode(state, m.status) + c.string.preencode(state, m.name) + }, + encode(state, m) { + c.string.encode(state, m.project) + encoding0.encode(state, m.status) + c.string.encode(state, m.name) + }, + decode(state) { + const r0 = c.string.decode(state) + const r1 = encoding0.decode(state) + const r2 = c.string.decode(state) + + return { + project: r0, + status: r1, + name: r2 + } + } +} + +// @db/task/hyperdb#0 +const encoding2 = { + preencode(state, m) { + + }, + encode(state, m) { + + }, + decode(state) { + return { + project: null, + status: null, + name: null + } + } +} + +function setVersion(v) { + version = v +} + +function encode(name, value, v = VERSION) { + version = v + return c.encode(getEncoding(name), value) +} + +function decode(name, buffer, v = VERSION) { + version = v + return c.decode(getEncoding(name), buffer) +} + +function getEnum(name) { + switch (name) { + case '@db/status': + return encoding0_enum + default: + throw new Error('Enum not found ' + name) + } +} + +function getEncoding(name) { + switch (name) { + case '@db/status': + return encoding0 + case '@db/task': + return encoding1 + case '@db/task/hyperdb#0': + return encoding2 + default: + throw new Error('Encoder not found ' + name) + } +} + +function getStruct(name, v = VERSION) { + const enc = getEncoding(name) + return { + preencode(state, m) { + version = v + enc.preencode(state, m) + }, + encode(state, m) { + version = v + enc.encode(state, m) + }, + decode(state) { + version = v + return enc.decode(state) + } + } +} + +const resolveStruct = getStruct // compat + +module.exports = { + resolveStruct, + getStruct, + getEnum, + getEncoding, + encode, + decode, + setVersion, + version +} diff --git a/test/fixtures/generated/8/hyperschema/index.js b/test/fixtures/generated/8/hyperschema/index.js new file mode 100644 index 0000000..6d93e05 --- /dev/null +++ b/test/fixtures/generated/8/hyperschema/index.js @@ -0,0 +1,141 @@ +// This file is autogenerated by the hyperschema compiler +// Schema Version: 1 +/* eslint-disable camelcase */ +/* eslint-disable quotes */ +/* eslint-disable space-before-function-paren */ + +const { c } = require('hyperschema/runtime') + +const VERSION = 1 + +// eslint-disable-next-line no-unused-vars +let version = VERSION + +const encoding0_enum = { + pending: 'pending', + active: 'active', + completed: 'completed' +} + +// @db/status enum +const encoding0 = { + preencode (state, m) { + state.end++ // max enum is 3 so always one byte + }, + encode (state, m) { + switch (m) { + case 'pending': + c.uint.encode(state, 1) + break + case 'active': + c.uint.encode(state, 2) + break + case 'completed': + c.uint.encode(state, 3) + break + default: + throw new Error('Unknown enum') + } + }, + decode (state) { + switch (c.uint.decode(state)) { + case 1: + return 'pending' + case 2: + return 'active' + case 3: + return 'completed' + default: return null + } + } +} + +// @db/task +const encoding1 = { + preencode(state, m) { + c.string.preencode(state, m.project) + encoding0.preencode(state, m.status) + c.string.preencode(state, m.name) + }, + encode(state, m) { + c.string.encode(state, m.project) + encoding0.encode(state, m.status) + c.string.encode(state, m.name) + }, + decode(state) { + const r0 = c.string.decode(state) + const r1 = encoding0.decode(state) + const r2 = c.string.decode(state) + + return { + project: r0, + status: r1, + name: r2 + } + } +} + +function setVersion(v) { + version = v +} + +function encode(name, value, v = VERSION) { + version = v + return c.encode(getEncoding(name), value) +} + +function decode(name, buffer, v = VERSION) { + version = v + return c.decode(getEncoding(name), buffer) +} + +function getEnum(name) { + switch (name) { + case '@db/status': + return encoding0_enum + default: + throw new Error('Enum not found ' + name) + } +} + +function getEncoding(name) { + switch (name) { + case '@db/status': + return encoding0 + case '@db/task': + return encoding1 + default: + throw new Error('Encoder not found ' + name) + } +} + +function getStruct(name, v = VERSION) { + const enc = getEncoding(name) + return { + preencode(state, m) { + version = v + enc.preencode(state, m) + }, + encode(state, m) { + version = v + enc.encode(state, m) + }, + decode(state) { + version = v + return enc.decode(state) + } + } +} + +const resolveStruct = getStruct // compat + +module.exports = { + resolveStruct, + getStruct, + getEnum, + getEncoding, + encode, + decode, + setVersion, + version +} diff --git a/test/fixtures/generated/8/hyperschema/schema.json b/test/fixtures/generated/8/hyperschema/schema.json new file mode 100644 index 0000000..28a0ad5 --- /dev/null +++ b/test/fixtures/generated/8/hyperschema/schema.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "schema": [ + { + "name": "status", + "namespace": "db", + "offset": 1, + "enum": [ + { + "key": "pending", + "version": 1 + }, + { + "key": "active", + "version": 1 + }, + { + "key": "completed", + "version": 1 + } + ], + "strings": true + }, + { + "name": "task", + "namespace": "db", + "compact": false, + "flagsPosition": -1, + "fields": [ + { + "name": "project", + "required": true, + "type": "string", + "version": 1 + }, + { + "name": "status", + "required": true, + "type": "@db/status", + "version": 1 + }, + { + "name": "name", + "required": true, + "type": "string", + "version": 1 + } + ] + } + ] +}