1- import type { Access , Collection , FieldType , Relation , ThsField , ThsSchema , UniqueIndex } from '@tokenhost/schema' ;
1+ import type { Access , Collection , FieldType , QueryIndex , Relation , ThsField , ThsSchema , UniqueIndex } from '@tokenhost/schema' ;
22import { computeSchemaHash } from '@tokenhost/schema' ;
33
44type SolidityType = 'string' | 'uint256' | 'int256' | 'bool' | 'address' | 'bytes32' ;
@@ -49,6 +49,16 @@ function uniqueScope(index: UniqueIndex): 'active' | 'allTime' {
4949 return index . scope ?? 'active' ;
5050}
5151
52+ function queryIndexMode ( index : QueryIndex ) : 'equality' | 'tokenized' {
53+ return index . mode === 'tokenized' ? 'tokenized' : 'equality' ;
54+ }
55+
56+ function queryIndexKeyExpr ( fieldType : FieldType , expr : string ) : string {
57+ return solidityStorageType ( fieldType ) === 'string'
58+ ? `keccak256(bytes(${ expr } ))`
59+ : `keccak256(abi.encode(${ expr } ))` ;
60+ }
61+
5262class W {
5363 private lines : string [ ] = [ ] ;
5464 private indent = 0 ;
@@ -140,6 +150,8 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
140150 w . line ( 'uint256 public constant MAX_LIST_LIMIT = 50;' ) ;
141151 w . line ( 'uint256 public constant MAX_SCAN_STEPS = 1000;' ) ;
142152 w . line ( 'uint256 public constant MAX_MULTICALL_CALLS = 20;' ) ;
153+ w . line ( 'uint256 public constant MAX_TOKENIZED_INDEX_TOKENS = 8;' ) ;
154+ w . line ( 'uint256 public constant MAX_TOKENIZED_INDEX_TOKEN_LENGTH = 32;' ) ;
143155 w . line ( ) ;
144156
145157 // Errors
@@ -152,6 +164,8 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
152164 w . line ( 'error TransferDisabled();' ) ;
153165 w . line ( 'error InvalidRecipient();' ) ;
154166 w . line ( 'error VersionMismatch(uint256 expected, uint256 got);' ) ;
167+ w . line ( 'error TooManyIndexTokens();' ) ;
168+ w . line ( 'error IndexTokenTooLong();' ) ;
155169 w . line ( ) ;
156170
157171 // Events (SPEC 7.9)
@@ -228,6 +242,61 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
228242 } ) ;
229243 w . line ( ) ;
230244
245+ w . block ( 'function _isHashtagChar(bytes1 ch) internal pure returns (bool)' , ( ) => {
246+ w . line ( 'return (ch >= 0x30 && ch <= 0x39) || (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) || ch == 0x5F;' ) ;
247+ } ) ;
248+ w . line ( ) ;
249+
250+ w . block ( 'function _toLowerAscii(bytes1 ch) internal pure returns (bytes1)' , ( ) => {
251+ w . line ( 'if (ch >= 0x41 && ch <= 0x5A) {' ) ;
252+ w . line ( ' return bytes1(uint8(ch) + 32);' ) ;
253+ w . line ( '}' ) ;
254+ w . line ( 'return ch;' ) ;
255+ } ) ;
256+ w . line ( ) ;
257+
258+ w . block ( 'function _extractHashtagKeys(string memory value) internal pure returns (bytes32[] memory keys, uint256 count)' , ( ) => {
259+ w . line ( 'bytes memory raw = bytes(value);' ) ;
260+ w . line ( 'keys = new bytes32[](MAX_TOKENIZED_INDEX_TOKENS);' ) ;
261+ w . line ( 'uint256 i = 0;' ) ;
262+ w . block ( 'while (i < raw.length)' , ( ) => {
263+ w . line ( 'if (raw[i] != 0x23) {' ) ;
264+ w . line ( ' i += 1;' ) ;
265+ w . line ( ' continue;' ) ;
266+ w . line ( '}' ) ;
267+ w . line ( 'uint256 start = i + 1;' ) ;
268+ w . line ( 'uint256 end = start;' ) ;
269+ w . block ( 'while (end < raw.length && _isHashtagChar(raw[end]))' , ( ) => {
270+ w . line ( 'end += 1;' ) ;
271+ } ) ;
272+ w . line ( 'uint256 tokenLength = end - start;' ) ;
273+ w . line ( 'i = end;' ) ;
274+ w . line ( 'if (tokenLength == 0) {' ) ;
275+ w . line ( ' continue;' ) ;
276+ w . line ( '}' ) ;
277+ w . line ( 'if (tokenLength > MAX_TOKENIZED_INDEX_TOKEN_LENGTH) revert IndexTokenTooLong();' ) ;
278+ w . line ( 'bytes memory token = new bytes(tokenLength);' ) ;
279+ w . block ( 'for (uint256 j = 0; j < tokenLength; j++)' , ( ) => {
280+ w . line ( 'token[j] = _toLowerAscii(raw[start + j]);' ) ;
281+ } ) ;
282+ w . line ( 'bytes32 key = keccak256(token);' ) ;
283+ w . line ( 'bool seen = false;' ) ;
284+ w . block ( 'for (uint256 j = 0; j < count; j++)' , ( ) => {
285+ w . line ( 'if (keys[j] == key) {' ) ;
286+ w . line ( ' seen = true;' ) ;
287+ w . line ( ' break;' ) ;
288+ w . line ( '}' ) ;
289+ } ) ;
290+ w . line ( 'if (seen) {' ) ;
291+ w . line ( ' continue;' ) ;
292+ w . line ( '}' ) ;
293+ w . line ( 'if (count >= MAX_TOKENIZED_INDEX_TOKENS) revert TooManyIndexTokens();' ) ;
294+ w . line ( 'keys[count] = key;' ) ;
295+ w . line ( 'count += 1;' ) ;
296+ } ) ;
297+ } ) ;
298+ w . line ( ) ;
299+
231300 const relationIndexes = buildRelationIndexes ( schema ) ;
232301
233302 // ---- Per-collection storage + methods ----
@@ -312,6 +381,13 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
312381 }
313382 if ( c . indexes . unique . length > 0 ) w . line ( ) ;
314383
384+ if ( onChainIndexing ) {
385+ for ( const idx of c . indexes . index ) {
386+ w . line ( `mapping(bytes32 => uint256[]) private index_${ C } _${ idx . field } ;` ) ;
387+ }
388+ if ( c . indexes . index . length > 0 ) w . line ( ) ;
389+ }
390+
315391 // Reverse reference indexes (append-only)
316392 if ( onChainIndexing ) {
317393 const rels = relationIndexes . filter ( ( r ) => r . from . name === C && r . rel . reverseIndex ) ;
@@ -408,6 +484,143 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
408484 w . line ( ) ;
409485 }
410486 }
487+ if ( onChainIndexing ) {
488+ for ( const idx of c . indexes . index ) {
489+ w . line ( `mapping(bytes32 => uint256[]) private index_${ C } _${ idx . field } ;` ) ;
490+ }
491+ if ( c . indexes . index . length > 0 ) w . line ( ) ;
492+ }
493+
494+ // Reverse reference indexes (append-only)
495+ if ( onChainIndexing ) {
496+ const rels = relationIndexes . filter ( ( r ) => r . from . name === C && r . rel . reverseIndex ) ;
497+ for ( const r of rels ) {
498+ w . line ( `mapping(uint256 => uint256[]) private refIndex_${ C } _${ r . rel . field } ;` ) ;
499+ }
500+ if ( rels . length > 0 ) w . line ( ) ;
501+ }
502+
503+ // exists / getCount
504+ w . block ( `function exists${ C } (uint256 id) public view returns (bool)` , ( ) => {
505+ w . line ( `${ record } storage r = ${ cVar } Records[id];` ) ;
506+ w . line ( 'if (r.createdBy == address(0)) return false;' ) ;
507+ w . line ( 'if (r.isDeleted) return false;' ) ;
508+ w . line ( 'return true;' ) ;
509+ } ) ;
510+ w . line ( ) ;
511+
512+ w . block ( `function getCount${ C } (bool includeDeleted) external view returns (uint256)` , ( ) => {
513+ w . line ( 'if (includeDeleted) {' ) ;
514+ w . line ( ` return nextId${ C } - 1;` ) ;
515+ w . line ( '}' ) ;
516+ w . line ( `return activeCount${ C } ;` ) ;
517+ } ) ;
518+ w . line ( ) ;
519+
520+ // get
521+ w . block ( `function get${ C } (uint256 id, bool includeDeleted) public view returns (${ record } memory)` , ( ) => {
522+ w . line ( `${ record } storage r = ${ cVar } Records[id];` ) ;
523+ w . line ( 'if (r.createdBy == address(0)) revert RecordNotFound();' ) ;
524+ w . line ( 'if (!includeDeleted && r.isDeleted) revert RecordIsDeleted();' ) ;
525+ w . line ( 'return r;' ) ;
526+ } ) ;
527+ w . line ( ) ;
528+
529+ w . block ( `function get${ C } (uint256 id) external view returns (${ record } memory)` , ( ) => {
530+ w . line ( `return get${ C } (id, false);` ) ;
531+ } ) ;
532+ w . line ( ) ;
533+
534+ // listIds
535+ w . block (
536+ `function listIds${ C } (uint256 cursorIdExclusive, uint256 limit, bool includeDeleted) external view returns (uint256[] memory)` ,
537+ ( ) => {
538+ w . line ( 'if (limit > MAX_LIST_LIMIT) revert InvalidLimit();' ) ;
539+ w . line ( `uint256 cursor = cursorIdExclusive;` ) ;
540+ w . line ( `uint256 nextId = nextId${ C } ;` ) ;
541+ w . line ( 'if (cursor == 0 || cursor > nextId) {' ) ;
542+ w . line ( ' cursor = nextId;' ) ;
543+ w . line ( '}' ) ;
544+ w . line ( 'uint256[] memory tmp = new uint256[](limit);' ) ;
545+ w . line ( 'uint256 found = 0;' ) ;
546+ w . line ( 'uint256 steps = 0;' ) ;
547+ w . line ( 'uint256 id = cursor;' ) ;
548+ w . block ( 'while (id > 1 && found < limit && steps < MAX_SCAN_STEPS)' , ( ) => {
549+ w . line ( 'id--;' ) ;
550+ w . line ( 'steps++;' ) ;
551+ w . line ( `${ record } storage r = ${ cVar } Records[id];` ) ;
552+ w . line ( 'if (r.createdBy == address(0)) { continue; }' ) ;
553+ w . line ( 'if (!includeDeleted && r.isDeleted) { continue; }' ) ;
554+ w . line ( 'tmp[found] = id;' ) ;
555+ w . line ( 'found++;' ) ;
556+ } ) ;
557+ w . line ( 'uint256[] memory out = new uint256[](found);' ) ;
558+ w . block ( 'for (uint256 i = 0; i < found; i++)' , ( ) => {
559+ w . line ( 'out[i] = tmp[i];' ) ;
560+ } ) ;
561+ w . line ( 'return out;' ) ;
562+ }
563+ ) ;
564+ w . line ( ) ;
565+
566+ // Reverse reference accessor(s)
567+ if ( onChainIndexing && c . relations ) {
568+ for ( const rel of c . relations . filter ( ( r ) => r . reverseIndex ) ) {
569+ w . block (
570+ `function listByRef${ C } _${ rel . field } (uint256 refId, uint256 offset, uint256 limit) external view returns (uint256[] memory)` ,
571+ ( ) => {
572+ w . line ( 'if (limit > MAX_LIST_LIMIT) revert InvalidLimit();' ) ;
573+ w . line ( `uint256[] storage bucket = refIndex_${ C } _${ rel . field } [refId];` ) ;
574+ w . line ( 'if (offset >= bucket.length) {' ) ;
575+ w . line ( ' return new uint256[](0);' ) ;
576+ w . line ( '}' ) ;
577+ w . line ( 'uint256 end = offset + limit;' ) ;
578+ w . line ( 'if (end > bucket.length) end = bucket.length;' ) ;
579+ w . line ( 'uint256 outLen = end - offset;' ) ;
580+ w . line ( 'uint256[] memory out = new uint256[](outLen);' ) ;
581+ w . block ( 'for (uint256 i = 0; i < outLen; i++)' , ( ) => {
582+ w . line ( 'out[i] = bucket[offset + i];' ) ;
583+ } ) ;
584+ w . line ( 'return out;' ) ;
585+ }
586+ ) ;
587+ w . line ( ) ;
588+ }
589+ }
590+
591+ if ( onChainIndexing ) {
592+ for ( const idx of c . indexes . index ) {
593+ w . block (
594+ `function listByIndex${ C } _${ idx . field } (bytes32 key, uint256 offset, uint256 limit) external view returns (uint256[] memory)` ,
595+ ( ) => {
596+ w . line ( 'if (limit > MAX_LIST_LIMIT) revert InvalidLimit();' ) ;
597+ w . line ( `uint256[] storage bucket = index_${ C } _${ idx . field } [key];` ) ;
598+ w . line ( 'if (offset >= bucket.length) {' ) ;
599+ w . line ( ' return new uint256[](0);' ) ;
600+ w . line ( '}' ) ;
601+ w . line ( 'uint256 end = offset + limit;' ) ;
602+ w . line ( 'if (end > bucket.length) end = bucket.length;' ) ;
603+ w . line ( 'uint256 outLen = end - offset;' ) ;
604+ w . line ( 'uint256[] memory out = new uint256[](outLen);' ) ;
605+ w . block ( 'for (uint256 i = 0; i < outLen; i++)' , ( ) => {
606+ w . line ( 'out[i] = bucket[offset + i];' ) ;
607+ } ) ;
608+ w . line ( 'return out;' ) ;
609+ }
610+ ) ;
611+ w . line ( ) ;
612+
613+ if ( queryIndexMode ( idx ) === 'tokenized' ) {
614+ w . block ( `function _indexHashtag${ C } _${ idx . field } (string memory value, uint256 id) internal` , ( ) => {
615+ w . line ( '(bytes32[] memory keys, uint256 count) = _extractHashtagKeys(value);' ) ;
616+ w . block ( 'for (uint256 i = 0; i < count; i++)' , ( ) => {
617+ w . line ( `index_${ C } _${ idx . field } [keys[i]].push(id);` ) ;
618+ } ) ;
619+ } ) ;
620+ w . line ( ) ;
621+ }
622+ }
623+ }
411624
412625 // create
413626 const createFnName = `create${ C } ` ;
@@ -468,6 +681,18 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
468681 w . line ( `unique_${ C } _${ u . field } [key_${ u . field } ] = id;` ) ;
469682 }
470683
684+ if ( onChainIndexing ) {
685+ for ( const idx of c . indexes . index ) {
686+ const f = c . fields . find ( ( x ) => x . name === idx . field ) ;
687+ if ( ! f ) continue ;
688+ if ( queryIndexMode ( idx ) === 'tokenized' ) {
689+ w . line ( `_indexHashtag${ C } _${ idx . field } (input.${ idx . field } , id);` ) ;
690+ } else {
691+ w . line ( `index_${ C } _${ idx . field } [${ queryIndexKeyExpr ( f . type , `input.${ idx . field } ` ) } ].push(id);` ) ;
692+ }
693+ }
694+ }
695+
471696 // reverse index maintenance
472697 if ( onChainIndexing && c . relations ) {
473698 for ( const rel of c . relations . filter ( ( r ) => r . reverseIndex ) ) {
@@ -525,6 +750,31 @@ export function generateAppSolidity(schema: ThsSchema): { path: string; contents
525750 w . line ( '}' ) ;
526751 }
527752
753+ if ( onChainIndexing ) {
754+ const queryByField = new Map ( c . indexes . index . map ( ( idx ) => [ idx . field , idx ] ) ) ;
755+ for ( const field of c . updateRules . mutable ) {
756+ const idx = queryByField . get ( field ) ;
757+ if ( ! idx ) continue ;
758+ const f = c . fields . find ( ( x ) => x . name === field ) ;
759+ if ( ! f ) continue ;
760+ if ( queryIndexMode ( idx ) === 'tokenized' ) {
761+ w . line ( `bytes32 oldFieldHash_${ field } = keccak256(bytes(r.${ field } ));` ) ;
762+ w . line ( `bytes32 newFieldHash_${ field } = keccak256(bytes(${ field } ));` ) ;
763+ w . line ( `if (oldFieldHash_${ field } != newFieldHash_${ field } ) {` ) ;
764+ w . line ( ` _indexHashtag${ C } _${ field } (${ field } , id);` ) ;
765+ w . line ( '}' ) ;
766+ } else {
767+ const oldKey = queryIndexKeyExpr ( f . type , `r.${ field } ` ) ;
768+ const newKey = queryIndexKeyExpr ( f . type , field ) ;
769+ w . line ( `bytes32 oldIndexKey_${ field } = ${ oldKey } ;` ) ;
770+ w . line ( `bytes32 newIndexKey_${ field } = ${ newKey } ;` ) ;
771+ w . line ( `if (oldIndexKey_${ field } != newIndexKey_${ field } ) {` ) ;
772+ w . line ( ` index_${ C } _${ field } [newIndexKey_${ field } ].push(id);` ) ;
773+ w . line ( '}' ) ;
774+ }
775+ }
776+ }
777+
528778 for ( const field of c . updateRules . mutable ) {
529779 w . line ( `r.${ field } = ${ field } ;` ) ;
530780 }
0 commit comments