Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}))
: []
},
Expand Down
29 changes: 27 additions & 2 deletions packages/schema/schemas/tokenhost-ths.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 58 additions & 1 deletion packages/schema/src/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function fieldMap(collection: Collection): Map<string, ThsField> {
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;
Expand Down Expand Up @@ -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<string>();
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<string>();
if (c.relations) {
Expand Down
5 changes: 5 additions & 0 deletions packages/schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 27 additions & 2 deletions schemas/tokenhost-ths.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions test/testThsSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading