also looking at this
Migrate :text/x-text bindings to {{ }} interpolation (stx 0.2.72)
#990
50 files
+611
-371
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -59,7 +59,7 @@ export default defineModel({ | ||
| 59 | 59 | order: 2, |
| 60 | 60 | fillable: true, |
| 61 | 61 | validation: { |
| 62 | rule: schema.number().required().min(0), | |
| 62 | rule: schema.float().required().min(0), | |
| 63 | 63 | message: { |
| 64 | 64 | required: 'Distance is required', |
| 65 | 65 | }, |
| @@ -113,7 +113,7 @@ export default defineModel({ | ||
| 113 | 113 | order: 5, |
| 114 | 114 | fillable: true, |
| 115 | 115 | validation: { |
| 116 | rule: schema.number().min(0), | |
| 116 | rule: schema.float().min(0), | |
| 117 | 117 | }, |
| 118 | 118 | factory: (faker) => faker.number.int({ min: 100, max: 3000 }), |
| 119 | 119 | }, |
| @@ -112,7 +112,7 @@ export default defineModel({ | ||
| 112 | 112 | order: 5, |
| 113 | 113 | fillable: true, |
| 114 | 114 | validation: { |
| 115 | rule: schema.number().required(), | |
| 115 | rule: schema.float().required(), | |
| 116 | 116 | message: { |
| 117 | 117 | required: 'Center latitude is required', |
| 118 | 118 | }, |
| @@ -124,7 +124,7 @@ export default defineModel({ | ||
| 124 | 124 | order: 6, |
| 125 | 125 | fillable: true, |
| 126 | 126 | validation: { |
| 127 | rule: schema.number().required(), | |
| 127 | rule: schema.float().required(), | |
| 128 | 128 | message: { |
| 129 | 129 | required: 'Center longitude is required', |
| 130 | 130 | }, |
| @@ -136,7 +136,7 @@ export default defineModel({ | ||
| 136 | 136 | order: 7, |
| 137 | 137 | fillable: true, |
| 138 | 138 | validation: { |
| 139 | rule: schema.number().required().min(0), | |
| 139 | rule: schema.float().required().min(0), | |
| 140 | 140 | message: { |
| 141 | 141 | required: 'Area size is required', |
| 142 | 142 | }, |
| @@ -149,7 +149,7 @@ export default defineModel({ | ||
| 149 | 149 | fillable: true, |
| 150 | 150 | nullable: true, |
| 151 | 151 | validation: { |
| 152 | rule: schema.number().min(0), | |
| 152 | rule: schema.float().min(0), | |
| 153 | 153 | }, |
| 154 | 154 | factory: (faker) => faker.number.float({ min: 100, max: 5000, fractionDigits: 2 }), |
| 155 | 155 | }, |
| @@ -75,7 +75,7 @@ export default defineModel({ | ||
| 75 | 75 | fillable: true, |
| 76 | 76 | nullable: true, |
| 77 | 77 | validation: { |
| 78 | rule: schema.number().min(0), | |
| 78 | rule: schema.float().min(0), | |
| 79 | 79 | }, |
| 80 | 80 | factory: (faker) => faker.number.float({ min: 1000, max: 500000, fractionDigits: 2 }), |
| 81 | 81 | }, |
| @@ -48,7 +48,7 @@ export default defineModel({ | ||
| 48 | 48 | order: 2, |
| 49 | 49 | fillable: true, |
| 50 | 50 | validation: { |
| 51 | rule: schema.number().min(0), | |
| 51 | rule: schema.float().min(0), | |
| 52 | 52 | }, |
| 53 | 53 | factory: (faker) => faker.number.float({ min: 0, max: 5000000, fractionDigits: 2 }), |
| 54 | 54 | }, |
| @@ -108,7 +108,7 @@ export default defineModel({ | ||
| 108 | 108 | order: 8, |
| 109 | 109 | fillable: true, |
| 110 | 110 | validation: { |
| 111 | rule: schema.number().min(0), | |
| 111 | rule: schema.float().min(0), | |
| 112 | 112 | }, |
| 113 | 113 | factory: (faker) => faker.number.float({ min: 0, max: 500000, fractionDigits: 2 }), |
| 114 | 114 | }, |
| @@ -57,7 +57,7 @@ export default defineModel({ | ||
| 57 | 57 | order: 3, |
| 58 | 58 | fillable: true, |
| 59 | 59 | validation: { |
| 60 | rule: schema.number().required().min(0), | |
| 60 | rule: schema.float().required().min(0), | |
| 61 | 61 | message: { |
| 62 | 62 | required: 'Distance is required', |
| 63 | 63 | min: 'Distance must be positive', |
| @@ -70,7 +70,7 @@ export default defineModel({ | ||
| 70 | 70 | order: 4, |
| 71 | 71 | fillable: true, |
| 72 | 72 | validation: { |
| 73 | rule: schema.number().required().min(0), | |
| 73 | rule: schema.float().required().min(0), | |
| 74 | 74 | message: { |
| 75 | 75 | required: 'Elevation is required', |
| 76 | 76 | }, |
| @@ -94,7 +94,7 @@ export default defineModel({ | ||
| 94 | 94 | order: 6, |
| 95 | 95 | fillable: true, |
| 96 | 96 | validation: { |
| 97 | rule: schema.number().min(0).max(5), | |
| 97 | rule: schema.float().min(0).max(5), | |
| 98 | 98 | }, |
| 99 | 99 | factory: (faker) => faker.number.float({ min: 3.5, max: 5, fractionDigits: 1 }), |
| 100 | 100 | }, |
| @@ -143,7 +143,7 @@ export default defineModel({ | ||
| 143 | 143 | order: 11, |
| 144 | 144 | fillable: true, |
| 145 | 145 | validation: { |
| 146 | rule: schema.number(), | |
| 146 | rule: schema.float(), | |
| 147 | 147 | }, |
| 148 | 148 | factory: (faker) => faker.location.latitude(), |
| 149 | 149 | }, |
| @@ -152,7 +152,7 @@ export default defineModel({ | ||
| 152 | 152 | order: 12, |
| 153 | 153 | fillable: true, |
| 154 | 154 | validation: { |
| 155 | rule: schema.number(), | |
| 155 | rule: schema.float(), | |
| 156 | 156 | }, |
| 157 | 157 | factory: (faker) => faker.location.longitude(), |
| 158 | 158 | }, |
| @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS "challenges" ( | ||
| 3 | 3 | "challenger_id" INTEGER, |
| 4 | 4 | "challenged_id" INTEGER, |
| 5 | 5 | "territory_id" INTEGER REFERENCES "territories"("id"), |
| 6 | "area_at_stake" INTEGER, | |
| 6 | "area_at_stake" REAL, | |
| 7 | 7 | "status" TEXT CHECK ("status" IN ('pending', 'active', 'completed', 'declined')), |
| 8 | 8 | "winner_id" INTEGER, |
| 9 | 9 | "deadline" TEXT, |
| @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "territory_histories" ( | ||
| 5 | 5 | "activity_id" INTEGER REFERENCES "activities"("id"), |
| 6 | 6 | "previous_owner_id" INTEGER, |
| 7 | 7 | "event_type" TEXT CHECK ("event_type" IN ('claimed', 'conquered', 'split', 'defended', 'contested', 'expired')), |
| 8 | "area_at_event" INTEGER, | |
| 8 | "area_at_event" REAL, | |
| 9 | 9 | "previous_ownership_duration" INTEGER, |
| 10 | 10 | "notes" TEXT, |
| 11 | 11 | "new_territory_id" INTEGER, |
| @@ -2,16 +2,16 @@ CREATE TABLE IF NOT EXISTS "trails" ( | ||
| 2 | 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | 3 | "name" TEXT, |
| 4 | 4 | "location" TEXT, |
| 5 | "distance" INTEGER, | |
| 6 | "elevation" INTEGER, | |
| 5 | "distance" REAL, | |
| 6 | "elevation" REAL, | |
| 7 | 7 | "difficulty" TEXT CHECK ("difficulty" IN ('easy', 'moderate', 'hard')), |
| 8 | "rating" INTEGER, | |
| 8 | "rating" REAL, | |
| 9 | 9 | "review_count" INTEGER, |
| 10 | 10 | "estimated_time" TEXT, |
| 11 | 11 | "image" TEXT, |
| 12 | 12 | "tags" TEXT, |
| 13 | "latitude" INTEGER, | |
| 14 | "longitude" INTEGER, | |
| 13 | "latitude" REAL, | |
| 14 | "longitude" REAL, | |
| 15 | 15 | "description" TEXT, |
| 16 | 16 | "geometry" TEXT, |
| 17 | 17 | "created_at" TEXT not null default CURRENT_TIMESTAMP, |
| @@ -3,11 +3,11 @@ CREATE TABLE IF NOT EXISTS "activities" ( | ||
| 3 | 3 | "user_id" INTEGER REFERENCES "users"("id"), |
| 4 | 4 | "trail_id" INTEGER REFERENCES "trails"("id"), |
| 5 | 5 | "activity_type" TEXT CHECK ("activity_type" IN ('Trail Run', 'Hike', 'Walk', 'Bike')), |
| 6 | "distance" INTEGER, | |
| 6 | "distance" REAL, | |
| 7 | 7 | "duration" TEXT, |
| 8 | 8 | "moving_time" TEXT, |
| 9 | 9 | "pace" TEXT, |
| 10 | "elevation" INTEGER, | |
| 10 | "elevation" REAL, | |
| 11 | 11 | "kudos_count" INTEGER, |
| 12 | 12 | "notes" TEXT, |
| 13 | 13 | "gpx_data" TEXT, |
| @@ -6,10 +6,10 @@ CREATE TABLE IF NOT EXISTS "territories" ( | ||
| 6 | 6 | "name" TEXT, |
| 7 | 7 | "polygon_data" TEXT, |
| 8 | 8 | "bounding_box" TEXT, |
| 9 | "center_lat" INTEGER, | |
| 10 | "center_lng" INTEGER, | |
| 11 | "area_size" INTEGER, | |
| 12 | "perimeter" INTEGER, | |
| 9 | "center_lat" REAL, | |
| 10 | "center_lng" REAL, | |
| 11 | "area_size" REAL, | |
| 12 | "perimeter" REAL, | |
| 13 | 13 | "status" TEXT CHECK ("status" IN ('active', 'contested', 'expired')), |
| 14 | 14 | "conquest_count" INTEGER, |
| 15 | 15 | "claimed_at" TEXT, |
| @@ -2,13 +2,13 @@ CREATE TABLE IF NOT EXISTS "territory_stats" ( | ||
| 2 | 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | 3 | "user_id" INTEGER REFERENCES "users"("id"), |
| 4 | 4 | "total_territories_owned" INTEGER, |
| 5 | "total_area_owned" INTEGER, | |
| 5 | "total_area_owned" REAL, | |
| 6 | 6 | "territories_claimed" INTEGER, |
| 7 | 7 | "territories_conquered" INTEGER, |
| 8 | 8 | "territories_lost" INTEGER, |
| 9 | 9 | "territories_defended" INTEGER, |
| 10 | 10 | "longest_ownership_days" INTEGER, |
| 11 | "largest_territory_area" INTEGER, | |
| 11 | "largest_territory_area" REAL, | |
| 12 | 12 | "weekly_rank" INTEGER, |
| 13 | 13 | "all_time_rank" INTEGER, |
| 14 | 14 | "created_at" TEXT not null default CURRENT_TIMESTAMP, |
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * Verification harness for numeric column affinity (#974): float-holding | |
| 3 | * columns (lat/lng/area/perimeter/rating/distance/elevation) must have REAL | |
| 4 | * affinity; ids and counts must stay INTEGER; and fractional values must | |
| 5 | * round-trip through a fresh migrate without truncation. | |
| 6 | * | |
| 7 | * Run after a fresh migrate + seed: | |
| 8 | * bun scripts/seed-game-world.ts && bun scripts/verify-column-affinity.ts | |
| 9 | */ | |
| 10 | /* eslint-disable ts/no-top-level-await */ | |
| 11 | import process from 'node:process' | |
| 12 | ||
| 13 | process.on('unhandledRejection', (err) => { console.error('UNHANDLED', err); process.exit(1) }) | |
| 14 | ||
| 15 | import { Database } from 'bun:sqlite' | |
| 16 | ||
| 17 | let failures = 0 | |
| 18 | function check(label: string, ok: boolean, detail?: string) { | |
| 19 | console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` — ${detail}` : ''}`) | |
| 20 | if (!ok) failures++ | |
| 21 | } | |
| 22 | ||
| 23 | const db = new Database('database/stacks.sqlite') | |
| 24 | function colType(table: string, col: string): string { | |
| 25 | const info = db.query(`PRAGMA table_info(${table})`).all() as any[] | |
| 26 | return info.find(c => c.name === col)?.type ?? '(missing)' | |
| 27 | } | |
| 28 | ||
| 29 | // --- REAL float columns ---------------------------------------------------------- | |
| 30 | ||
| 31 | const REAL_COLS: Array<[string, string]> = [ | |
| 32 | ['trails', 'latitude'], ['trails', 'longitude'], ['trails', 'rating'], ['trails', 'distance'], ['trails', 'elevation'], | |
| 33 | ['territories', 'center_lat'], ['territories', 'center_lng'], ['territories', 'area_size'], ['territories', 'perimeter'], | |
| 34 | ['activities', 'distance'], ['activities', 'elevation'], | |
| 35 | ['territory_stats', 'total_area_owned'], ['territory_stats', 'largest_territory_area'], | |
| 36 | ['challenges', 'area_at_stake'], | |
| 37 | ['territory_histories', 'area_at_event'], | |
| 38 | ] | |
| 39 | for (const [t, c] of REAL_COLS) | |
| 40 | check(`${t}.${c} is REAL`, colType(t, c) === 'REAL', colType(t, c)) | |
| 41 | ||
| 42 | // --- INTEGER id/count columns (must NOT have become REAL) ------------------------- | |
| 43 | ||
| 44 | const INT_COLS: Array<[string, string]> = [ | |
| 45 | ['territories', 'user_id'], ['territories', 'activity_id'], ['territories', 'conquest_count'], | |
| 46 | ['activities', 'user_id'], ['activities', 'kudos_count'], | |
| 47 | ['trails', 'review_count'], | |
| 48 | ['territory_stats', 'total_territories_owned'], ['territory_stats', 'territories_conquered'], ['territory_stats', 'weekly_rank'], | |
| 49 | ['challenges', 'challenger_id'], ['challenges', 'winner_id'], | |
| 50 | ['reviews_alias_skip', 'noop'], | |
| 51 | ] | |
| 52 | for (const [t, c] of INT_COLS) { | |
| 53 | if (t === 'reviews_alias_skip') | |
| 54 | continue | |
| 55 | check(`${t}.${c} stays INTEGER`, colType(t, c) === 'INTEGER', colType(t, c)) | |
| 56 | } | |
| 57 | ||
| 58 | // --- fractional values survive a fresh migrate ----------------------------------- | |
| 59 | ||
| 60 | const terr = db.query('SELECT area_size, center_lat, center_lng, perimeter FROM territories WHERE area_size IS NOT NULL LIMIT 1').get() as any | |
| 61 | check('seeded territory floats are fractional (not truncated)', !!terr | |
| 62 | && terr.area_size % 1 !== 0 && terr.center_lat % 1 !== 0, JSON.stringify(terr)) | |
| 63 | ||
| 64 | // Insert a deliberately fractional row and read it back exactly. | |
| 65 | const probe = 123456.789012 | |
| 66 | db.query('INSERT INTO territories (user_id, name, status, area_size, center_lat, center_lng, perimeter) VALUES (1, \'affinity-probe\', \'active\', ?, ?, ?, ?)') | |
| 67 | .run(probe, 12.3456789, -98.7654321, 42.42) | |
| 68 | const back = db.query('SELECT area_size, center_lat FROM territories WHERE name = \'affinity-probe\'').get() as any | |
| 69 | check('fractional value round-trips exactly through REAL', back?.area_size === probe && back?.center_lat === 12.3456789, JSON.stringify(back)) | |
| 70 | check('stored as REAL storage class (not text/int)', | |
| 71 | (db.query('SELECT typeof(area_size) t FROM territories WHERE name = \'affinity-probe\'').get() as any)?.t === 'real') | |
| 72 | db.query('DELETE FROM territories WHERE name = \'affinity-probe\'').run() | |
| 73 | ||
| 74 | console.log(failures === 0 ? '\n✅ all column affinity checks passed' : `\n❌ ${failures} check(s) failed`) | |
| 75 | process.exit(failures === 0 ? 0 : 1) | |
| @@ -349,7 +349,7 @@ export function mapFieldTypeToColumnType(validator: ValidationType, driver = 'my | ||
| 349 | 349 | return `'timestamp'` |
| 350 | 350 | |
| 351 | 351 | if (isFloatValidator(validator)) |
| 352 | return `'float4'` | |
| 352 | return driver === 'sqlite' ? `'real'` : `'float4'` // SQLite: REAL affinity for floats (#974) | |
| 353 | 353 | |
| 354 | 354 | // Handle array/object types |
| 355 | 355 | if (['array', 'object'].includes(validator.name)) |