Skip to content

Commit 60c961b

Browse files
committed
Issue 62 PR 02: THS query-index extensions
1 parent b9342e7 commit 60c961b

6 files changed

Lines changed: 227 additions & 6 deletions

File tree

packages/cli/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,14 @@ function normalizeStudioFormState(input: any): ThsSchema {
255255
: [],
256256
index: Array.isArray(indexes.index)
257257
? indexes.index.map((idx: any) => ({
258-
field: String(idx?.field ?? '')
258+
field: String(idx?.field ?? ''),
259+
mode:
260+
idx?.mode === 'tokenized'
261+
? 'tokenized'
262+
: idx?.mode === 'equality'
263+
? 'equality'
264+
: undefined,
265+
tokenizer: idx?.tokenizer === 'hashtag' ? 'hashtag' : undefined
259266
}))
260267
: []
261268
},

packages/schema/schemas/tokenhost-ths.schema.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,33 @@
267267
"additionalProperties": false,
268268
"required": ["field"],
269269
"properties": {
270-
"field": { "type": "string" }
271-
}
270+
"field": { "type": "string" },
271+
"mode": { "type": "string", "enum": ["equality", "tokenized"] },
272+
"tokenizer": { "type": "string", "enum": ["hashtag"] }
273+
},
274+
"allOf": [
275+
{
276+
"if": {
277+
"properties": {
278+
"mode": { "const": "tokenized" }
279+
},
280+
"required": ["mode"]
281+
},
282+
"then": {
283+
"required": ["tokenizer"]
284+
}
285+
},
286+
{
287+
"if": {
288+
"required": ["tokenizer"]
289+
},
290+
"then": {
291+
"properties": {
292+
"mode": { "const": "tokenized" }
293+
}
294+
}
295+
}
296+
]
272297
},
273298
"relation": {
274299
"type": "object",

packages/schema/src/lint.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ function fieldMap(collection: Collection): Map<string, ThsField> {
5353
return map;
5454
}
5555

56+
function queryIndexMode(index: { mode?: string }): 'equality' | 'tokenized' {
57+
return index.mode === 'tokenized' ? 'tokenized' : 'equality';
58+
}
59+
5660
function isSafeAutoExpr(expr: string): boolean {
5761
if (expr === 'block.timestamp') return true;
5862
if (expr === '_msgSender()') return true;
@@ -151,12 +155,65 @@ export function lintThs(schema: ThsSchema): Issue[] {
151155
issues.push(err(`${cPath}/indexes/unique/${k}/field`, 'lint.indexes.unique_unknown', `Unique index references unknown field "${index.field}".`));
152156
}
153157
}
158+
const seenQueryIndexFields = new Set<string>();
154159
for (const [k, index] of c.indexes.index.entries()) {
155-
if (!fieldsByName.has(index.field)) {
160+
const f = fieldsByName.get(index.field);
161+
if (!f) {
156162
issues.push(err(`${cPath}/indexes/index/${k}/field`, 'lint.indexes.index_unknown', `Query index references unknown field "${index.field}".`));
163+
continue;
164+
}
165+
if (seenQueryIndexFields.has(index.field)) {
166+
issues.push(
167+
err(
168+
`${cPath}/indexes/index/${k}/field`,
169+
'lint.indexes.index_duplicate_field',
170+
`Duplicate query index for field "${index.field}" is not supported.`
171+
)
172+
);
173+
}
174+
seenQueryIndexFields.add(index.field);
175+
176+
const mode = queryIndexMode(index);
177+
if (mode === 'tokenized') {
178+
if (f.type !== 'string') {
179+
issues.push(
180+
err(
181+
`${cPath}/indexes/index/${k}/field`,
182+
'lint.indexes.index_tokenized_unsupported_type',
183+
`Tokenized query index on field "${index.field}" requires type "string"; got "${f.type}".`
184+
)
185+
);
186+
}
187+
if (index.tokenizer !== 'hashtag') {
188+
issues.push(
189+
err(
190+
`${cPath}/indexes/index/${k}/tokenizer`,
191+
'lint.indexes.index_tokenized_missing_tokenizer',
192+
'Tokenized query indexes currently require tokenizer="hashtag".'
193+
)
194+
);
195+
}
196+
} else if (index.tokenizer !== undefined) {
197+
issues.push(
198+
err(
199+
`${cPath}/indexes/index/${k}/tokenizer`,
200+
'lint.indexes.index_tokenizer_without_tokenized_mode',
201+
'query index tokenizer is only valid when mode="tokenized".'
202+
)
203+
);
157204
}
158205
}
159206

207+
if ((schema.app.features?.onChainIndexing ?? true) === false && c.indexes.index.length > 0) {
208+
issues.push(
209+
warn(
210+
`${cPath}/indexes/index`,
211+
'lint.indexes.index_ignored_when_onchain_disabled',
212+
'app.features.onChainIndexing is false; query indexes will be omitted from generated contracts and on-chain browsing will require fallback behavior.'
213+
)
214+
);
215+
}
216+
160217
// relations
161218
const relationFields = new Set<string>();
162219
if (c.relations) {

packages/schema/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,13 @@ export interface UniqueIndex {
9797
scope?: 'active' | 'allTime';
9898
}
9999

100+
export type QueryIndexMode = 'equality' | 'tokenized';
101+
export type QueryIndexTokenizer = 'hashtag';
102+
100103
export interface QueryIndex {
101104
field: string;
105+
mode?: QueryIndexMode;
106+
tokenizer?: QueryIndexTokenizer;
102107
}
103108

104109
export interface Indexes {

schemas/tokenhost-ths.schema.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,33 @@
228228
"additionalProperties": false,
229229
"required": ["field"],
230230
"properties": {
231-
"field": { "type": "string" }
232-
}
231+
"field": { "type": "string" },
232+
"mode": { "type": "string", "enum": ["equality", "tokenized"] },
233+
"tokenizer": { "type": "string", "enum": ["hashtag"] }
234+
},
235+
"allOf": [
236+
{
237+
"if": {
238+
"properties": {
239+
"mode": { "const": "tokenized" }
240+
},
241+
"required": ["mode"]
242+
},
243+
"then": {
244+
"required": ["tokenizer"]
245+
}
246+
},
247+
{
248+
"if": {
249+
"required": ["tokenizer"]
250+
},
251+
"then": {
252+
"properties": {
253+
"mode": { "const": "tokenized" }
254+
}
255+
}
256+
}
257+
]
233258
},
234259
"relation": {
235260
"type": "object",

test/testThsSchema.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,106 @@ describe('THS schema validation + lint', function () {
150150
const issues = lintThs(res.data);
151151
expect(issues.some((i) => i.code === 'lint.app.ui.custom_home_without_extensions')).to.equal(true);
152152
});
153+
154+
it('validateThsStructural accepts tokenized query index primitives', function () {
155+
const input = minimalSchema({
156+
collections: [
157+
{
158+
name: 'Post',
159+
fields: [{ name: 'body', type: 'string', required: true }],
160+
createRules: { required: ['body'], access: 'public' },
161+
visibilityRules: { gets: ['body'], access: 'public' },
162+
updateRules: { mutable: ['body'], access: 'owner' },
163+
deleteRules: { softDelete: true, access: 'owner' },
164+
indexes: {
165+
unique: [],
166+
index: [{ field: 'body', mode: 'tokenized', tokenizer: 'hashtag' }]
167+
}
168+
}
169+
]
170+
});
171+
172+
const res = validateThsStructural(input);
173+
expect(res.ok).to.equal(true);
174+
});
175+
176+
it('lintThs rejects tokenized query indexes on unsupported field types', function () {
177+
const input = minimalSchema({
178+
collections: [
179+
{
180+
name: 'Post',
181+
fields: [{ name: 'imageRef', type: 'image', required: true }],
182+
createRules: { required: ['imageRef'], access: 'public' },
183+
visibilityRules: { gets: ['imageRef'], access: 'public' },
184+
updateRules: { mutable: ['imageRef'], access: 'owner' },
185+
deleteRules: { softDelete: true, access: 'owner' },
186+
indexes: {
187+
unique: [],
188+
index: [{ field: 'imageRef', mode: 'tokenized', tokenizer: 'hashtag' }]
189+
}
190+
}
191+
]
192+
});
193+
194+
const res = validateThsStructural(input);
195+
expect(res.ok).to.equal(true);
196+
const issues = lintThs(res.data);
197+
expect(issues.some((i) => i.code === 'lint.indexes.index_tokenized_unsupported_type')).to.equal(true);
198+
});
199+
200+
it('lintThs rejects duplicate query indexes on the same field', function () {
201+
const input = minimalSchema({
202+
collections: [
203+
{
204+
name: 'Post',
205+
fields: [{ name: 'body', type: 'string', required: true }],
206+
createRules: { required: ['body'], access: 'public' },
207+
visibilityRules: { gets: ['body'], access: 'public' },
208+
updateRules: { mutable: ['body'], access: 'owner' },
209+
deleteRules: { softDelete: true, access: 'owner' },
210+
indexes: {
211+
unique: [],
212+
index: [
213+
{ field: 'body' },
214+
{ field: 'body', mode: 'tokenized', tokenizer: 'hashtag' }
215+
]
216+
}
217+
}
218+
]
219+
});
220+
221+
const res = validateThsStructural(input);
222+
expect(res.ok).to.equal(true);
223+
const issues = lintThs(res.data);
224+
expect(issues.some((i) => i.code === 'lint.indexes.index_duplicate_field')).to.equal(true);
225+
});
226+
227+
it('lintThs warns when query indexes are declared but on-chain indexing is disabled', function () {
228+
const input = minimalSchema({
229+
app: {
230+
name: 'Test App',
231+
slug: 'test-app',
232+
features: { uploads: false, onChainIndexing: false }
233+
},
234+
collections: [
235+
{
236+
name: 'Post',
237+
fields: [{ name: 'body', type: 'string', required: true }],
238+
createRules: { required: ['body'], access: 'public' },
239+
visibilityRules: { gets: ['body'], access: 'public' },
240+
updateRules: { mutable: ['body'], access: 'owner' },
241+
deleteRules: { softDelete: true, access: 'owner' },
242+
indexes: {
243+
unique: [],
244+
index: [{ field: 'body', mode: 'tokenized', tokenizer: 'hashtag' }]
245+
}
246+
}
247+
]
248+
});
249+
250+
const res = validateThsStructural(input);
251+
expect(res.ok).to.equal(true);
252+
const issues = lintThs(res.data);
253+
expect(issues.some((i) => i.code === 'lint.indexes.index_ignored_when_onchain_disabled')).to.equal(true);
254+
});
153255
});

0 commit comments

Comments
 (0)