Skip to content

Commit d24cc8d

Browse files
kriszypclaude
andcommitted
feat: make typed random-access structures configurable (config + @table directive)
main already gates struct-mode writes to primary DBIs (a2d0faf, for v4-downgrade compat). This makes the primary-DBI default itself controllable: - storage.randomAccessFields config (default off) — global default for primary stores - @table(randomAccessFields: true) directive — per-table override, persisted at creation (like sealed/compression) - OpenDBIObject: primary randomAccessStructure now follows the config (isPrimary && RANDOM_ACCESS_FIELDS); non-primary stays off (v4-downgrade compat unchanged) Typed structures key on per-field value WIDTH, so wide/variably-typed schemas mint an unbounded per-encoder dictionary (OOM) and diverge across replicas (decode failures); defaulting them off for primary stores is the conservative choice, with opt-in where safe. Also keep custom-index object stores (e.g. HNSW vector graphs) in struct mode regardless of the config — their internal node shapes (numeric-keyed per-level connection arrays and quantized bins) are fixed and rely on struct encoding, so the table-level default-off would corrupt the graph. Adds unit coverage for the config + directive wiring (databases.test.js, randomAccessFieldsDirective.test.js). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 25e3e7e commit d24cc8d

8 files changed

Lines changed: 116 additions & 1 deletion

File tree

