diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e2183c2..2f3ecf4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -255,7 +255,14 @@ function normalizeStudioFormState(input: any): ThsSchema { : [], index: Array.isArray(indexes.index) ? indexes.index.map((idx: any) => ({ - field: String(idx?.field ?? '') + field: String(idx?.field ?? ''), + mode: + idx?.mode === 'tokenized' + ? 'tokenized' + : idx?.mode === 'equality' + ? 'equality' + : undefined, + tokenizer: idx?.tokenizer === 'hashtag' ? 'hashtag' : undefined })) : [] }, diff --git a/packages/schema/schemas/tokenhost-ths.schema.json b/packages/schema/schemas/tokenhost-ths.schema.json index 9389e58..a453cc8 100644 --- a/packages/schema/schemas/tokenhost-ths.schema.json +++ b/packages/schema/schemas/tokenhost-ths.schema.json @@ -267,8 +267,33 @@ "additionalProperties": false, "required": ["field"], "properties": { - "field": { "type": "string" } - } + "field": { "type": "string" }, + "mode": { "type": "string", "enum": ["equality", "tokenized"] }, + "tokenizer": { "type": "string", "enum": ["hashtag"] } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "tokenized" } + }, + "required": ["mode"] + }, + "then": { + "required": ["tokenizer"] + } + }, + { + "if": { + "required": ["tokenizer"] + }, + "then": { + "properties": { + "mode": { "const": "tokenized" } + } + } + } + ] }, "relation": { "type": "object", diff --git a/packages/schema/src/lint.ts b/packages/schema/src/lint.ts index 4c94bc2..c9e09d2 100644 --- a/packages/schema/src/lint.ts +++ b/packages/schema/src/lint.ts @@ -53,6 +53,10 @@ function fieldMap(collection: Collection): Map { return map; } +function queryIndexMode(index: { mode?: string }): 'equality' | 'tokenized' { + return index.mode === 'tokenized' ? 'tokenized' : 'equality'; +} + function isSafeAutoExpr(expr: string): boolean { if (expr === 'block.timestamp') return true; if (expr === '_msgSender()') return true; @@ -151,12 +155,65 @@ export function lintThs(schema: ThsSchema): Issue[] { issues.push(err(`${cPath}/indexes/unique/${k}/field`, 'lint.indexes.unique_unknown', `Unique index references unknown field "${index.field}".`)); } } + const seenQueryIndexFields = new Set(); for (const [k, index] of c.indexes.index.entries()) { - if (!fieldsByName.has(index.field)) { + const f = fieldsByName.get(index.field); + if (!f) { issues.push(err(`${cPath}/indexes/index/${k}/field`, 'lint.indexes.index_unknown', `Query index references unknown field "${index.field}".`)); + continue; + } + if (seenQueryIndexFields.has(index.field)) { + issues.push( + err( + `${cPath}/indexes/index/${k}/field`, + 'lint.indexes.index_duplicate_field', + `Duplicate query index for field "${index.field}" is not supported.` + ) + ); + } + seenQueryIndexFields.add(index.field); + + const mode = queryIndexMode(index); + if (mode === 'tokenized') { + if (f.type !== 'string') { + issues.push( + err( + `${cPath}/indexes/index/${k}/field`, + 'lint.indexes.index_tokenized_unsupported_type', + `Tokenized query index on field "${index.field}" requires type "string"; got "${f.type}".` + ) + ); + } + if (index.tokenizer !== 'hashtag') { + issues.push( + err( + `${cPath}/indexes/index/${k}/tokenizer`, + 'lint.indexes.index_tokenized_missing_tokenizer', + 'Tokenized query indexes currently require tokenizer="hashtag".' + ) + ); + } + } else if (index.tokenizer !== undefined) { + issues.push( + err( + `${cPath}/indexes/index/${k}/tokenizer`, + 'lint.indexes.index_tokenizer_without_tokenized_mode', + 'query index tokenizer is only valid when mode="tokenized".' + ) + ); } } + if ((schema.app.features?.onChainIndexing ?? true) === false && c.indexes.index.length > 0) { + issues.push( + warn( + `${cPath}/indexes/index`, + 'lint.indexes.index_ignored_when_onchain_disabled', + 'app.features.onChainIndexing is false; query indexes will be omitted from generated contracts and on-chain browsing will require fallback behavior.' + ) + ); + } + // relations const relationFields = new Set(); if (c.relations) { diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index ef901d4..11fd29c 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -97,8 +97,13 @@ export interface UniqueIndex { scope?: 'active' | 'allTime'; } +export type QueryIndexMode = 'equality' | 'tokenized'; +export type QueryIndexTokenizer = 'hashtag'; + export interface QueryIndex { field: string; + mode?: QueryIndexMode; + tokenizer?: QueryIndexTokenizer; } export interface Indexes { diff --git a/schemas/tokenhost-ths.schema.json b/schemas/tokenhost-ths.schema.json index f8c6542..5a7885d 100644 --- a/schemas/tokenhost-ths.schema.json +++ b/schemas/tokenhost-ths.schema.json @@ -228,8 +228,33 @@ "additionalProperties": false, "required": ["field"], "properties": { - "field": { "type": "string" } - } + "field": { "type": "string" }, + "mode": { "type": "string", "enum": ["equality", "tokenized"] }, + "tokenizer": { "type": "string", "enum": ["hashtag"] } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "tokenized" } + }, + "required": ["mode"] + }, + "then": { + "required": ["tokenizer"] + } + }, + { + "if": { + "required": ["tokenizer"] + }, + "then": { + "properties": { + "mode": { "const": "tokenized" } + } + } + } + ] }, "relation": { "type": "object", diff --git a/test/testThsSchema.js b/test/testThsSchema.js index af27c91..0cee69c 100644 --- a/test/testThsSchema.js +++ b/test/testThsSchema.js @@ -150,4 +150,106 @@ describe('THS schema validation + lint', function () { const issues = lintThs(res.data); expect(issues.some((i) => i.code === 'lint.app.ui.custom_home_without_extensions')).to.equal(true); }); + + it('validateThsStructural accepts tokenized query index primitives', function () { + const input = minimalSchema({ + collections: [ + { + name: 'Post', + fields: [{ name: 'body', type: 'string', required: true }], + createRules: { required: ['body'], access: 'public' }, + visibilityRules: { gets: ['body'], access: 'public' }, + updateRules: { mutable: ['body'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { + unique: [], + index: [{ field: 'body', mode: 'tokenized', tokenizer: 'hashtag' }] + } + } + ] + }); + + const res = validateThsStructural(input); + expect(res.ok).to.equal(true); + }); + + it('lintThs rejects tokenized query indexes on unsupported field types', function () { + const input = minimalSchema({ + collections: [ + { + name: 'Post', + fields: [{ name: 'imageRef', type: 'image', required: true }], + createRules: { required: ['imageRef'], access: 'public' }, + visibilityRules: { gets: ['imageRef'], access: 'public' }, + updateRules: { mutable: ['imageRef'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { + unique: [], + index: [{ field: 'imageRef', mode: 'tokenized', tokenizer: 'hashtag' }] + } + } + ] + }); + + const res = validateThsStructural(input); + expect(res.ok).to.equal(true); + const issues = lintThs(res.data); + expect(issues.some((i) => i.code === 'lint.indexes.index_tokenized_unsupported_type')).to.equal(true); + }); + + it('lintThs rejects duplicate query indexes on the same field', function () { + const input = minimalSchema({ + collections: [ + { + name: 'Post', + fields: [{ name: 'body', type: 'string', required: true }], + createRules: { required: ['body'], access: 'public' }, + visibilityRules: { gets: ['body'], access: 'public' }, + updateRules: { mutable: ['body'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { + unique: [], + index: [ + { field: 'body' }, + { field: 'body', mode: 'tokenized', tokenizer: 'hashtag' } + ] + } + } + ] + }); + + const res = validateThsStructural(input); + expect(res.ok).to.equal(true); + const issues = lintThs(res.data); + expect(issues.some((i) => i.code === 'lint.indexes.index_duplicate_field')).to.equal(true); + }); + + it('lintThs warns when query indexes are declared but on-chain indexing is disabled', function () { + const input = minimalSchema({ + app: { + name: 'Test App', + slug: 'test-app', + features: { uploads: false, onChainIndexing: false } + }, + collections: [ + { + name: 'Post', + fields: [{ name: 'body', type: 'string', required: true }], + createRules: { required: ['body'], access: 'public' }, + visibilityRules: { gets: ['body'], access: 'public' }, + updateRules: { mutable: ['body'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { + unique: [], + index: [{ field: 'body', mode: 'tokenized', tokenizer: 'hashtag' }] + } + } + ] + }); + + const res = validateThsStructural(input); + expect(res.ok).to.equal(true); + const issues = lintThs(res.data); + expect(issues.some((i) => i.code === 'lint.indexes.index_ignored_when_onchain_disabled')).to.equal(true); + }); });