config-root.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@
460460
"description": "Max write queue time before rejecting (e.g. '45s')."
461461
},
462462
"noReadAhead": { "type": "boolean", "description": "Advise OS to not read ahead. Default: false" },
463+
"randomAccessFields": {
464+
"type": "boolean",
465+
"description": "Encode records as typed random-access structures, optimizing for fast field access and smaller records. Best for tables with stable, homogeneous field types; wide or variably-typed schemas should leave this off. Can be overridden per-table with the @table(randomAccessFields:) schema directive. Default: false"
466+
},
463467
"prefetchWrites": { "type": "boolean", "description": "Load data prior to write transactions. Default: true" },
464468
"path": { "type": "string", "description": "Directory for all database files. Default: <rootPath>/database" },
465469
"blobPaths": {

resources/databases.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ function initStores(
586586
envGet(CONFIG_PARAMS.STORAGE_COMPRESSION_THRESHOLD) || DEFAULT_COMPRESSION_THRESHOLD; // this is the only thing that can change;
587587
dbiInit.compression.threshold = compressionThreshold;
588588
}
589+
// per-table override of the storage.randomAccessFields default (see OpenDBIObject)
590+
if (typeof primaryAttribute.randomAccessFields === 'boolean')
591+
dbiInit.randomAccessStructure = primaryAttribute.randomAccessFields;
589592
if (rootStore instanceof RocksDatabase) {
590593
primaryStore = handleLocalTimeForGets(
591594
openRocksDatabase(rootStore.path, { ...dbiInit, name: primaryAttribute.key } as any),
@@ -710,6 +713,7 @@ interface TableDefinition {
710713
sealed?: boolean;
711714
splitSegments?: boolean;
712715
replicate?: boolean;
716+
randomAccessFields?: boolean;
713717
trackDeletes?: boolean;
714718
attributes: any[];
715719
schemaDefined?: boolean;
@@ -878,6 +882,14 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
878882
const objectStorage =
879883
attribute.isPrimaryKey || (attribute.indexed.type && CUSTOM_INDEXES[attribute.indexed.type]?.useObjectStore);
880884
const dbiInit = createOpenDBIObject(!objectStorage, objectStorage);
885+
// Custom-index object stores (e.g. HNSW vector graphs) hold fixed-shape internal nodes —
886+
// numeric-keyed per-level connection arrays and quantized bins — that rely on random-access
887+
// struct encoding. Keep them in struct mode regardless of the table's storage.randomAccessFields
888+
// setting: their node shapes are controlled, so the wide/variably-typed OOM + divergence risks
889+
// that motivate the table-level default-off don't apply, and disabling structs corrupts the graph.
890+
if (attribute.indexed?.type && CUSTOM_INDEXES[attribute.indexed.type]?.useObjectStore) {
891+
dbiInit.randomAccessStructure = true;
892+
}
881893
let dbi:
882894
| LMDBDatabase
883895
| (RocksDatabase & {
@@ -929,6 +941,7 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
929941
sealed,
930942
splitSegments,
931943
replicate,
944+
randomAccessFields,
932945
trackDeletes,
933946
schemaDefined,
934947
origin,
@@ -982,13 +995,19 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
982995
primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
983996
if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
984997
if (typeof replicate === 'boolean') primaryKeyAttribute.replicate = replicate;
998+
// Like compression, the record encoding is fixed at creation: existing typed-struct data
999+
// still decodes either way, but we don't rewrite already-persisted records.
1000+
if (typeof randomAccessFields === 'boolean') primaryKeyAttribute.randomAccessFields = randomAccessFields;
9851001
if (origin) {
9861002
if (!primaryKeyAttribute.origins) primaryKeyAttribute.origins = [origin];
9871003
else if (!primaryKeyAttribute.origins.includes(origin)) primaryKeyAttribute.origins.push(origin);
9881004
}
9891005
logger.trace(`${tableName} table loading, opening primary store`);
9901006
const dbiInit = createOpenDBIObject(false, true);
9911007
dbiInit.compression = primaryKeyAttribute.compression;
1008+
// per-table override of the storage.randomAccessFields default (see OpenDBIObject)
1009+
if (typeof primaryKeyAttribute.randomAccessFields === 'boolean')
1010+
dbiInit.randomAccessStructure = primaryKeyAttribute.randomAccessFields;
9921011
const dbiName = tableName + '/';
9931012

9941013
if (rootStore instanceof RocksDatabase) {

resources/graphql.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ async function processGraphQLSchema(gqlContent, urlPath, filePath, resources) {
7777
if (typeDef.schema) typeDef.database = typeDef.schema;
7878
if (!typeDef.table) typeDef.table = typeName;
7979
if (typeDef.audit) typeDef.audit = typeDef.audit !== 'false';
80+
// Boolean directive args arrive as actual booleans; tolerate string forms too.
81+
if (typeDef.randomAccessFields !== undefined)
82+
typeDef.randomAccessFields = typeDef.randomAccessFields === true || typeDef.randomAccessFields === 'true';
8083
typeDef.attributes = typeDef.properties;
8184
tables.push(typeDef);
8285
}

schema.graphql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ directive @table(
113113
table definition.
114114
"""
115115
replicate: Boolean
116+
"""
117+
Encode this table's records as typed random-access structures, optimizing for fast field
118+
access and smaller records. Best for tables with stable, homogeneous field types; leave off
119+
for wide or variably-typed schemas. Overrides the storage.randomAccessFields config for this
120+
table. Applied when the table is created.
121+
"""
122+
randomAccessFields: Boolean
116123
) on OBJECT
117124

118125
"""

unitTests/resources/databases.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,33 @@ describe('flushDatabases', () => {
1919
await assert.doesNotReject(() => flushDatabases());
2020
});
2121
});
22+
23+
describe('table() randomAccessFields directive', () => {
24+
before(function () {
25+
setupTestDBPath();
26+
setMainIsWorker(true);
27+
});
28+
29+
it('defaults to classic structures (struct writes disabled) when the directive is absent', function () {
30+
const DefaultTable = table({
31+
table: 'RafDefault',
32+
database: 'test',
33+
attributes: [{ name: 'id', isPrimaryKey: true }],
34+
});
35+
const encoder = DefaultTable.primaryStore.encoder;
36+
assert.ok(!encoder.randomAccessStructure);
37+
assert.strictEqual(encoder._writeStruct.length, 0, 'expected the no-op write stub');
38+
});
39+
40+
it('enables typed random-access structures when @table(randomAccessFields: true)', function () {
41+
const RafTable = table({
42+
table: 'RafEnabled',
43+
database: 'test',
44+
randomAccessFields: true,
45+
attributes: [{ name: 'id', isPrimaryKey: true }],
46+
});
47+
const encoder = RafTable.primaryStore.encoder;
48+
assert.strictEqual(encoder.randomAccessStructure, true);
49+
assert.ok(encoder._writeStruct.length > 0, 'expected the real struct-write hook');
50+
});
51+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use strict';
2+
3+
const assert = require('node:assert/strict');
4+
const { setupTestDBPath } = require('../../testUtils');
5+
const { loadGQLSchema } = require('#src/resources/graphql');
6+
const { tables } = require('#src/resources/databases');
7+
8+
// Parse-level behavior of the @table(randomAccessFields:) directive: the boolean is coerced from the
9+
// GraphQL string value and flows through to the primary store's encoder, which keeps or stubs its
10+
// struct-write hook. storage.randomAccessFields defaults off, so an absent directive leaves writes off.
11+
describe('@table(randomAccessFields:) directive parsing', () => {
12+
before(() => setupTestDBPath());
13+
14+
it('enables typed random-access structures when randomAccessFields: true', async () => {
15+
await loadGQLSchema(`type RafOn @table(randomAccessFields: true) {
16+
id: ID @primaryKey
17+
name: String
18+
}`);
19+
const encoder = tables.RafOn.primaryStore.encoder;
20+
assert.equal(encoder.randomAccessStructure, true);
21+
assert.ok(encoder._writeStruct.length > 0, 'expected the real struct-write hook');
22+
});
23+
24+
it('keeps writes disabled when randomAccessFields: false', async () => {
25+
await loadGQLSchema(`type RafOff @table(randomAccessFields: false) {
26+
id: ID @primaryKey
27+
name: String
28+
}`);
29+
const encoder = tables.RafOff.primaryStore.encoder;
30+
assert.ok(!encoder.randomAccessStructure);
31+
assert.equal(encoder._writeStruct.length, 0, 'expected the no-op write stub');
32+
});
33+
34+
it('defaults to disabled when the directive is absent', async () => {
35+
await loadGQLSchema(`type RafAbsent @table {
36+
id: ID @primaryKey
37+
name: String
38+
}`);
39+
const encoder = tables.RafAbsent.primaryStore.encoder;
40+
assert.ok(!encoder.randomAccessStructure);
41+
assert.equal(encoder._writeStruct.length, 0, 'expected the no-op write stub');
42+
});
43+
});

utility/hdbTerms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ export const CONFIG_PARAMS = {
604604
STORAGE_CACHING: 'storage_caching',
605605
STORAGE_COMPRESSION: 'storage_compression',
606606
STORAGE_NOREADAHEAD: 'storage_noReadAhead',
607+
STORAGE_RANDOMACCESSFIELDS: 'storage_randomAccessFields',
607608
STORAGE_PREFETCHWRITES: 'storage_prefetchWrites',
608609
STORAGE_ENCRYPTION: 'storage_encryption',
609610
STORAGE_MAXTRANSACTIONQUEUETIME: 'storage_maxTransactionQueueTime',

utility/lmdb/OpenDBIObject.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { RecordEncoder } from '../../resources/RecordEncoder.ts';
55
envMngr.initSync();
66

77
const LMDB_CACHING = envMngr.get(terms.CONFIG_PARAMS.STORAGE_CACHING) !== false;
8+
// Typed random-access structures are opt-in (default off). They optimize for fast field access and
9+
// smaller records, but key structures on per-field value WIDTH, so a wide/variably-typed schema can
10+
// mint an unbounded dictionary. Enable globally with storage.randomAccessFields, or per-table with
11+
// the @table(randomAccessFields:) directive (which overrides this default).
12+
const RANDOM_ACCESS_FIELDS = envMngr.get(terms.CONFIG_PARAMS.STORAGE_RANDOMACCESSFIELDS) === true;
813

914
/**
1015
* Defines how a DBI will be created/opened
@@ -42,7 +47,10 @@ export class OpenDBIObject {
4247
// non-primary stores (e.g. the __dbis__ metadata DBI) stay in records mode and remain
4348
// decodable after a downgrade. RecordEncoder still reads struct data so existing v5
4449
// struct entries decode.
45-
this.randomAccessStructure = isPrimary;
50+
// Non-primary DBIs stay off (v4-downgrade compat — their metadata must be readable by a
51+
// struct-unaware decoder). Primary DBIs follow the storage.randomAccessFields default (off),
52+
// overridable per-table via databases.ts before the store opens.
53+
this.randomAccessStructure = isPrimary && RANDOM_ACCESS_FIELDS;
4654
if (isPrimary) {
4755
this.cache = LMDB_CACHING && { validated: true };
4856
this.freezeData = true;

0 commit comments

Comments
 (0)