style: format server and shared with prettier

A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
This commit is contained in:
Maurice
2026-10-10 21:23:37 +02:00
parent f4e518b2df
commit 724c88c201
1978 changed files with 78448 additions and 39347 deletions
+402 -137
View File
@@ -13,21 +13,20 @@
// FeatureCollections that the server serves at runtime (no network at boot).
//
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas')
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas');
// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f'
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f';
const MEDIA = (a3, level) =>
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`;
// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
@@ -36,10 +35,10 @@ const MEDIA = (a3, level) =>
// irrelevant. (gbOpen ADM0 maxes Norway at 71°N and has no Svalbard at all.)
const CGAZ_ADM0 =
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`;
const CONCURRENCY = 8
const RETRIES = 3
const CONCURRENCY = 8;
const RETRIES = 3;
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
// Regional-Codes, plus user-assigned XKX/XK for Kosovo, which geoBoundaries ships but
@@ -47,7 +46,258 @@ const RETRIES = 3
// code; missing ones 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries
// keys by alpha-3 `shapeGroup`). A complete map — not the client's curated ~180 — is
// what restores the dropped territories (Greenland, Falklands, French Guiana, …).
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "XKX":"XK", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
const A3_TO_A2 = {
ABW: 'AW',
AFG: 'AF',
AGO: 'AO',
AIA: 'AI',
ALA: 'AX',
ALB: 'AL',
AND: 'AD',
ARE: 'AE',
ARG: 'AR',
ARM: 'AM',
ASM: 'AS',
ATA: 'AQ',
ATF: 'TF',
ATG: 'AG',
AUS: 'AU',
AUT: 'AT',
AZE: 'AZ',
BDI: 'BI',
BEL: 'BE',
BEN: 'BJ',
BES: 'BQ',
BFA: 'BF',
BGD: 'BD',
BGR: 'BG',
BHR: 'BH',
BHS: 'BS',
BIH: 'BA',
BLM: 'BL',
BLR: 'BY',
BLZ: 'BZ',
BMU: 'BM',
BOL: 'BO',
BRA: 'BR',
BRB: 'BB',
BRN: 'BN',
BTN: 'BT',
BVT: 'BV',
BWA: 'BW',
CAF: 'CF',
CAN: 'CA',
CCK: 'CC',
CHE: 'CH',
CHL: 'CL',
CHN: 'CN',
CIV: 'CI',
CMR: 'CM',
COD: 'CD',
COG: 'CG',
COK: 'CK',
COL: 'CO',
COM: 'KM',
CPV: 'CV',
CRI: 'CR',
CUB: 'CU',
CUW: 'CW',
CXR: 'CX',
CYM: 'KY',
CYP: 'CY',
CZE: 'CZ',
DEU: 'DE',
DJI: 'DJ',
DMA: 'DM',
DNK: 'DK',
DOM: 'DO',
DZA: 'DZ',
ECU: 'EC',
EGY: 'EG',
ERI: 'ER',
ESH: 'EH',
ESP: 'ES',
EST: 'EE',
ETH: 'ET',
FIN: 'FI',
FJI: 'FJ',
FLK: 'FK',
FRA: 'FR',
FRO: 'FO',
FSM: 'FM',
GAB: 'GA',
GBR: 'GB',
GEO: 'GE',
GGY: 'GG',
GHA: 'GH',
GIB: 'GI',
GIN: 'GN',
GLP: 'GP',
GMB: 'GM',
GNB: 'GW',
GNQ: 'GQ',
GRC: 'GR',
GRD: 'GD',
GRL: 'GL',
GTM: 'GT',
GUF: 'GF',
GUM: 'GU',
GUY: 'GY',
HKG: 'HK',
HMD: 'HM',
HND: 'HN',
HRV: 'HR',
HTI: 'HT',
HUN: 'HU',
IDN: 'ID',
IMN: 'IM',
IND: 'IN',
IOT: 'IO',
IRL: 'IE',
IRN: 'IR',
IRQ: 'IQ',
ISL: 'IS',
ISR: 'IL',
ITA: 'IT',
JAM: 'JM',
JEY: 'JE',
JOR: 'JO',
JPN: 'JP',
KAZ: 'KZ',
KEN: 'KE',
KGZ: 'KG',
KHM: 'KH',
KIR: 'KI',
KNA: 'KN',
KOR: 'KR',
KWT: 'KW',
LAO: 'LA',
LBN: 'LB',
LBR: 'LR',
LBY: 'LY',
LCA: 'LC',
LIE: 'LI',
LKA: 'LK',
LSO: 'LS',
LTU: 'LT',
LUX: 'LU',
LVA: 'LV',
MAC: 'MO',
MAF: 'MF',
MAR: 'MA',
MCO: 'MC',
MDA: 'MD',
MDG: 'MG',
MDV: 'MV',
MEX: 'MX',
MHL: 'MH',
MKD: 'MK',
MLI: 'ML',
MLT: 'MT',
MMR: 'MM',
MNE: 'ME',
MNG: 'MN',
MNP: 'MP',
MOZ: 'MZ',
MRT: 'MR',
MSR: 'MS',
MTQ: 'MQ',
MUS: 'MU',
MWI: 'MW',
MYS: 'MY',
MYT: 'YT',
NAM: 'NA',
NCL: 'NC',
NER: 'NE',
NFK: 'NF',
NGA: 'NG',
NIC: 'NI',
NIU: 'NU',
NLD: 'NL',
NOR: 'NO',
NPL: 'NP',
NRU: 'NR',
NZL: 'NZ',
OMN: 'OM',
PAK: 'PK',
PAN: 'PA',
PCN: 'PN',
PER: 'PE',
PHL: 'PH',
PLW: 'PW',
PNG: 'PG',
POL: 'PL',
PRI: 'PR',
PRK: 'KP',
PRT: 'PT',
PRY: 'PY',
PSE: 'PS',
PYF: 'PF',
QAT: 'QA',
REU: 'RE',
ROU: 'RO',
RUS: 'RU',
RWA: 'RW',
SAU: 'SA',
SDN: 'SD',
SEN: 'SN',
SGP: 'SG',
SGS: 'GS',
SHN: 'SH',
SJM: 'SJ',
SLB: 'SB',
SLE: 'SL',
SLV: 'SV',
SMR: 'SM',
SOM: 'SO',
SPM: 'PM',
SRB: 'RS',
SSD: 'SS',
STP: 'ST',
SUR: 'SR',
SVK: 'SK',
SVN: 'SI',
SWE: 'SE',
SWZ: 'SZ',
SXM: 'SX',
SYC: 'SC',
SYR: 'SY',
TCA: 'TC',
TCD: 'TD',
TGO: 'TG',
THA: 'TH',
TJK: 'TJ',
TKL: 'TK',
TKM: 'TM',
TLS: 'TL',
TON: 'TO',
TTO: 'TT',
TUN: 'TN',
TUR: 'TR',
TUV: 'TV',
TWN: 'TW',
TZA: 'TZ',
UGA: 'UG',
UKR: 'UA',
UMI: 'UM',
URY: 'UY',
USA: 'US',
UZB: 'UZ',
VAT: 'VA',
VCT: 'VC',
VEN: 'VE',
VGB: 'VG',
VIR: 'VI',
VNM: 'VN',
VUT: 'VU',
WLF: 'WF',
WSM: 'WS',
XKX: 'XK',
YEM: 'YE',
ZAF: 'ZA',
ZMB: 'ZM',
ZWE: 'ZW',
};
/**
* Countries whose sub-national level is not the one everybody else's ADM1 is.
@@ -63,52 +313,55 @@ const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "A
* bundle several times over for no gain: for most countries ADM1 already is the
* level people think in.
*/
const ADM_LEVEL = { GBR: 'ADM2' }
const ADM_LEVEL = { GBR: 'ADM2' };
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
const COUNTRIES = Object.keys(A3_TO_A2); // every ISO alpha-3 code (ADM1 fetch list)
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF)
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF);
async function fetchGeo(url) {
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0])
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0]);
if (fs.existsSync(cacheFile)) {
const cached = fs.readFileSync(cacheFile, 'utf8')
return cached === '' ? null : JSON.parse(cached)
const cached = fs.readFileSync(cacheFile, 'utf8');
return cached === '' ? null : JSON.parse(cached);
}
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try {
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } })
if (res.status === 404) { fs.writeFileSync(cacheFile, ''); return null } // no file — skip
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content')
const parsed = JSON.parse(text)
fs.writeFileSync(cacheFile, text)
return parsed
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } });
if (res.status === 404) {
fs.writeFileSync(cacheFile, '');
return null;
} // no file — skip
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content');
const parsed = JSON.parse(text);
fs.writeFileSync(cacheFile, text);
return parsed;
} catch (err) {
if (attempt === RETRIES) {
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`)
return null
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`);
return null;
}
await new Promise(r => setTimeout(r, 500 * attempt))
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
return null
return null;
}
// Run async tasks with a fixed concurrency cap.
async function pool(items, worker) {
const results = []
let i = 0
const results = [];
let i = 0;
const runners = Array.from({ length: CONCURRENCY }, async () => {
while (i < items.length) {
const idx = i++
results[idx] = await worker(items[idx], idx)
const idx = i++;
results[idx] = await worker(items[idx], idx);
}
})
await Promise.all(runners)
return results
});
await Promise.all(runners);
return results;
}
// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
@@ -117,149 +370,161 @@ async function pool(items, worker) {
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
// they tolerate a coarser grid than ADM1 region borders.
const ADM0_DECIMALS = 2 // ~1.1 km
const ADM1_DECIMALS = 3 // ~110 m
const ADM0_DECIMALS = 2; // ~1.1 km
const ADM1_DECIMALS = 3; // ~110 m
function quantizeRing(ring, decimals) {
const m = 10 ** decimals
const out = []
let prevX, prevY
const m = 10 ** decimals;
const out = [];
let prevX, prevY;
for (const pt of ring) {
const x = Math.round(pt[0] * m) / m
const y = Math.round(pt[1] * m) / m
if (x === prevX && y === prevY) continue
out.push([x, y])
prevX = x; prevY = y
const x = Math.round(pt[0] * m) / m;
const y = Math.round(pt[1] * m) / m;
if (x === prevX && y === prevY) continue;
out.push([x, y]);
prevX = x;
prevY = y;
}
return out
return out;
}
// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
function quantizeGeometry(geom, decimals) {
if (!geom) return null
if (!geom) return null;
if (geom.type === 'Polygon') {
const rings = geom.coordinates.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4)
return rings.length ? { type: 'Polygon', coordinates: rings } : null
const rings = geom.coordinates.map((r) => quantizeRing(r, decimals)).filter((r) => r.length >= 4);
return rings.length ? { type: 'Polygon', coordinates: rings } : null;
}
if (geom.type === 'MultiPolygon') {
const polys = geom.coordinates
.map(poly => poly.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4))
.filter(poly => poly.length)
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null
.map((poly) => poly.map((r) => quantizeRing(r, decimals)).filter((r) => r.length >= 4))
.filter((poly) => poly.length);
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null;
}
return geom
return geom;
}
// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
// pseudo-entry or anything without a group/geometry.
function normalizeAdm0Feature(f) {
const a3 = f.properties?.shapeGroup
if (!a3) return null
const name = f.properties?.shapeName || a3
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS)
if (!geometry) return null
const a3 = f.properties?.shapeGroup;
if (!a3) return null;
const name = f.properties?.shapeName || a3;
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS);
if (!geometry) return null;
return {
type: 'Feature',
properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
geometry,
}
};
}
function normalizeAdm1(geo, a3, countryName) {
if (!geo?.features) return []
const a2 = A3_TO_A2[a3] || null
if (!geo?.features) return [];
const a2 = A3_TO_A2[a3] || null;
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
// country.
const used = new Set()
const used = new Set();
const uniq = (base) => {
let code = base, n = 2
while (used.has(code)) code = `${base}-${n++}`
used.add(code)
return code
}
return geo.features.map(f => {
let name = f.properties?.shapeName || ''
// geoBoundaries erroneously names Guangdong Province as "Guangzhou Province" (Guangzhou is the capital city).
if (a3 === 'CHN' && name === 'Guangzhou Province') {
name = 'Guangdong Province'
}
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
if (!geometry) return null
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
// fills it with the bare country code instead of a subdivision code — e.g. every
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
// stable, unique-per-country id from the region name so each region is independently
// markable.
const raw = f.properties?.shapeISO || ''
let code
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
code = raw
used.add(code)
} else if (a2) {
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`)
} else {
code = raw
}
return {
type: 'Feature',
// Property names the Atlas region layer + server getRegionGeo already read.
properties: {
iso_a2: a2,
iso_3166_2: code,
name,
name_en: name,
admin: countryName,
},
geometry,
}
}).filter(Boolean)
let code = base,
n = 2;
while (used.has(code)) code = `${base}-${n++}`;
used.add(code);
return code;
};
return geo.features
.map((f) => {
let name = f.properties?.shapeName || '';
// geoBoundaries erroneously names Guangdong Province as "Guangzhou Province" (Guangzhou is the capital city).
if (a3 === 'CHN' && name === 'Guangzhou Province') {
name = 'Guangdong Province';
}
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS);
if (!geometry) return null;
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
// fills it with the bare country code instead of a subdivision code — e.g. every
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
// stable, unique-per-country id from the region name so each region is independently
// markable.
const raw = f.properties?.shapeISO || '';
let code;
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
code = raw;
used.add(code);
} else if (a2) {
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`);
} else {
code = raw;
}
return {
type: 'Feature',
// Property names the Atlas region layer + server getRegionGeo already read.
properties: {
iso_a2: a2,
iso_3166_2: code,
name,
name_en: name,
admin: countryName,
},
geometry,
};
})
.filter(Boolean);
}
async function main() {
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`)
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.mkdirSync(CACHE_DIR, { recursive: true })
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`);
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(CACHE_DIR, { recursive: true });
// ADM0 (countries) — one comprehensive CGAZ file (large; cached). Also yields the
// English country name (shapeGroup → shapeName) used for the ADM1 `admin` field.
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…')
const cgaz = await fetchGeo(CGAZ_ADM0)
const adm0Features = []
const a3ToName = {}
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…');
const cgaz = await fetchGeo(CGAZ_ADM0);
const adm0Features = [];
const a3ToName = {};
for (const f of cgaz?.features || []) {
const nf = normalizeAdm0Feature(f)
if (nf) { a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME; adm0Features.push(nf) }
const nf = normalizeAdm0Feature(f);
if (nf) {
a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME;
adm0Features.push(nf);
}
}
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
console.log('[atlas-geo] downloading ADM1 (regions)…')
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')))
const adm1Features = []
let withCodes = 0
console.log('[atlas-geo] downloading ADM1 (regions)…');
const adm1Raw = await pool(COUNTRIES, (a3) => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')));
const adm1Features = [];
let withCodes = 0;
COUNTRIES.forEach((a3, idx) => {
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3)
for (const f of feats) if (f.properties.iso_3166_2) withCodes++
adm1Features.push(...feats)
})
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3);
for (const f of feats) if (f.properties.iso_3166_2) withCodes++;
adm1Features.push(...feats);
});
const write = (name, features) => {
const fc = { type: 'FeatureCollection', features }
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 })
const file = path.join(OUT_DIR, `${name}.geojson.gz`)
fs.writeFileSync(file, gz)
console.log(`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`)
}
const fc = { type: 'FeatureCollection', features };
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 });
const file = path.join(OUT_DIR, `${name}.geojson.gz`);
fs.writeFileSync(file, gz);
console.log(
`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} — ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`,
);
};
write('admin0', adm0Features)
write('admin1', adm1Features)
write('admin0', adm0Features);
write('admin1', adm1Features);
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length)
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`)
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`)
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`)
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length);
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`);
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`);
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`);
}
main().catch(err => { console.error(err); process.exit(1) })
main().catch((err) => {
console.error(err);
process.exit(1);
});
+271 -27
View File
@@ -37,7 +37,17 @@ const HEADER = `-- Legacy-runner install at schema_version 244: the ${TAG} relea
function bootRelease(tmp) {
const archive = path.join(tmp, 'src.tar');
execFileSync('git', ['-C', REPO, 'archive', '-o', archive, TAG, 'server/src', 'server/package.json', 'server/tsconfig.json']);
execFileSync('git', [
'-C',
REPO,
'archive',
'-o',
archive,
TAG,
'server/src',
'server/package.json',
'server/tsconfig.json',
]);
// Relative names: GNU tar reads a drive letter as a remote host.
execFileSync('tar', ['-xf', 'src.tar'], { cwd: tmp });
fs.rmSync(archive);
@@ -70,14 +80,38 @@ function seed(db) {
db.prepare(`UPDATE users SET password_hash = 'fixture-login-disabled' WHERE id = 1`).run();
// Mixed case on purpose: the case-insensitive email index must accept it.
ins('users', { id: 2, username: 'alice', email: 'Alice.Traveller@Example.test', password_hash: 'fixture-login-disabled', display_name: 'Alice' });
ins('users', {
id: 2,
username: 'alice',
email: 'Alice.Traveller@Example.test',
password_hash: 'fixture-login-disabled',
display_name: 'Alice',
});
ins('users', { id: 3, username: 'bob', email: 'bob@example.test', password_hash: 'fixture-login-disabled' });
ins('settings', { user_id: 2, key: 'temperature_unit', value: 'celsius' });
ins('settings', { user_id: 3, key: 'language', value: 'de' });
// Trip 1 is upcoming and shared; trip 2 lies in the past with a reminder long due.
ins('trips', { id: 1, user_id: 2, title: 'Lisbon long weekend', description: 'Synthetic upgrade fixture trip', start_date: '2027-05-10', end_date: '2027-05-12', currency: 'EUR', reminder_days: 3 });
ins('trips', { id: 2, user_id: 3, title: 'Alps hiking', start_date: '2026-03-01', end_date: '2026-03-02', currency: 'CHF', reminder_days: 1, is_archived: 1 });
ins('trips', {
id: 1,
user_id: 2,
title: 'Lisbon long weekend',
description: 'Synthetic upgrade fixture trip',
start_date: '2027-05-10',
end_date: '2027-05-12',
currency: 'EUR',
reminder_days: 3,
});
ins('trips', {
id: 2,
user_id: 3,
title: 'Alps hiking',
start_date: '2026-03-01',
end_date: '2026-03-02',
currency: 'CHF',
reminder_days: 1,
is_archived: 1,
});
ins('trip_members', { trip_id: 1, user_id: 3, invited_by: 2 });
for (const [id, trip_id, day_number, date, title] of [
@@ -90,16 +124,59 @@ function seed(db) {
ins('days', { id, trip_id, day_number, date, title });
}
ins('places', { id: 1, trip_id: 1, name: 'Hotel Alfama', lat: 38.7115, lng: -9.13, address: 'Rua dos Remedios 1, Lisboa', category_id: 1 });
ins('places', { id: 2, trip_id: 1, name: 'Torre de Belem', lat: 38.6916, lng: -9.216, address: 'Av. Brasilia, Lisboa', category_id: 3, price: 12.5, currency: 'EUR', notes: 'Book online' });
ins('places', { id: 3, trip_id: 1, name: 'Time Out Market', lat: 38.7069, lng: -9.1459, category_id: 2, place_time: '19:30' });
ins('places', {
id: 1,
trip_id: 1,
name: 'Hotel Alfama',
lat: 38.7115,
lng: -9.13,
address: 'Rua dos Remedios 1, Lisboa',
category_id: 1,
});
ins('places', {
id: 2,
trip_id: 1,
name: 'Torre de Belem',
lat: 38.6916,
lng: -9.216,
address: 'Av. Brasilia, Lisboa',
category_id: 3,
price: 12.5,
currency: 'EUR',
notes: 'Book online',
});
ins('places', {
id: 3,
trip_id: 1,
name: 'Time Out Market',
lat: 38.7069,
lng: -9.1459,
category_id: 2,
place_time: '19:30',
});
ins('places', { id: 4, trip_id: 2, name: 'Gornergrat', lat: 45.9833, lng: 7.7847, category_id: 3 });
ins('places', { id: 5, trip_id: 1, name: 'Fado bar', lat: 38.7110, lng: -9.1290, category_id: 2 });
ins('places', { id: 5, trip_id: 1, name: 'Fado bar', lat: 38.711, lng: -9.129, category_id: 2 });
// The canaries tests/helpers/legacy-upgrade-suite.ts asserts on, as in legacy-v242:
// user-set times on assignments 1 to 3 of day 2, and the booked night after them.
ins('day_accommodations', { id: 1, trip_id: 1, place_id: 1, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00', confirmation: 'HTL-4242' });
ins('day_assignments', { id: 1, day_id: 2, place_id: 2, order_index: 0, assignment_time: '09:00', assignment_end_time: '12:00' });
ins('day_accommodations', {
id: 1,
trip_id: 1,
place_id: 1,
start_day_id: 1,
end_day_id: 3,
check_in: '15:00',
check_out: '11:00',
confirmation: 'HTL-4242',
});
ins('day_assignments', {
id: 1,
day_id: 2,
place_id: 2,
order_index: 0,
assignment_time: '09:00',
assignment_end_time: '12:00',
});
ins('day_assignments', { id: 2, day_id: 2, place_id: 3, order_index: 1, assignment_time: '18:00' });
ins('day_assignments', { id: 3, day_id: 2, place_id: 5, order_index: 2, assignment_time: '20:00' });
ins('day_assignments', { id: 4, day_id: 2, place_id: 1, order_index: 3, accommodation_id: 1 });
@@ -107,36 +184,203 @@ function seed(db) {
ins('day_assignments', { id: 6, day_id: 4, place_id: 4, order_index: 0 });
ins('day_notes', { day_id: 2, trip_id: 1, text: 'Tram 15 to Belem', time: '09:30', sort_order: 0 });
ins('reservations', { id: 1, trip_id: 1, day_id: 1, title: 'TP 1351 Frankfurt to Lisbon', reservation_time: '2027-05-10T08:15', reservation_end_time: '2027-05-10T10:05', confirmation_number: 'ABC123', status: 'confirmed', type: 'flight' });
ins('reservation_endpoints', { reservation_id: 1, role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA', lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin', local_time: '08:15', local_date: '2027-05-10' });
ins('reservation_endpoints', { reservation_id: 1, role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', lat: 38.7742, lng: -9.1342, timezone: 'Europe/Lisbon', local_time: '10:05', local_date: '2027-05-10' });
ins('reservations', { id: 2, trip_id: 1, day_id: 1, end_day_id: 3, place_id: 1, accommodation_id: 1, title: 'Hotel Alfama', confirmation_number: 'HTL-4242', status: 'confirmed', type: 'hotel' });
ins('reservations', {
id: 1,
trip_id: 1,
day_id: 1,
title: 'TP 1351 Frankfurt to Lisbon',
reservation_time: '2027-05-10T08:15',
reservation_end_time: '2027-05-10T10:05',
confirmation_number: 'ABC123',
status: 'confirmed',
type: 'flight',
});
ins('reservation_endpoints', {
reservation_id: 1,
role: 'from',
sequence: 0,
name: 'Frankfurt',
code: 'FRA',
lat: 50.0379,
lng: 8.5622,
timezone: 'Europe/Berlin',
local_time: '08:15',
local_date: '2027-05-10',
});
ins('reservation_endpoints', {
reservation_id: 1,
role: 'to',
sequence: 1,
name: 'Lisbon',
code: 'LIS',
lat: 38.7742,
lng: -9.1342,
timezone: 'Europe/Lisbon',
local_time: '10:05',
local_date: '2027-05-10',
});
ins('reservations', {
id: 2,
trip_id: 1,
day_id: 1,
end_day_id: 3,
place_id: 1,
accommodation_id: 1,
title: 'Hotel Alfama',
confirmation_number: 'HTL-4242',
status: 'confirmed',
type: 'hotel',
});
ins('budget_items', { id: 1, trip_id: 1, category: 'Accommodation', name: 'Hotel Alfama, two nights', total_price: 312.4, persons: 2, days: 2, paid_by_user_id: 2, expense_date: '2027-05-10', reservation_id: 2, currency: 'EUR' });
ins('budget_items', {
id: 1,
trip_id: 1,
category: 'Accommodation',
name: 'Hotel Alfama, two nights',
total_price: 312.4,
persons: 2,
days: 2,
paid_by_user_id: 2,
expense_date: '2027-05-10',
reservation_id: 2,
currency: 'EUR',
});
ins('budget_item_members', { budget_item_id: 1, user_id: 2, paid: 1, amount: 156.2 });
ins('budget_item_members', { budget_item_id: 1, user_id: 3, paid: 0, amount: 156.2 });
ins('budget_items', { id: 2, trip_id: 1, category: 'Food', name: 'Dinner at the market', total_price: 64, persons: 2, paid_by_user_id: 3, currency: 'EUR' });
ins('budget_items', {
id: 2,
trip_id: 1,
category: 'Food',
name: 'Dinner at the market',
total_price: 64,
persons: 2,
paid_by_user_id: 3,
currency: 'EUR',
});
ins('packing_bags', { id: 1, trip_id: 1, name: 'Carry-on', color: '#3b82f6', weight_limit_grams: 8000, sort_order: 0, user_id: 2 });
ins('packing_items', { trip_id: 1, name: 'Passport', checked: 1, category: 'Documents', sort_order: 0, bag_id: 1, quantity: 1 });
ins('packing_items', { trip_id: 1, name: 'Sunscreen', checked: 0, category: 'Toiletries', sort_order: 1, weight_grams: 150, quantity: 1 });
ins('packing_items', { trip_id: 1, name: 'Private charger', checked: 0, category: 'Tech', sort_order: 2, is_private: 1, owner_id: 3, quantity: 1 });
ins('packing_bags', {
id: 1,
trip_id: 1,
name: 'Carry-on',
color: '#3b82f6',
weight_limit_grams: 8000,
sort_order: 0,
user_id: 2,
});
ins('packing_items', {
trip_id: 1,
name: 'Passport',
checked: 1,
category: 'Documents',
sort_order: 0,
bag_id: 1,
quantity: 1,
});
ins('packing_items', {
trip_id: 1,
name: 'Sunscreen',
checked: 0,
category: 'Toiletries',
sort_order: 1,
weight_grams: 150,
quantity: 1,
});
ins('packing_items', {
trip_id: 1,
name: 'Private charger',
checked: 0,
category: 'Tech',
sort_order: 2,
is_private: 1,
owner_id: 3,
quantity: 1,
});
ins('todo_items', { trip_id: 1, name: 'Buy Lisboa Card', checked: 0, category: 'Tickets', sort_order: 0, due_date: '2027-05-01', assigned_user_id: 3, priority: 1 });
ins('collab_notes', { trip_id: 1, user_id: 2, category: 'Ideas', title: 'Fado evening', content: 'Ask the hotel for a tip' });
ins('todo_items', {
trip_id: 1,
name: 'Buy Lisboa Card',
checked: 0,
category: 'Tickets',
sort_order: 0,
due_date: '2027-05-01',
assigned_user_id: 3,
priority: 1,
});
ins('collab_notes', {
trip_id: 1,
user_id: 2,
category: 'Ideas',
title: 'Fado evening',
content: 'Ask the hotel for a tip',
});
ins('trip_files', { id: 1, trip_id: 1, reservation_id: 1, filename: 'fixture-boarding-pass.pdf', original_name: 'Boarding pass.pdf', file_size: 48213, mime_type: 'application/pdf', uploaded_by: 2 });
ins('trip_files', { id: 2, trip_id: 1, place_id: 2, filename: 'fixture-tickets.pdf', original_name: 'Tickets.pdf', file_size: 1024, mime_type: 'application/pdf', uploaded_by: 3, starred: 1 });
ins('trip_files', {
id: 1,
trip_id: 1,
reservation_id: 1,
filename: 'fixture-boarding-pass.pdf',
original_name: 'Boarding pass.pdf',
file_size: 48213,
mime_type: 'application/pdf',
uploaded_by: 2,
});
ins('trip_files', {
id: 2,
trip_id: 1,
place_id: 2,
filename: 'fixture-tickets.pdf',
original_name: 'Tickets.pdf',
file_size: 1024,
mime_type: 'application/pdf',
uploaded_by: 3,
starred: 1,
});
ins('tags', { id: 1, user_id: 2, name: 'Must see', color: '#ef4444' });
ins('place_tags', { place_id: 2, tag_id: 1 });
ins('journeys', { id: 1, user_id: 2, title: 'Portugal 2027', subtitle: 'Three days by the river', status: 'draft', created_at: T, updated_at: T });
ins('journeys', {
id: 1,
user_id: 2,
title: 'Portugal 2027',
subtitle: 'Three days by the river',
status: 'draft',
created_at: T,
updated_at: T,
});
ins('journey_trips', { journey_id: 1, trip_id: 1, added_at: T });
ins('journey_contributors', { journey_id: 1, user_id: 3, role: 'editor', added_at: T });
ins('journey_entries', { id: 1, journey_id: 1, source_trip_id: 1, source_place_id: 2, author_id: 2, type: 'entry', title: 'Belem in the sun', story: 'Pasteis first, tower second.', entry_date: '2027-05-11', entry_time: '11:00', location_name: 'Belem', location_lat: 38.6916, location_lng: -9.216, mood: 'amazing', visibility: 'shared', sort_order: 0, created_at: T, updated_at: T });
ins('journey_entries', {
id: 1,
journey_id: 1,
source_trip_id: 1,
source_place_id: 2,
author_id: 2,
type: 'entry',
title: 'Belem in the sun',
story: 'Pasteis first, tower second.',
entry_date: '2027-05-11',
entry_time: '11:00',
location_name: 'Belem',
location_lat: 38.6916,
location_lng: -9.216,
mood: 'amazing',
visibility: 'shared',
sort_order: 0,
created_at: T,
updated_at: T,
});
ins('notifications', { type: 'simple', scope: 'trip', target: 1, sender_id: 2, recipient_id: 3, title_key: 'notif.test.title', text_key: 'notif.test.text', is_read: 0 });
ins('notifications', {
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
recipient_id: 3,
title_key: 'notif.test.title',
text_key: 'notif.test.text',
is_read: 0,
});
}
/** Every timestamp the runner or a column default wrote is "now"; pin them so a rebuild is byte-stable. */
+4 -1
View File
@@ -35,7 +35,10 @@ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
const domains = new Map();
for (const [file, entry] of Object.entries(summary)) {
if (file === 'total') continue;
const match = file.split('\\').join('/').match(/\/src\/nest\/([a-z0-9-]+)\//);
const match = file
.split('\\')
.join('/')
.match(/\/src\/nest\/([a-z0-9-]+)\//);
if (!match) continue;
const totals = domains.get(match[1]) ?? new Map(METRICS.map(([k]) => [k, { covered: 0, total: 0 }]));
for (const [key, metric] of METRICS) {
+8 -1
View File
@@ -4,7 +4,14 @@ console.log('[dev] initial build...');
execSync('node scripts/build.mjs', { stdio: 'inherit' });
const children = [];
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
const stop = () => {
children.forEach((c) => {
try {
c.kill();
} catch {}
});
process.exit(0);
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
+19 -7
View File
@@ -29,8 +29,8 @@
*
* --dir=<path> points the check at another server root (the unit tests use it).
*/
import { createRequire } from 'node:module';
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -225,9 +225,12 @@ export function readBaseline(path) {
throw new Error(`scripts/dialect-baseline.json: ${file} must map rule names to counts`);
}
for (const [rule, n] of Object.entries(rules)) {
if (!RULE_NAMES.has(rule)) throw new Error(`scripts/dialect-baseline.json: ${file} names an unknown rule "${rule}"`);
if (!RULE_NAMES.has(rule))
throw new Error(`scripts/dialect-baseline.json: ${file} names an unknown rule "${rule}"`);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`scripts/dialect-baseline.json: ${file} ${rule} holds ${JSON.stringify(n)}, expected a positive integer`);
throw new Error(
`scripts/dialect-baseline.json: ${file} ${rule} holds ${JSON.stringify(n)}, expected a positive integer`,
);
}
}
}
@@ -291,18 +294,27 @@ function main(argv) {
console.error(`FAIL ${file}: ${n} SQLite-only ${rule} hit(s), ${allowed} allowed. ${adviceFor(rule)}.`);
}
for (const { file, rule, allowed, now } of stale) {
console.error(`FAIL ${file} is held at ${allowed} ${rule} hit(s) in scripts/dialect-baseline.json, but has ${now} now.`);
console.error(
`FAIL ${file} is held at ${allowed} ${rule} hit(s) in scripts/dialect-baseline.json, but has ${now} now.`,
);
}
if (stale.length) {
console.error('Run npm run lint:dialect -- --update to lower the baseline with the change that removed them.');
}
const total = Object.values(baseline).reduce((sum, rules) => sum + Object.values(rules).reduce((a, b) => a + b, 0), 0);
console.log(`dialect: ${Object.keys(baseline).length} file(s) hold ${total} SQLite-only spelling(s) at their baseline`);
const total = Object.values(baseline).reduce(
(sum, rules) => sum + Object.values(rules).reduce((a, b) => a + b, 0),
0,
);
console.log(
`dialect: ${Object.keys(baseline).length} file(s) hold ${total} SQLite-only spelling(s) at their baseline`,
);
return grown.length || stale.length ? 1 : 0;
}
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
+3 -2
View File
@@ -23,10 +23,11 @@
* ESLint comes from this workspace, so the server lints with the major its
* config was written for.
*/
import { ESLint } from 'eslint';
import { fileURLToPath } from 'node:url';
import { check, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
import { runCli } from '../../scripts/lib/ratchet.mjs';
import { ESLint } from 'eslint';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update'), lint: lintWith(ESLint) }));
+30 -26
View File
@@ -6,16 +6,16 @@
* out on an A4 landscape book as it does on the 210mm square it was designed
* for. Run from server/, output goes to the client.
*/
const Database = require('better-sqlite3')
const fs = require('fs')
const Database = require('better-sqlite3');
const fs = require('fs');
const JOURNEY = Number(process.argv[2] || 7)
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts'
const JOURNEY = Number(process.argv[2] || 7);
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts';
const db = new Database('data/travel.db', { readonly: true })
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY)
db.close()
if (!row) throw new Error(`no book on journey ${JOURNEY}`)
const db = new Database('data/travel.db', { readonly: true });
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY);
db.close();
if (!row) throw new Error(`no book on journey ${JOURNEY}`);
/*
* Through the contract on the way in, so a template carries every field the
@@ -23,40 +23,44 @@ if (!row) throw new Error(`no book on journey ${JOURNEY}`)
* Without it a template written today is missing tomorrow's defaults and does
* not typecheck as a BookElement.
*/
const { normalizeBookDocument } = require('@trek/shared')
const doc = normalizeBookDocument(JSON.parse(row.document))
const PW = doc.page.pageWidth
const PH = doc.page.pageHeight
const { normalizeBookDocument } = require('@trek/shared');
const doc = normalizeBookDocument(JSON.parse(row.document));
const PW = doc.page.pageWidth;
const PH = doc.page.pageHeight;
const r4 = n => Math.round(n * 10000) / 10000
const r4 = (n) => Math.round(n * 10000) / 10000;
/** A frame as fractions of one page's width and of the page height. */
function relFrame(f) {
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) }
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) };
}
/** Type size as a fraction of the page height, so it scales with the sheet. */
const relSize = n => r4(n / PH)
const relSize = (n) => r4(n / PH);
function element(e) {
// The id travels with the template and is replaced when it is applied —
// dropping it here would leave the element off the BookElement union.
const out = { ...e, frame: relFrame(e.frame) }
if (typeof out.size === 'number') out.size = relSize(out.size)
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW)
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW)
return out
const out = { ...e, frame: relFrame(e.frame) };
if (typeof out.size === 'number') out.size = relSize(out.size);
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW);
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW);
return out;
}
const spreads = doc.spreads
.map((s, i) => ({ index: i, role: s.role, background: s.background, elements: s.elements.map(element) }))
.filter(s => s.role === 'inner')
.filter((s) => s.role === 'inner');
const body = spreads.map((s, i) => ` {
const body = spreads
.map(
(s, i) => ` {
id: 'ref-${i + 1}',
background: ${JSON.stringify(s.background)},
elements: ${JSON.stringify(s.elements, null, 6).replace(/\n/g, '\n ')},
},`).join('\n')
},`,
)
.join('\n');
const file = `import type { BookElement } from '@trek/shared'
@@ -103,7 +107,7 @@ export interface SpreadTemplate {
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
${body}
]
`
`;
fs.writeFileSync(OUT, file)
console.log(`wrote ${spreads.length} templates to ${OUT}`)
fs.writeFileSync(OUT, file);
console.log(`wrote ${spreads.length} templates to ${OUT}`);
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -22,11 +22,12 @@
* scripts/lib/format.mjs at the repository root; Prettier and its config come
* from this workspace.
*/
import { fileURLToPath } from 'node:url';
import * as prettier from 'prettier';
import { check } from '../../scripts/lib/format.mjs';
import { runCli } from '../../scripts/lib/ratchet.mjs';
import { fileURLToPath } from 'node:url';
import * as prettier from 'prettier';
const root = fileURLToPath(new URL('..', import.meta.url));
// The entity files are written by scripts/generate-entities.ts and held byte for
// byte to its output by check:entities, so their layout is the generator's, not
+53 -23
View File
@@ -15,30 +15,60 @@
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
* with an empty permission list.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX, PLUGIN_API_VERSION,
} from '../src/nest/plugins/protocol/envelope';
import {
ACTIONS_MAX, ADDON_ID_RE, CAPABILITY_NAME_RE, EGRESS_HOST_RE, MCP_TOOLS_MAX, PLUGIN_CHANNEL_EVENTS, PLUGIN_ID_RE,
PLUGIN_SEMVER_RE, PLUGIN_TYPES, REPLACEABLE_TABS, RESERVED_PLUGIN_IDS, RESERVED_SETTING_KEYS, ROUTE_PROFILE_ID_RE,
ROUTE_PROFILES_MAX, SETTING_FIELD_KEYS, SETTING_KEY_RE, TOOL_NAME_RE, TRIP_PAGE_POSITION_MAX, WIDGET_SLOTS,
} from '../src/nest/plugins/protocol/manifest-rules';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
import {
PLUGIN_ENTITY_CONTRACT, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName,
type PluginMethodOutput,
} from '../src/nest/plugins/protocol/output-contract';
import { ADDON_IDS } from '../src/addons';
// A relative import of the shared SOURCE, not @trek/shared: the plugin-facts CI job
// installs only the server, so shared's dist does not exist there. The file has no
// imports of its own for exactly this reason.
import {
PLUGIN_POI_ICONS, PLUGIN_POI_LABEL_MAX, PLUGIN_POI_MAX_CATEGORIES,
PLUGIN_POI_ICONS,
PLUGIN_POI_LABEL_MAX,
PLUGIN_POI_MAX_CATEGORIES,
} from '../../shared/src/plugins/plugin-poi-facts';
import { ADDON_IDS } from '../src/addons';
import {
HOOK_PERMISSION,
KNOWN_METHODS,
KNOWN_PERMISSIONS,
METHOD_PERMISSION,
EVENTS_PERMISSION,
JOBS_PERMISSION,
USER_DATA_PERMISSION,
HTTP_OUTBOUND_PREFIX,
PLUGIN_API_VERSION,
} from '../src/nest/plugins/protocol/envelope';
import {
ACTIONS_MAX,
ADDON_ID_RE,
CAPABILITY_NAME_RE,
EGRESS_HOST_RE,
MCP_TOOLS_MAX,
PLUGIN_CHANNEL_EVENTS,
PLUGIN_ID_RE,
PLUGIN_SEMVER_RE,
PLUGIN_TYPES,
REPLACEABLE_TABS,
RESERVED_PLUGIN_IDS,
RESERVED_SETTING_KEYS,
ROUTE_PROFILE_ID_RE,
ROUTE_PROFILES_MAX,
SETTING_FIELD_KEYS,
SETTING_KEY_RE,
TOOL_NAME_RE,
TRIP_PAGE_POSITION_MAX,
WIDGET_SLOTS,
} from '../src/nest/plugins/protocol/manifest-rules';
import {
PLUGIN_ENTITY_CONTRACT,
PLUGIN_ENTITY_NESTED,
PLUGIN_METHOD_OUTPUT,
pluginEntityFields,
type PluginEntityName,
type PluginMethodOutput,
} from '../src/nest/plugins/protocol/output-contract';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -54,7 +84,9 @@ const HEADER = [
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
const pairs = (o: Readonly<Record<string, string>>) =>
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
Object.entries(o)
.map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`)
.join('\n');
/**
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
@@ -261,9 +293,7 @@ function coverageProblems(): string[] {
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
if (missingHint.length) {
problems.push(
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
);
problems.push(`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`);
}
return problems;
+6 -7
View File
@@ -25,13 +25,11 @@
* node --import tsx scripts/generate-db-types.ts # rewrite src/db/kysely/
* node --import tsx scripts/generate-db-types.ts --check # exit 1 when src/db/kysely/ drifted (CI)
*/
import fs from 'node:fs';
import path from 'node:path';
import { migrateTempDb, tempDbPath } from './generate-entities';
import Database from 'better-sqlite3';
import { migrateTempDb, tempDbPath } from './generate-entities';
import fs from 'node:fs';
import path from 'node:path';
const SERVER_ROOT = path.join(__dirname, '..');
export const KYSELY_DIR = path.join(SERVER_ROOT, 'src/db/kysely');
@@ -39,7 +37,8 @@ export const KYSELY_DIR = path.join(SERVER_ROOT, 'src/db/kysely');
/** Tables the application never queries: the migrator's own bookkeeping. */
const SKIPPED_TABLES: ReadonlySet<string> = new Set(['mikro_orm_migrations']);
const HEADER = '// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:\n' +
const HEADER =
'// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:\n' +
'// change the migration, then run `npm run gen:db-types`.\n';
export interface ColumnInfo {
@@ -151,7 +150,7 @@ export function renderDb(tables: readonly TableInfo[]): string {
const body = tables.map((t) => ` ${propertyKey(t.name)}: ${interfaceName(t.name)};`).join('\n');
return (
`${HEADER}${imports}\n\n` +
'/** Every table of the migrated schema. A repository narrows it: `this.kysely<Pick<DB, \'trips\'>>()`. */\n' +
"/** Every table of the migrated schema. A repository narrows it: `this.kysely<Pick<DB, 'trips'>>()`. */\n" +
`export interface DB {\n${body}\n}\n`
);
}
+27 -9
View File
@@ -68,15 +68,23 @@
*
* --dir=<path> points the check at another server root (the unit tests use it).
*/
import { createRequire } from 'node:module';
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const ts = require('typescript');
export const RULES = ['fileCycles', 'domainCycles', 'sharedImportsDomain', 'domainInternals', 'dbImportsNest', 'foreignRepositories', 'providersImportOrchestrator'];
export const RULES = [
'fileCycles',
'domainCycles',
'sharedImportsDomain',
'domainInternals',
'dbImportsNest',
'foreignRepositories',
'providersImportOrchestrator',
];
/**
* Folders under src/nest whose every file is shared infrastructure, open to all domains.
@@ -148,7 +156,9 @@ function isTypeOnlyImport(node) {
function isTypeOnlyExport(node) {
if (node.isTypeOnly) return true;
const clause = node.exportClause;
return Boolean(clause && ts.isNamedExports(clause) && clause.elements.length > 0 && clause.elements.every((el) => el.isTypeOnly));
return Boolean(
clause && ts.isNamedExports(clause) && clause.elements.length > 0 && clause.elements.every((el) => el.isTypeOnly),
);
}
/** Every relative import of a file: { spec, runtime } where runtime means eager and not erased. */
@@ -180,7 +190,8 @@ export function importsOf(text, fileName = 'file.ts') {
ts.forEachChild(node, (child) => visit(child, inner));
};
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt) && !ts.isImportEqualsDeclaration(stmt)) visit(stmt, 0);
if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt) && !ts.isImportEqualsDeclaration(stmt))
visit(stmt, 0);
}
return found;
}
@@ -412,7 +423,8 @@ export function analyse(edges) {
if (a && b && SHARED_DOMAINS.has(a) && !SHARED_DOMAINS.has(b)) sharedImportsDomain.add(`${e.from} -> ${e.to}`);
if (a && b && a !== b && !isPublic(e.to)) domainInternals.add(`${a} -> ${e.to.slice('nest/'.length)}`);
if (e.from.startsWith('db/') && e.to.startsWith('nest/')) dbImportsNest.add(`${e.from} -> ${e.to}`);
if (isProviderFile(e.from) && isOrchestratorOf(e.from, e.to)) providersImportOrchestrator.add(`${e.from} -> ${e.to}`);
if (isProviderFile(e.from) && isOrchestratorOf(e.from, e.to))
providersImportOrchestrator.add(`${e.from} -> ${e.to}`);
}
const sorted = (set) => [...set].sort();
return {
@@ -494,14 +506,17 @@ function main(argv) {
);
if (unowned.length) {
// An entity without an owner is an injection the rule cannot judge.
for (const u of unowned) console.error(`FAIL no owner for injected entity ${u}: add it to scripts/repository-owners.json`);
for (const u of unowned)
console.error(`FAIL no owner for injected entity ${u}: add it to scripts/repository-owners.json`);
return 1;
}
found.foreignRepositories = foreignRepositories;
let baseline = readBaseline(baselinePath);
if (update) {
const now = Object.fromEntries(RULES.map((rule) => [rule, new Set(found[rule])]));
baseline = Object.fromEntries(RULES.map((rule) => [rule, baseline[rule].filter((key) => now[rule].has(key)).sort()]));
baseline = Object.fromEntries(
RULES.map((rule) => [rule, baseline[rule].filter((key) => now[rule].has(key)).sort()]),
);
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
}
@@ -526,14 +541,17 @@ function main(argv) {
);
}
console.log(
`boundaries: ${edges.length} import(s); held at baseline: ` + RULES.map((rule) => `${rule} ${baseline[rule].length}`).join(', '),
`boundaries: ${edges.length} import(s); held at baseline: ` +
RULES.map((rule) => `${rule} ${baseline[rule].length}`).join(', '),
);
return failed ? 1 : 0;
}
// Compared by real path, so the check still runs when the script is started through a symlink.
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
+50 -27
View File
@@ -14,12 +14,11 @@
* The script will prompt for the old and new keys interactively so they never
* appear in shell history, process arguments, or log output.
*/
import Database from 'better-sqlite3';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import readline from 'readline';
import Database from 'better-sqlite3';
// ---------------------------------------------------------------------------
// Crypto helpers — mirrors apiKeyCrypto.ts and mfaCrypto.ts but with
@@ -143,9 +142,7 @@ async function main() {
console.log('A backup of the database will be created before any changes are made.\n');
// Resolve DB path
const dbPath = path.resolve(
process.env.DB_PATH ?? path.join(__dirname, '../data/travel.db')
);
const dbPath = path.resolve(process.env.DB_PATH ?? path.join(__dirname, '../data/travel.db'));
if (!fs.existsSync(dbPath)) {
console.error(`Database not found at: ${dbPath}`);
@@ -213,14 +210,18 @@ async function main() {
const wantedJournal = (process.env.TREK_DB_JOURNAL_MODE ?? '').trim().toUpperCase();
const journalMode = journalModes.includes(wantedJournal) ? wantedJournal : 'WAL';
if (wantedJournal && journalMode !== wantedJournal) {
console.warn(`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`);
console.warn(
`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`,
);
}
const wantedSync = (process.env.TREK_DB_SYNCHRONOUS ?? '').trim().toUpperCase();
const defaultSync = journalMode === 'WAL' ? 'NORMAL' : 'FULL';
const synchronous = syncLevels.includes(wantedSync) ? wantedSync : defaultSync;
if (wantedSync && synchronous !== wantedSync) {
console.warn(`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`);
console.warn(
`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`,
);
}
db.pragma(`journal_mode = ${journalMode}`);
@@ -257,7 +258,10 @@ async function main() {
// Helper: migrate a single MFA value (no prefix, raw base64)
function migrateMfaValue(raw: string, label: string): string | null {
if (!raw) { result.skipped++; return null; }
if (!raw) {
result.skipped++;
return null;
}
const plain = decryptMfa(raw, oldKey);
if (plain !== null) {
@@ -335,7 +339,10 @@ async function main() {
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
const row = db.prepare(`SELECT ${apiKeyColumns.join(', ')} FROM users WHERE id = ?`).get(user.id) as Record<string, string>;
const row = db.prepare(`SELECT ${apiKeyColumns.join(', ')} FROM users WHERE id = ?`).get(user.id) as Record<
string,
string
>;
for (const col of apiKeyColumns) {
if (!row[col]) continue;
@@ -346,7 +353,8 @@ async function main() {
}
// mfa_secret (mfa crypto)
const mfaRow = db.prepare('SELECT mfa_secret FROM users WHERE id = ? AND mfa_secret IS NOT NULL').get(user.id) as { mfa_secret: string } | undefined;
const mfaRow = db.prepare('SELECT mfa_secret FROM users WHERE id = ? AND mfa_secret IS NOT NULL').get(user.id) as
{ mfa_secret: string } | undefined;
if (mfaRow?.mfa_secret) {
const newVal = migrateMfaValue(mfaRow.mfa_secret, `users[${user.id}].mfa_secret`);
if (newVal !== null) {
@@ -373,8 +381,7 @@ async function main() {
};
{
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'storage.backends'").get() as
| { value: string }
| undefined;
{ value: string } | undefined;
if (row?.value) {
let backends: unknown;
try {
@@ -418,9 +425,11 @@ async function main() {
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'carto_api_key', 'llm_api_key'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
const settingRows = db
.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`,
)
.all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
for (const row of settingRows) {
if (!row.value) continue;
@@ -465,7 +474,7 @@ async function main() {
// Which keys are secret is per plugin and per scope, and it lives in the DB
// rather than in this script, so nothing has to be pinned by hand here.
const secretFields = db
.prepare("SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1")
.prepare('SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1')
.all() as { plugin_id: string; field_key: string; scope: string | null }[];
const secretKeysFor = (pluginId: string, scope: string): string[] =>
secretFields
@@ -500,7 +509,10 @@ async function main() {
};
if (tableExists('plugins')) {
const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as { id: string; config: string | null }[];
const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as {
id: string;
config: string | null;
}[];
for (const row of pluginRows) {
const next = migrateConfigJson(row.config, secretKeysFor(row.id, 'instance'), `plugins[${row.id}].config`);
if (next !== null) {
@@ -510,9 +522,11 @@ async function main() {
}
if (tableExists('plugin_user_config')) {
const userConfigRows = db
.prepare('SELECT plugin_id, user_id, config FROM plugin_user_config')
.all() as { plugin_id: string; user_id: number; config: string | null }[];
const userConfigRows = db.prepare('SELECT plugin_id, user_id, config FROM plugin_user_config').all() as {
plugin_id: string;
user_id: number;
config: string | null;
}[];
for (const row of userConfigRows) {
const next = migrateConfigJson(
row.config,
@@ -535,9 +549,10 @@ async function main() {
// (llm-parse/llm-config.ts). Left out, LLM parsing quietly runs without a
// key after the rotation, since a failed decrypt reads as "no key".
if (tableExists('addons')) {
const addonRows = db
.prepare('SELECT id, config FROM addons WHERE config IS NOT NULL')
.all() as { id: string; config: string }[];
const addonRows = db.prepare('SELECT id, config FROM addons WHERE config IS NOT NULL').all() as {
id: string;
config: string;
}[];
for (const row of addonRows) {
let config: unknown;
try {
@@ -559,7 +574,10 @@ async function main() {
}
// --- trip_album_links: passphrase ---
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as {
id: number;
passphrase: string;
}[];
for (const row of albumLinks) {
const newVal = migrateApiKeyValue(row.passphrase, `trip_album_links[${row.id}].passphrase`);
if (newVal !== null) {
@@ -584,7 +602,10 @@ async function main() {
}
// --- trek_photos: passphrase ---
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as {
id: number;
passphrase: string;
}[];
for (const row of photos) {
const newVal = migrateApiKeyValue(row.passphrase, `trek_photos[${row.id}].passphrase`);
if (newVal !== null) {
@@ -640,7 +661,7 @@ async function main() {
console.log(` Skipped (empty): ${result.skipped}`);
if (result.errors.length > 0) {
console.warn(` Errors: ${result.errors.length}`);
result.errors.forEach(e => console.warn(` - ${e}`));
result.errors.forEach((e) => console.warn(` - ${e}`));
console.warn('\nSome secrets could not be migrated. Check the errors above.');
console.warn(`Your original database is backed up at: ${backupPath}`);
process.exit(1);
@@ -654,7 +675,9 @@ async function main() {
fs.writeFileSync(keyFile, newKey, { mode: 0o600 });
console.log(`Key file updated: ${keyFile}`);
} else {
console.log('No key file next to the database. Store the new key wherever TREK reads it from (ENCRYPTION_KEY or data/.encryption_key).');
console.log(
'No key file next to the database. Store the new key wherever TREK reads it from (ENCRYPTION_KEY or data/.encryption_key).',
);
}
console.log(`Backup retained at: ${backupPath}`);
}
+55 -17
View File
@@ -50,17 +50,31 @@
* Exit codes: 0 passed, 1 a helper case or the ratchet failed, 2 the probe
* could not run (no database, a database that is not empty, a crash).
*/
import { planRepositories, readCompilerOptions, type MethodPlan } from './pg-probe/arg-samples';
import { compareWithBaseline, formatBaseline, lowerBaseline, readBaseline } from './pg-probe/baseline';
import {
connectProbeOrm,
connectSetupOrm,
postgresHelperEngine,
prepareDatabase,
probeRepositories,
sqliteHelperEngine,
} from './pg-probe/engines';
import { runHelperCases, type HelperResult } from './pg-probe/helper-cases';
import { StatementRecorder } from './pg-probe/recorder';
import {
formatConsole,
formatMarkdown,
passed,
probedNothing,
summarize,
type MethodResult,
type ReportInput,
} from './pg-probe/report';
import { appendFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { planRepositories, readCompilerOptions, type MethodPlan } from './pg-probe/arg-samples';
import { compareWithBaseline, formatBaseline, lowerBaseline, readBaseline } from './pg-probe/baseline';
import { connectProbeOrm, connectSetupOrm, postgresHelperEngine, prepareDatabase, probeRepositories, sqliteHelperEngine } from './pg-probe/engines';
import { runHelperCases, type HelperResult } from './pg-probe/helper-cases';
import { StatementRecorder } from './pg-probe/recorder';
import { formatConsole, formatMarkdown, passed, probedNothing, summarize, type MethodResult, type ReportInput } from './pg-probe/report';
const SERVER_ROOT = path.join(__dirname, '..');
export const BASELINE_PATH = path.join(SERVER_ROOT, 'scripts', 'pg-probe-baseline.json');
const REPOSITORIES_DIR = path.join(SERVER_ROOT, 'src', 'db', 'repositories');
@@ -87,7 +101,9 @@ export function parseArgs(argv: readonly string[], env: Readonly<Record<string,
summary: env.GITHUB_STEP_SUMMARY || null,
};
for (const arg of argv) {
const [flag, value] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, ''];
const [flag, value] = arg.includes('=')
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
: [arg, ''];
if (flag === '--update' && value === '') args.update = true;
else if (flag === '--url' && value) args.url = value;
else if (flag === '--next-baseline' && value) args.nextBaseline = value;
@@ -96,7 +112,9 @@ export function parseArgs(argv: readonly string[], env: Readonly<Record<string,
else throw new Error(`pg-probe: unknown argument ${arg}`);
}
if (!/^postgres(ql)?:\/\//.test(args.url)) {
throw new Error('pg-probe: no Postgres URL. Set TREK_PG_PROBE_URL or pass --url=postgres://user:password@host:port/database.');
throw new Error(
'pg-probe: no Postgres URL. Set TREK_PG_PROBE_URL or pass --url=postgres://user:password@host:port/database.',
);
}
return args;
}
@@ -147,7 +165,9 @@ async function main(): Promise<number> {
try {
const schema = await timed('derive the schema from the entities', () => prepareDatabase(setup));
schemaFailures = schema.failures;
console.log(` ${schema.statements} DDL statements, ${schema.adjusted} columns adjusted, ${schema.failures.length} refused`);
console.log(
` ${schema.statements} DDL statements, ${schema.adjusted} columns adjusted, ${schema.failures.length} refused`,
);
helpers = await timed('dialect helpers on SQLite and Postgres', async () => {
const sqlite = sqliteHelperEngine();
@@ -159,14 +179,20 @@ async function main(): Promise<number> {
});
const plans: MethodPlan[] = await timed('plan the repository calls', async () =>
planRepositories(repositoryFiles(REPOSITORIES_DIR), readCompilerOptions(path.join(SERVER_ROOT, 'tsconfig.json')), SERVER_ROOT),
planRepositories(
repositoryFiles(REPOSITORIES_DIR),
readCompilerOptions(path.join(SERVER_ROOT, 'tsconfig.json')),
SERVER_ROOT,
),
);
console.log(` ${plans.length} public repository methods`);
const recorder = new StatementRecorder();
const probe = await connectProbeOrm(args.url, recorder, STATEMENT_TIMEOUT_MS);
try {
results = await timed('call every repository method', () => probeRepositories(probe, recorder, plans, SERVER_ROOT, METHOD_TIMEOUT_MS));
results = await timed('call every repository method', () =>
probeRepositories(probe, recorder, plans, SERVER_ROOT, METHOD_TIMEOUT_MS),
);
} finally {
await probe.close(true);
}
@@ -201,7 +227,10 @@ async function main(): Promise<number> {
annotate('error', `pg-probe helper case [${failure.engine}] ${failure.name}: ${failure.failure}`);
}
for (const entry of verdict.grown) {
annotate('error', `pg-probe: ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`);
annotate(
'error',
`pg-probe: ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`,
);
}
for (const entry of verdict.stale) {
annotate(
@@ -218,9 +247,13 @@ async function main(): Promise<number> {
);
}
for (const method of verdict.nowCovered) {
annotate('error', `pg-probe: ${method} is listed as uncovered but is measured now (or gone). Drop it (--update, or the pg-probe-baseline artifact).`);
annotate(
'error',
`pg-probe: ${method} is listed as uncovered but is measured now (or gone). Drop it (--update, or the pg-probe-baseline artifact).`,
);
}
if (probedNothing(summary)) annotate('error', `pg-probe: no repository method was called (${summary.methods} planned).`);
if (probedNothing(summary))
annotate('error', `pg-probe: no repository method was called (${summary.methods} planned).`);
if (verdict.unseeded) {
annotate(
'error',
@@ -236,12 +269,17 @@ if (require.main === module) {
// A method abandoned after its timeout may still reject later; that is
// already counted as a timeout and must not take the whole run down.
process.on('unhandledRejection', (reason: unknown) => {
annotate('warning', `pg-probe: a rejection nobody awaited: ${reason instanceof Error ? reason.message.split('\n')[0] : String(reason)}`);
annotate(
'warning',
`pg-probe: a rejection nobody awaited: ${reason instanceof Error ? reason.message.split('\n')[0] : String(reason)}`,
);
});
main().then(
(code) => process.exit(code),
(error: unknown) => {
console.error(`pg-probe could not run: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
console.error(
`pg-probe could not run: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(2);
},
);
+21 -6
View File
@@ -14,9 +14,7 @@
* method unprobeable: the report lists it instead of guessing, and the
* baseline's `uncovered` list must name it, so a new one fails the run.
*/
import path from 'node:path';
import ts from 'typescript';
export type Sample = { ok: true; value: unknown } | { ok: false; reason: string };
@@ -118,7 +116,13 @@ function sampleObjectType(checker: ts.TypeChecker, type: ts.ObjectType, name: st
return sampleObject(checker, type, name, depth);
}
function sampleCollection(checker: ts.TypeChecker, type: ts.ObjectType, kind: string, name: string, depth: number): Sample {
function sampleCollection(
checker: ts.TypeChecker,
type: ts.ObjectType,
kind: string,
name: string,
depth: number,
): Sample {
const args = checker.getTypeArguments(type as ts.TypeReference);
const samples = args.map((arg, i) => sampleFor(checker, arg, i === 0 ? singular(name) : name, depth + 1));
const failed = samples.find((sample) => !sample.ok);
@@ -152,7 +156,10 @@ function isPublicMethod(member: ts.ClassElement): member is ts.MethodDeclaration
}
/** The sample argument list for one method, or why there is none. */
export function planMethod(checker: ts.TypeChecker, method: ts.MethodDeclaration): { args: unknown[] } | { unprobeable: string } {
export function planMethod(
checker: ts.TypeChecker,
method: ts.MethodDeclaration,
): { args: unknown[] } | { unprobeable: string } {
const args: unknown[] = [];
for (const parameter of method.parameters) {
const name = ts.isIdentifier(parameter.name) ? parameter.name.text : 'options';
@@ -169,7 +176,11 @@ export function planMethod(checker: ts.TypeChecker, method: ts.MethodDeclaration
}
/** Every public method of every exported class declared in `files`, with its sample arguments. */
export function planRepositories(files: readonly string[], compilerOptions: ts.CompilerOptions, root: string): MethodPlan[] {
export function planRepositories(
files: readonly string[],
compilerOptions: ts.CompilerOptions,
root: string,
): MethodPlan[] {
const program = ts.createProgram({ rootNames: [...files], options: { ...compilerOptions, noEmit: true } });
const checker = program.getTypeChecker();
const wanted = new Set(files.map((file) => path.resolve(file)));
@@ -186,7 +197,11 @@ export function planRepositories(files: readonly string[], compilerOptions: ts.C
if (!isPublicMethod(member)) continue;
const method = (member.name as ts.Identifier).text;
const plan = planMethod(checker, member);
plans.push('args' in plan ? { className, file, method, args: plan.args } : { className, file, method, unprobeable: plan.unprobeable });
plans.push(
'args' in plan
? { className, file, method, args: plan.args }
: { className, file, method, unprobeable: plan.unprobeable },
);
}
}
}
+4 -3
View File
@@ -31,7 +31,6 @@
* measurement, written by `--update` or taken from the CI artifact, is
* committed.
*/
import { readFileSync } from 'node:fs';
export interface ProbeBaseline {
@@ -104,7 +103,8 @@ function parseFailing(failing: unknown, source: string): Record<string, number>
/** A missing `uncovered` key reads as unmeasured, so a baseline written before the list existed gets it seeded. */
function parseUncovered(uncovered: unknown, source: string): string[] | null {
if (uncovered === undefined || uncovered === null) return null;
if (!Array.isArray(uncovered)) throw new Error(`${source}: "uncovered" must be null or an array of Class.method names`);
if (!Array.isArray(uncovered))
throw new Error(`${source}: "uncovered" must be null or an array of Class.method names`);
const seen = new Set<string>();
for (const method of uncovered as unknown[]) {
if (typeof method !== 'string' || !METHOD_KEY.test(method)) {
@@ -169,7 +169,8 @@ export function lowerBaseline(
if (now > 0) next[method] = now;
}
}
const uncovered = baseline.uncovered === null ? [...uncoveredNow] : baseline.uncovered.filter((method) => uncoveredNow.has(method));
const uncovered =
baseline.uncovered === null ? [...uncoveredNow] : baseline.uncovered.filter((method) => uncoveredNow.has(method));
return {
failing: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))),
uncovered: [...new Set(uncovered)].sort((a, b) => a.localeCompare(b)),
+61 -19
View File
@@ -5,21 +5,34 @@
* SQLite database the helper cases are compared on, and the loop that calls
* every repository method.
*/
import path from 'node:path';
import { RequestContext, type Configuration, type EntityManager, type EntityProperty } from '@mikro-orm/core';
import { MikroORM, PostgreSqlConnection, PostgreSqlDriver, type Options } from '@mikro-orm/postgresql';
import { SqlitePlatform } from '@mikro-orm/sql';
import Database from 'better-sqlite3';
import { DummyDriver, Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, type PostgresDialect } from 'kysely';
import { ALL_ENTITIES } from '../../src/db/entities';
import type { MethodPlan } from './arg-samples';
import { VALUES_TABLE, VALUES_TABLE_DDL, type HelperEngine, type KyselySelect, type RawSelect, type ValuesDB, type ValuesRow } from './helper-cases';
import {
VALUES_TABLE,
VALUES_TABLE_DDL,
type HelperEngine,
type KyselySelect,
type RawSelect,
type ValuesDB,
type ValuesRow,
} from './helper-cases';
import { ProbePostgresDialect, type StatementRecorder } from './recorder';
import type { MethodResult } from './report';
import { adjustColumns, NOCASE_COLLATION_SQL, splitStatements, type ColumnMeta } from './schema';
import { RequestContext, type Configuration, type EntityManager, type EntityProperty } from '@mikro-orm/core';
import { MikroORM, PostgreSqlConnection, PostgreSqlDriver, type Options } from '@mikro-orm/postgresql';
import { SqlitePlatform } from '@mikro-orm/sql';
import Database from 'better-sqlite3';
import {
DummyDriver,
Kysely,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
type PostgresDialect,
} from 'kysely';
import path from 'node:path';
/** Marks a database the probe built, so a rerun may drop what it finds there and nothing else. */
const MARKER_TABLE = 'trek_pg_probe_marker';
@@ -42,9 +55,14 @@ export function connectSetupOrm(url: string): Promise<MikroORM> {
}
/** A Postgres driver whose Kysely dialect records every statement (see recorder.ts). */
export function createProbeDriverClass(recorder: StatementRecorder, statementTimeoutMs: number): typeof PostgreSqlDriver {
export function createProbeDriverClass(
recorder: StatementRecorder,
statementTimeoutMs: number,
): typeof PostgreSqlDriver {
class ProbeConnection extends PostgreSqlConnection {
override createKyselyDialect(overrides: Parameters<PostgreSqlConnection['createKyselyDialect']>[0]): PostgresDialect {
override createKyselyDialect(
overrides: Parameters<PostgreSqlConnection['createKyselyDialect']>[0],
): PostgresDialect {
return new ProbePostgresDialect(super.createKyselyDialect(overrides), recorder, statementTimeoutMs);
}
}
@@ -58,7 +76,11 @@ export function createProbeDriverClass(recorder: StatementRecorder, statementTim
};
}
export function connectProbeOrm(url: string, recorder: StatementRecorder, statementTimeoutMs: number): Promise<MikroORM> {
export function connectProbeOrm(
url: string,
recorder: StatementRecorder,
statementTimeoutMs: number,
): Promise<MikroORM> {
return MikroORM.init({ ...baseOptions(url), driver: createProbeDriverClass(recorder, statementTimeoutMs) });
}
@@ -74,7 +96,9 @@ async function resetDatabase(orm: MikroORM): Promise<void> {
);
const names = tables.map((row) => row.table_name);
if (names.length > 0 && !names.includes(MARKER_TABLE)) {
throw new Error(`refusing to run: the database holds ${names.length} table(s) the probe did not create. Point it at an empty database.`);
throw new Error(
`refusing to run: the database holds ${names.length} table(s) the probe did not create. Point it at an empty database.`,
);
}
if (names.length > 0) {
await connection.execute('drop schema public cascade');
@@ -89,7 +113,9 @@ export interface SchemaFailure {
}
/** Resets the database and builds the probe schema; returns the DDL statements Postgres refused. */
export async function prepareDatabase(orm: MikroORM): Promise<{ failures: SchemaFailure[]; adjusted: number; statements: number }> {
export async function prepareDatabase(
orm: MikroORM,
): Promise<{ failures: SchemaFailure[]; adjusted: number; statements: number }> {
await resetDatabase(orm);
const connection = orm.em.getConnection();
const failures: SchemaFailure[] = [];
@@ -128,7 +154,10 @@ export function postgresHelperEngine(orm: MikroORM): HelperEngine {
},
async selectKysely(select: KyselySelect) {
const platform = orm.em.getPlatform();
const row = await kysely.selectFrom(VALUES_TABLE).select((eb) => select(platform, eb).as('v')).executeTakeFirst();
const row = await kysely
.selectFrom(VALUES_TABLE)
.select((eb) => select(platform, eb).as('v'))
.executeTakeFirst();
return row?.v;
},
};
@@ -153,7 +182,13 @@ export function sqliteHelperEngine(): HelperEngine & { close(): void } {
platform,
async writeRow(row: ValuesRow) {
db.prepare(`delete from ${VALUES_TABLE}`).run();
db.prepare(`insert into ${VALUES_TABLE} (id, a, b, n, x) values (?, ?, ?, ?, ?)`).run(row.id, row.a, row.b, row.n, row.x);
db.prepare(`insert into ${VALUES_TABLE} (id, a, b, n, x) values (?, ?, ?, ?, ?)`).run(
row.id,
row.a,
row.b,
row.n,
row.x,
);
},
async selectRaw(select: RawSelect) {
const sql = `select ${select.sql}${select.column ? '' : ' as v'} from ${VALUES_TABLE}`;
@@ -161,7 +196,10 @@ export function sqliteHelperEngine(): HelperEngine & { close(): void } {
return row?.[select.column ?? 'v'];
},
async selectKysely(select: KyselySelect) {
const compiled = compiler.selectFrom(VALUES_TABLE).select((eb) => select(platform, eb).as('v')).compile();
const compiled = compiler
.selectFrom(VALUES_TABLE)
.select((eb) => select(platform, eb).as('v'))
.compile();
const row = db.prepare(compiled.sql).get(...compiled.parameters.map(bind)) as Record<string, unknown> | undefined;
return row?.v;
},
@@ -172,7 +210,11 @@ export function sqliteHelperEngine(): HelperEngine & { close(): void } {
type RepositoryFactory = (em: EntityManager) => object;
/** How to reach each repository class: through its entity, or `new X(em)` for the two that have none. */
function repositoryFactories(orm: MikroORM, plans: readonly MethodPlan[], serverRoot: string): Map<string, RepositoryFactory | string> {
function repositoryFactories(
orm: MikroORM,
plans: readonly MethodPlan[],
serverRoot: string,
): Map<string, RepositoryFactory | string> {
const byEntity = new Map<string, RepositoryFactory>();
for (const meta of orm.getMetadata().getAll().values()) {
const repository = meta.repository?.();
+203 -48
View File
@@ -15,10 +15,15 @@
* Unlike the repository statements, nothing here is baselined: every case
* must pass on both engines.
*/
import type { Platform } from '@mikro-orm/core';
import type { AliasableExpression, ExpressionBuilder } from 'kysely';
import {
castIntegerKysely,
concatKysely,
currentTimestampKysely,
nowPlusSecondsKysely,
startsWithIsoDateKysely,
substringKysely,
unixEpochToIsoKysely,
} from '../../src/db/dialect/kysely-functions';
import {
absDifference,
caseWhenEquals,
@@ -53,15 +58,9 @@ import {
substring,
trim,
} from '../../src/db/dialect/sql-functions';
import {
castIntegerKysely,
concatKysely,
currentTimestampKysely,
nowPlusSecondsKysely,
startsWithIsoDateKysely,
substringKysely,
unixEpochToIsoKysely,
} from '../../src/db/dialect/kysely-functions';
import type { Platform } from '@mikro-orm/core';
import type { AliasableExpression, ExpressionBuilder } from 'kysely';
export type Engine = 'sqlite' | 'postgres';
@@ -92,7 +91,10 @@ export interface RawSelect {
column?: string;
}
export type KyselySelect = (platform: Platform, eb: ExpressionBuilder<ValuesDB, 'trek_pg_probe_values'>) => AliasableExpression<unknown>;
export type KyselySelect = (
platform: Platform,
eb: ExpressionBuilder<ValuesDB, 'trek_pg_probe_values'>,
) => AliasableExpression<unknown>;
export type Expectation =
| { equals: string | number | boolean | null }
@@ -118,7 +120,10 @@ const rawOf = (fragment: { sql: string; params: readonly unknown[] }, column?: s
});
/** `(<key> = <value>)` for the helpers used as a filter key. */
const comparison = (key: { sql: string; params: readonly unknown[] }, value: { sql: string; params: readonly unknown[] }): RawSelect => ({
const comparison = (
key: { sql: string; params: readonly unknown[] },
value: { sql: string; params: readonly unknown[] },
): RawSelect => ({
sql: `(${key.sql} = ${value.sql})`,
params: [...key.params, ...value.params],
});
@@ -133,9 +138,24 @@ export function isoSeconds(ms: number): string {
const EPOCH_MS = 1_758_459_909_123;
export const HELPER_CASES: readonly HelperCase[] = [
{ name: 'dateOf', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateOf(p, 'a')), expect: { equals: '2026-09-21' } },
{ name: 'dateAdd forward', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateAdd(p, 'a', 10)), expect: { equals: '2026-10-01' } },
{ name: 'dateAdd back', row: { a: '2026-09-21 13:05:09' }, raw: (p) => rawOf(dateAdd(p, 'a', -10)), expect: { equals: '2026-09-11' } },
{
name: 'dateOf',
row: { a: '2026-09-21 13:05:09' },
raw: (p) => rawOf(dateOf(p, 'a')),
expect: { equals: '2026-09-21' },
},
{
name: 'dateAdd forward',
row: { a: '2026-09-21 13:05:09' },
raw: (p) => rawOf(dateAdd(p, 'a', 10)),
expect: { equals: '2026-10-01' },
},
{
name: 'dateAdd back',
row: { a: '2026-09-21 13:05:09' },
raw: (p) => rawOf(dateAdd(p, 'a', -10)),
expect: { equals: '2026-09-11' },
},
{ name: 'currentTimestamp', row: {}, raw: (p) => rawOf(currentTimestamp(p)), expect: { timestamp: 0 } },
{ name: 'nowMinusDays', row: {}, raw: (p) => rawOf(nowMinusDays(p, 30)), expect: { timestamp: -30 * DAY_MS } },
{ name: 'nowMinusHours', row: {}, raw: (p) => rawOf(nowMinusHours(p, 20)), expect: { timestamp: -20 * 3_600_000 } },
@@ -143,28 +163,98 @@ export const HELPER_CASES: readonly HelperCase[] = [
{ name: 'nowDateOffset back', row: {}, raw: (p) => rawOf(nowDateOffset(p, -400)), expect: { date: -400 } },
{ name: 'nowDateOffset forward', row: {}, raw: (p) => rawOf(nowDateOffset(p, 1)), expect: { date: 1 } },
{ name: 'columnRef', row: { n: 7 }, raw: (p) => rawOf(columnRef(p, 'n')), expect: { equals: 7 } },
{ name: 'columnIncrementedBy', row: { n: 7 }, raw: (p) => rawOf(columnIncrementedBy(p, 'n', -2)), expect: { equals: 5 } },
{
name: 'columnIncrementedBy',
row: { n: 7 },
raw: (p) => rawOf(columnIncrementedBy(p, 'n', -2)),
expect: { equals: 5 },
},
{ name: 'lower', row: { a: 'Probe@X.com' }, raw: (p) => rawOf(lower(p, 'a')), expect: { equals: 'probe@x.com' } },
{ name: 'lower = lowerParam', row: { a: 'Probe@X.com' }, raw: (p) => comparison(lower(p, 'a'), lowerParam(p, 'PROBE@x.COM')), expect: { equals: true } },
{
name: 'lower = lowerParam',
row: { a: 'Probe@X.com' },
raw: (p) => comparison(lower(p, 'a'), lowerParam(p, 'PROBE@x.COM')),
expect: { equals: true },
},
{ name: 'trim', row: { a: ' probe ' }, raw: (p) => rawOf(trim(p, 'a')), expect: { equals: 'probe' } },
{ name: 'lowerTrim', row: { a: ' AbC ' }, raw: (p) => rawOf(lowerTrim(p, 'a')), expect: { equals: 'abc' } },
{ name: 'lowerTrim = lowerTrimParam', row: { a: ' AbC ' }, raw: (p) => comparison(lowerTrim(p, 'a'), lowerTrimParam(p, ' aBc')), expect: { equals: true } },
{ name: 'coalesce', row: { a: null, b: 'fallback' }, raw: (p) => rawOf(coalesce(p, 'a', 'b')), expect: { equals: 'fallback' } },
{ name: 'coalesceParam keeps the column', row: { a: 'stored' }, raw: (p) => rawOf(coalesceParam(p, 'a', 'new')), expect: { equals: 'stored' } },
{ name: 'coalesceParam fills a null', row: { a: null }, raw: (p) => rawOf(coalesceParam(p, 'a', 'new')), expect: { equals: 'new' } },
{ name: 'coalesceOverride prefers the value', row: { a: 'stored' }, raw: (p) => rawOf(coalesceOverride(p, 'new', 'a')), expect: { equals: 'new' } },
{ name: 'coalesceOverride keeps on null', row: { a: 'stored' }, raw: (p) => rawOf(coalesceOverride(p, null, 'a')), expect: { equals: 'stored' } },
{
name: 'lowerTrim = lowerTrimParam',
row: { a: ' AbC ' },
raw: (p) => comparison(lowerTrim(p, 'a'), lowerTrimParam(p, ' aBc')),
expect: { equals: true },
},
{
name: 'coalesce',
row: { a: null, b: 'fallback' },
raw: (p) => rawOf(coalesce(p, 'a', 'b')),
expect: { equals: 'fallback' },
},
{
name: 'coalesceParam keeps the column',
row: { a: 'stored' },
raw: (p) => rawOf(coalesceParam(p, 'a', 'new')),
expect: { equals: 'stored' },
},
{
name: 'coalesceParam fills a null',
row: { a: null },
raw: (p) => rawOf(coalesceParam(p, 'a', 'new')),
expect: { equals: 'new' },
},
{
name: 'coalesceOverride prefers the value',
row: { a: 'stored' },
raw: (p) => rawOf(coalesceOverride(p, 'new', 'a')),
expect: { equals: 'new' },
},
{
name: 'coalesceOverride keeps on null',
row: { a: 'stored' },
raw: (p) => rawOf(coalesceOverride(p, null, 'a')),
expect: { equals: 'stored' },
},
{ name: 'absDifference', row: { x: 4 }, raw: (p) => rawOf(absDifference(p, 'x', 6.5)), expect: { equals: 2.5 } },
{ name: 'countAll', row: {}, raw: (p) => rawOf(countAll(p, 'c'), 'c'), expect: { equals: 1 } },
{ name: 'countAllRef', row: {}, raw: (p) => rawOf(countAllRef(p)), expect: { equals: 1 } },
{ name: 'minOf', row: { n: 3 }, raw: (p) => rawOf(minOf(p, 'n', 'lo'), 'lo'), expect: { equals: 3 } },
{ name: 'maxOf', row: { n: 3 }, raw: (p) => rawOf(maxOf(p, 'n', 'hi'), 'hi'), expect: { equals: 3 } },
{ name: 'caseWhenEquals', row: { n: 7 }, raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')), expect: { equals: 'owner' } },
{ name: 'caseWhenEquals else', row: { n: 8 }, raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')), expect: { equals: 'member' } },
{ name: 'startsWithIsoDate yes', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(startsWithIsoDate(p, 'a')), expect: { equals: true } },
{ name: 'startsWithIsoDate no', row: { a: 'abcd-ef-gh' }, raw: (p) => rawOf(startsWithIsoDate(p, 'a')), expect: { equals: false } },
{ name: 'substring with length', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(substring(p, 'a', 1, 10)), expect: { equals: '2026-09-21' } },
{ name: 'substring to the end', row: { a: '2026-09-21T10:00' }, raw: (p) => rawOf(substring(p, 'a', 12)), expect: { equals: '10:00' } },
{
name: 'caseWhenEquals',
row: { n: 7 },
raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')),
expect: { equals: 'owner' },
},
{
name: 'caseWhenEquals else',
row: { n: 8 },
raw: (p) => rawOf(caseWhenEquals(p, 'n', 7, 'owner', 'member')),
expect: { equals: 'member' },
},
{
name: 'startsWithIsoDate yes',
row: { a: '2026-09-21T10:00' },
raw: (p) => rawOf(startsWithIsoDate(p, 'a')),
expect: { equals: true },
},
{
name: 'startsWithIsoDate no',
row: { a: 'abcd-ef-gh' },
raw: (p) => rawOf(startsWithIsoDate(p, 'a')),
expect: { equals: false },
},
{
name: 'substring with length',
row: { a: '2026-09-21T10:00' },
raw: (p) => rawOf(substring(p, 'a', 1, 10)),
expect: { equals: '2026-09-21' },
},
{
name: 'substring to the end',
row: { a: '2026-09-21T10:00' },
raw: (p) => rawOf(substring(p, 'a', 12)),
expect: { equals: '10:00' },
},
{
name: 'concat',
row: { a: '2026-09-21', b: '10:00' },
@@ -173,15 +263,30 @@ export const HELPER_CASES: readonly HelperCase[] = [
},
{ name: 'castInteger of 14.0', row: { a: '14.0' }, raw: (p) => rawOf(castInteger(p, 'a')), expect: { equals: 14 } },
{ name: 'castInteger of 14', row: { a: '14' }, raw: (p) => rawOf(castInteger(p, 'a')), expect: { equals: 14 } },
{ name: 'dayDistance', row: { a: '2026-09-21' }, raw: (p) => rawOf(dayDistance(p, 'a', '2026-09-24')), expect: { equals: 3 } },
{ name: 'collateNoCase', row: { a: 'Probe' }, raw: (p) => comparison(collateNoCase(p, 'a'), bound('pROBE')), expect: { equals: true } },
{
name: 'dayDistance',
row: { a: '2026-09-21' },
raw: (p) => rawOf(dayDistance(p, 'a', '2026-09-24')),
expect: { equals: 3 },
},
{
name: 'collateNoCase',
row: { a: 'Probe' },
raw: (p) => comparison(collateNoCase(p, 'a'), bound('pROBE')),
expect: { equals: true },
},
{
name: 'foundAgainState recovers',
row: { a: 'remote_missing', b: 'file-1' },
raw: (p) => rawOf(foundAgainState(p, 'a', 'b')),
expect: { equals: 'synced' },
},
{ name: 'foundAgainState passes through', row: { a: 'error', b: 'file-1' }, raw: (p) => rawOf(foundAgainState(p, 'a', 'b')), expect: { equals: 'error' } },
{
name: 'foundAgainState passes through',
row: { a: 'error', b: 'file-1' },
raw: (p) => rawOf(foundAgainState(p, 'a', 'b')),
expect: { equals: 'error' },
},
{
name: 'coalesceOverrideWhileSame keeps for the same key',
row: { a: 'https://a', n: 1 },
@@ -194,24 +299,61 @@ export const HELPER_CASES: readonly HelperCase[] = [
raw: (p) => rawOf(coalesceOverrideWhileSame(p, null, 'n', 'a', 'https://b')),
expect: { equals: 0 },
},
{ name: 'caseWhenNotNull', row: { a: 'x', b: 'route' }, raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')), expect: { equals: 'route' } },
{ name: 'caseWhenNotNull null', row: { a: null, b: 'route' }, raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')), expect: { equals: null } },
{
name: 'caseWhenNotNull',
row: { a: 'x', b: 'route' },
raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')),
expect: { equals: 'route' },
},
{
name: 'caseWhenNotNull null',
row: { a: null, b: 'route' },
raw: (p) => rawOf(caseWhenNotNull(p, 'a', 'b')),
expect: { equals: null },
},
{
name: 'startsWithIsoDateKysely',
row: { a: '2026-09-21 10:00:00' },
kysely: (p, eb) => eb.case().when(startsWithIsoDateKysely(p, eb, 'a')).then('yes').else('no').end(),
kysely: (p, eb) =>
eb
.case()
.when(startsWithIsoDateKysely(p, eb, 'a'))
.then('yes')
.else('no')
.end(),
expect: { equals: 'yes' },
},
{ name: 'substringKysely', row: { a: '2026-09-21T10:00' }, kysely: (p, eb) => substringKysely(p, eb, 'a', 1, 10), expect: { equals: '2026-09-21' } },
{
name: 'substringKysely',
row: { a: '2026-09-21T10:00' },
kysely: (p, eb) => substringKysely(p, eb, 'a', 1, 10),
expect: { equals: '2026-09-21' },
},
{
name: 'concatKysely',
row: { a: '2026-09-21', b: 'xx10:00' },
kysely: (p, eb) => concatKysely(p, eb, { column: 'a' }, { value: 'T' }, { expression: substringKysely(p, eb, 'b', 3) }),
kysely: (p, eb) =>
concatKysely(p, eb, { column: 'a' }, { value: 'T' }, { expression: substringKysely(p, eb, 'b', 3) }),
expect: { equals: '2026-09-21T10:00' },
},
{ name: 'castIntegerKysely', row: { a: '14.0' }, kysely: (p, eb) => castIntegerKysely(p, eb, 'a'), expect: { equals: 14 } },
{ name: 'unixEpochToIsoKysely', row: { n: EPOCH_MS }, kysely: (p, eb) => unixEpochToIsoKysely(p, eb, 'n'), expect: { equals: isoSeconds(EPOCH_MS) } },
{ name: 'nowPlusSecondsKysely', row: {}, kysely: (p, eb) => nowPlusSecondsKysely(p, eb, 45), expect: { timestamp: 45_000 } },
{
name: 'castIntegerKysely',
row: { a: '14.0' },
kysely: (p, eb) => castIntegerKysely(p, eb, 'a'),
expect: { equals: 14 },
},
{
name: 'unixEpochToIsoKysely',
row: { n: EPOCH_MS },
kysely: (p, eb) => unixEpochToIsoKysely(p, eb, 'n'),
expect: { equals: isoSeconds(EPOCH_MS) },
},
{
name: 'nowPlusSecondsKysely',
row: {},
kysely: (p, eb) => nowPlusSecondsKysely(p, eb, 45),
expect: { timestamp: 45_000 },
},
{ name: 'currentTimestampKysely', row: {}, kysely: (p) => currentTimestampKysely(p), expect: { timestamp: 0 } },
];
@@ -222,14 +364,17 @@ const DATE_TEXT = /^\d{4}-\d{2}-\d{2}$/;
export function checkValue(expectation: Expectation, value: unknown, engine: Engine, now: number): string | null {
if ('perEngine' in expectation) return checkValue(expectation.perEngine[engine], value, engine, now);
if ('timestamp' in expectation) {
if (typeof value !== 'string' || !TIMESTAMP_TEXT.test(value)) return `expected timestamp text, got ${JSON.stringify(value)}`;
if (typeof value !== 'string' || !TIMESTAMP_TEXT.test(value))
return `expected timestamp text, got ${JSON.stringify(value)}`;
const off = Date.parse(`${value.replace(' ', 'T')}Z`) - (now + expectation.timestamp);
return Math.abs(off) <= CLOCK_TOLERANCE_MS ? null : `${value} is ${Math.round(off / 1000)}s off`;
}
if ('date' in expectation) {
if (typeof value !== 'string' || !DATE_TEXT.test(value)) return `expected date text, got ${JSON.stringify(value)}`;
const wanted = Date.parse(new Date(now + expectation.date * DAY_MS).toISOString().slice(0, 10));
return Math.abs(Date.parse(value) - wanted) <= DAY_MS ? null : `${value} is not ${expectation.date} day(s) from today`;
return Math.abs(Date.parse(value) - wanted) <= DAY_MS
? null
: `${value} is not ${expectation.date} day(s) from today`;
}
const expected = expectation.equals;
if (expected === null) return value === null ? null : `expected null, got ${JSON.stringify(value)}`;
@@ -239,8 +384,15 @@ export function checkValue(expectation: Expectation, value: unknown, engine: Eng
return (expected ? truthy : falsy) ? null : `expected ${expected}, got ${JSON.stringify(value)}`;
}
if (typeof expected === 'number') {
const n = typeof value === 'bigint' ? Number(value) : typeof value === 'string' || typeof value === 'number' ? Number(value) : Number.NaN;
return Math.abs(n - expected) < 1e-9 ? null : `expected ${expected}, got ${JSON.stringify(typeof value === 'bigint' ? String(value) : value)}`;
const n =
typeof value === 'bigint'
? Number(value)
: typeof value === 'string' || typeof value === 'number'
? Number(value)
: Number.NaN;
return Math.abs(n - expected) < 1e-9
? null
: `expected ${expected}, got ${JSON.stringify(typeof value === 'bigint' ? String(value) : value)}`;
}
return value === expected ? null : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`;
}
@@ -260,7 +412,10 @@ export interface HelperResult {
failure: string | null;
}
export async function runHelperCases(engine: HelperEngine, cases: readonly HelperCase[] = HELPER_CASES): Promise<HelperResult[]> {
export async function runHelperCases(
engine: HelperEngine,
cases: readonly HelperCase[] = HELPER_CASES,
): Promise<HelperResult[]> {
const results: HelperResult[] = [];
for (const helperCase of cases) {
let failure: string | null;
+7 -5
View File
@@ -21,9 +21,6 @@
* AsyncLocalStorage, so a straggler from a timed-out method can never be
* counted against the next one.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import {
CompiledQuery,
type DatabaseConnection,
@@ -35,6 +32,7 @@ import {
type QueryCompiler,
type QueryResult,
} from 'kysely';
import { AsyncLocalStorage } from 'node:async_hooks';
export type StatementOutcome = { ok: true } | { ok: false; code: string; message: string };
@@ -44,7 +42,8 @@ export interface StatementRecord {
}
/** Transaction control a probed method sends; each statement already runs in its own rolled-back transaction. */
const TRANSACTION_CONTROL = /^\s*(begin|commit|rollback|end|abort|start\s+transaction|savepoint|release(\s+savepoint)?)\b/i;
const TRANSACTION_CONTROL =
/^\s*(begin|commit|rollback|end|abort|start\s+transaction|savepoint|release(\s+savepoint)?)\b/i;
export function isTransactionControl(sql: string): boolean {
return TRANSACTION_CONTROL.test(sql);
@@ -114,7 +113,10 @@ export class ProbeConnection implements DatabaseConnection {
this.recorder.record(key, { sql: compiled.sql, outcome: { ok: true } });
return result;
} catch (error) {
this.recorder.record(key, { sql: compiled.sql, outcome: { ok: false, code: sqlState(error), message: errorLine(error) } });
this.recorder.record(key, {
sql: compiled.sql,
outcome: { ok: false, code: sqlState(error), message: errorLine(error) },
});
return { rows: [] };
} finally {
await this.inner.executeQuery(CompiledQuery.raw('rollback'));
+64 -21
View File
@@ -2,7 +2,6 @@
* Turns what the Postgres probe saw into counts, a console log and the
* Markdown the CI job writes to its step summary.
*/
import { countsTowardRatchet, type BaselineVerdict } from './baseline';
import type { HelperResult } from './helper-cases';
import type { StatementRecord } from './recorder';
@@ -77,7 +76,10 @@ export function summarize(results: readonly MethodResult[]): ProbeSummary {
summary.timedOut += 1;
summary.uncovered.set(result.key, 'timed out');
} else if (result.statements.length === 0) {
summary.uncovered.set(result.key, result.threw === undefined ? 'sent no SQL' : `threw before any statement: ${result.threw}`);
summary.uncovered.set(
result.key,
result.threw === undefined ? 'sent no SQL' : `threw before any statement: ${result.threw}`,
);
}
if (result.statements.length === 0) summary.withoutSql += 1;
const seen = new Set<string>();
@@ -93,7 +95,13 @@ export function summarize(results: readonly MethodResult[]): ProbeSummary {
if (!('code' in outcome)) continue;
const dialect = countsTowardRatchet(outcome.code);
if (dialect) counted += 1;
summary.failedStatements.push({ method: result.key, code: outcome.code, message: outcome.message, sql: statement.sql, counted: dialect });
summary.failedStatements.push({
method: result.key,
code: outcome.code,
message: outcome.message,
sql: statement.sql,
counted: dialect,
});
summary.failuresByCode.set(outcome.code, (summary.failuresByCode.get(outcome.code) ?? 0) + 1);
}
summary.failingByMethod.set(result.key, counted);
@@ -102,7 +110,10 @@ export function summarize(results: readonly MethodResult[]): ProbeSummary {
}
/** Refused statements the ratchet holds (dialect errors) and the ones it only reports. */
export function splitRefusals(summary: Pick<ProbeSummary, 'failedStatements'>): { counted: FailedStatement[]; reported: FailedStatement[] } {
export function splitRefusals(summary: Pick<ProbeSummary, 'failedStatements'>): {
counted: FailedStatement[];
reported: FailedStatement[];
} {
return {
counted: summary.failedStatements.filter((failed) => failed.counted),
reported: summary.failedStatements.filter((failed) => !failed.counted),
@@ -163,11 +174,16 @@ export function formatConsole(input: ReportInput): string {
const { summary, verdict } = input;
const lines: string[] = [];
const failures = helperFailures(input.helpers);
lines.push(`Dialect helpers: ${input.helpers.length - failures.length}/${input.helpers.length} cases pass on SQLite and Postgres.`);
lines.push(
`Dialect helpers: ${input.helpers.length - failures.length}/${input.helpers.length} cases pass on SQLite and Postgres.`,
);
for (const failure of failures) lines.push(` FAIL [${failure.engine}] ${failure.name}: ${failure.failure}`);
if (input.schemaFailures.length > 0) {
lines.push(`Schema: ${input.schemaFailures.length} DDL statement(s) Postgres refused (their tables are missing below):`);
for (const failure of input.schemaFailures) lines.push(` ${failure.message} <- ${oneLine(failure.statement, 120)}`);
lines.push(
`Schema: ${input.schemaFailures.length} DDL statement(s) Postgres refused (their tables are missing below):`,
);
for (const failure of input.schemaFailures)
lines.push(` ${failure.message} <- ${oneLine(failure.statement, 120)}`);
}
lines.push(
`Repositories: ${summary.methods} methods, ${summary.called} called, ${summary.unprobeable} unprobeable, ` +
@@ -187,10 +203,13 @@ export function formatConsole(input: ReportInput): string {
}
if (summary.uncovered.size > 0) {
lines.push(`Unmeasured: ${summary.uncovered.size} method(s) whose SQL the probe did not see:`);
for (const [method, why] of [...summary.uncovered.entries()].sort(([a], [b]) => a.localeCompare(b))) lines.push(` ${method}: ${why}`);
for (const [method, why] of [...summary.uncovered.entries()].sort(([a], [b]) => a.localeCompare(b)))
lines.push(` ${method}: ${why}`);
}
if (probedNothing(summary)) {
lines.push(`FAIL No repository method was called (${summary.methods} planned); there is nothing to hold to the baseline.`);
lines.push(
`FAIL No repository method was called (${summary.methods} planned); there is nothing to hold to the baseline.`,
);
}
if (verdict.unseeded) {
lines.push(
@@ -202,7 +221,9 @@ export function formatConsole(input: ReportInput): string {
lines.push(`FAIL ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`);
}
for (const entry of verdict.stale) {
lines.push(`FAIL ${entry.method} is held at ${entry.allowed} failing statement(s) but fails ${entry.now} now; lower the baseline.`);
lines.push(
`FAIL ${entry.method} is held at ${entry.allowed} failing statement(s) but fails ${entry.now} now; lower the baseline.`,
);
}
for (const method of verdict.newlyUncovered) {
lines.push(
@@ -223,16 +244,21 @@ export function formatMarkdown(input: ReportInput): string {
const out: string[] = ['## Postgres probe', ''];
out.push(passed(input) ? 'Result: **passed**' : 'Result: **failed**', '');
out.push('| | |', '|---|---|');
out.push(`| Dialect helper cases (SQLite and Postgres) | ${input.helpers.length - failures.length} of ${input.helpers.length} pass |`);
out.push(
`| Dialect helper cases (SQLite and Postgres) | ${input.helpers.length - failures.length} of ${input.helpers.length} pass |`,
);
out.push(`| Schema statements refused | ${input.schemaFailures.length} |`);
const refusals = splitRefusals(summary);
out.push(`| Repository methods | ${summary.methods} (${summary.called} called, ${summary.unprobeable} unprobeable) |`);
out.push(
`| Repository methods | ${summary.methods} (${summary.called} called, ${summary.unprobeable} unprobeable) |`,
);
out.push(`| Methods whose SQL the probe did not see | ${summary.uncovered.size} |`);
out.push(`| Distinct statements | ${summary.statements} |`);
out.push(`| Refused by Postgres as SQL (held) | ${refusals.counted.length} |`);
out.push(`| Refused over a sample value or the empty tables (reported only) | ${refusals.reported.length} |`);
out.push('');
if (probedNothing(summary)) out.push('No repository method was called, so there is nothing to hold to the baseline.', '');
if (probedNothing(summary))
out.push('No repository method was called, so there is nothing to hold to the baseline.', '');
if (verdict.unseeded) {
out.push(
'The baseline has not been measured yet, which fails the run. Commit `pg-probe-baseline.json` from the `pg-probe-baseline` artifact as `server/scripts/pg-probe-baseline.json`.',
@@ -241,33 +267,50 @@ export function formatMarkdown(input: ReportInput): string {
}
if (verdict.grown.length > 0 || verdict.stale.length > 0) {
out.push('### Ratchet', '', '| Method | Allowed | Now |', '|---|---|---|');
for (const entry of [...verdict.grown, ...verdict.stale]) out.push(`| ${cell(entry.method)} | ${entry.allowed} | ${entry.now} |`);
for (const entry of [...verdict.grown, ...verdict.stale])
out.push(`| ${cell(entry.method)} | ${entry.allowed} | ${entry.now} |`);
out.push('');
}
if (verdict.newlyUncovered.length > 0 || verdict.nowCovered.length > 0) {
out.push('### Coverage ratchet', '', '| Method | Baseline | Now |', '|---|---|---|');
for (const method of verdict.newlyUncovered) out.push(`| ${cell(method)} | measured | ${cell(summary.uncovered.get(method) ?? 'unmeasured')} |`);
for (const method of verdict.newlyUncovered)
out.push(`| ${cell(method)} | measured | ${cell(summary.uncovered.get(method) ?? 'unmeasured')} |`);
for (const method of verdict.nowCovered) out.push(`| ${cell(method)} | uncovered | measured or gone |`);
out.push('');
}
if (failures.length > 0) {
out.push('### Helper cases that failed', '', '| Engine | Case | Why |', '|---|---|---|');
for (const failure of failures) out.push(`| ${failure.engine} | ${cell(failure.name)} | ${cell(failure.failure ?? '')} |`);
for (const failure of failures)
out.push(`| ${failure.engine} | ${cell(failure.name)} | ${cell(failure.failure ?? '')} |`);
out.push('');
}
if (summary.failuresByCode.size > 0) {
out.push('### Refused statements by SQLSTATE', '', '| SQLSTATE | Statements |', '|---|---|');
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1])) out.push(`| ${code} | ${count} |`);
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1]))
out.push(`| ${code} | ${count} |`);
out.push('');
out.push('<details><summary>Every refused statement</summary>', '', '| Method | SQLSTATE | Held | Message |', '|---|---|---|---|');
out.push(
'<details><summary>Every refused statement</summary>',
'',
'| Method | SQLSTATE | Held | Message |',
'|---|---|---|---|',
);
for (const failed of summary.failedStatements) {
out.push(`| ${cell(failed.method)} | ${failed.code} | ${failed.counted ? 'yes' : 'no'} | ${cell(failed.message)} |`);
out.push(
`| ${cell(failed.method)} | ${failed.code} | ${failed.counted ? 'yes' : 'no'} | ${cell(failed.message)} |`,
);
}
out.push('', '</details>', '');
}
if (summary.uncovered.size > 0) {
out.push('<details><summary>Methods whose SQL the probe did not see</summary>', '', '| Method | Why |', '|---|---|');
for (const [method, why] of [...summary.uncovered.entries()].sort(([a], [b]) => a.localeCompare(b))) out.push(`| ${cell(method)} | ${cell(why)} |`);
out.push(
'<details><summary>Methods whose SQL the probe did not see</summary>',
'',
'| Method | Why |',
'|---|---|',
);
for (const [method, why] of [...summary.uncovered.entries()].sort(([a], [b]) => a.localeCompare(b)))
out.push(`| ${cell(method)} | ${cell(why)} |`);
out.push('', '</details>', '');
}
return `${out.join('\n')}\n`;
+6 -3
View File
@@ -19,7 +19,6 @@
*
* Unique and partial unique indexes stay, because `ON CONFLICT` needs them.
*/
import { pgTimestampText, PG_UTC_NOW } from '../../src/db/dialect/platform';
/** SQLite's timestamp text on Postgres, for a column default. */
@@ -31,9 +30,13 @@ export const NOCASE_COLLATION_SQL =
/** The Postgres spelling of a column default the entities carry in SQLite's. */
export function portableDefault(raw: string): string {
const text = raw.trim().replace(/^\((.*)\)$/s, '$1').trim();
const text = raw
.trim()
.replace(/^\((.*)\)$/s, '$1')
.trim();
if (/^current_timestamp$/i.test(text) || /^datetime\(\s*'now'\s*\)$/i.test(text)) return PG_NOW_TEXT_DEFAULT;
if (/^strftime\(\s*'%s'\s*,\s*'now'\s*\)$/i.test(text)) return 'CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS bigint)';
if (/^strftime\(\s*'%s'\s*,\s*'now'\s*\)$/i.test(text))
return 'CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS bigint)';
return raw;
}
+6 -2
View File
@@ -138,13 +138,17 @@ function main(argv) {
console.error('Run npm run lint:query-api -- --update to lower the baseline with the change that made it smaller.');
}
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
console.log(`query-api: ${total} QueryBuilder call(s) in ${Object.keys(counts).length} file(s) held at their baseline`);
console.log(
`query-api: ${total} QueryBuilder call(s) in ${Object.keys(counts).length} file(s) held at their baseline`,
);
return grown.length || stale.length ? 1 : 0;
}
// Compared by real path, so the check still runs when the script is started through a symlink.
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
+5 -3
View File
@@ -33,8 +33,6 @@
* Walking the tree and handling the baseline live in scripts/lib/ratchet.mjs
* at the repository root.
*/
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
listFiles,
lowerCounts,
@@ -47,6 +45,9 @@ import {
writeBaseline,
} from '../../scripts/lib/ratchet.mjs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** The most lines a source file without a baseline entry may hold. */
const LIMIT = 1000;
@@ -112,7 +113,8 @@ const sizeEntries = (limit) => (value) => {
return 'it must be an object of file paths to line counts';
}
for (const [file, n] of Object.entries(value)) {
if (!Number.isInteger(n) || n <= limit) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${limit}`;
if (!Number.isInteger(n) || n <= limit)
return `${file} holds ${JSON.stringify(n)}, expected an integer above ${limit}`;
}
return null;
};
+15 -10
View File
@@ -24,11 +24,6 @@
* use both). The counting lives in scripts/lib/tsc-errors.mjs, the baseline
* handling in scripts/lib/ratchet.mjs at the repository root.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
countMap,
lowerCounts,
@@ -40,15 +35,25 @@ import {
} from '../../scripts/lib/ratchet.mjs';
import { countErrors } from './lib/tsc-errors.mjs';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** tsc over tsconfig.strict.json, as its output and exit status. */
function runTsc(serverDir) {
const tsc = createRequire(join(serverDir, 'package.json')).resolve('typescript/bin/tsc');
try {
const output = execFileSync(process.execPath, [tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'], {
cwd: serverDir,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
});
const output = execFileSync(
process.execPath,
[tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'],
{
cwd: serverDir,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
},
);
return { output, status: 0 };
} catch (err) {
if (typeof err.status !== 'number') throw err;
+2 -1
View File
@@ -24,9 +24,10 @@
* npm run lint:test-new-service check against the baseline (CI)
* npm run lint:test-new-service -- --update lower the counts; never raises or adds an entry
*/
import { cliMain, stripComments } from './lib/count-ratchet.mjs';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { cliMain, stripComments } from './lib/count-ratchet.mjs';
const CLASS_DECL = /\bclass\s+([A-Za-z_$][\w$]*)(?:\s*<[^{]*?>)?(?:\s+extends\s+([A-Za-z_$][\w$.]*))?[^{]*\{/g;
+3 -1
View File
@@ -225,7 +225,9 @@ function main(argv) {
// Compared by real path, so the check still runs when the script is started through a symlink.
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
+37 -11
View File
@@ -266,26 +266,41 @@ export function analyse(classes) {
return true;
}
const first = call.arguments[0];
return Boolean(first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) && RAW_WRITE.test(first.text));
return Boolean(
first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) && RAW_WRITE.test(first.text),
);
}
/** Write units of `node`, read inside `cls`. `covered` is true inside a transactional callback. */
function count(cls, node, covered) {
if (!node) return 0;
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return 0;
if (ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node)) return statements(cls, node.statements, 0, covered);
if (ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node))
return statements(cls, node.statements, 0, covered);
if (ts.isIfStatement(node)) {
return count(cls, node.expression, covered) + Math.max(count(cls, node.thenStatement, covered), count(cls, node.elseStatement, covered));
return (
count(cls, node.expression, covered) +
Math.max(count(cls, node.thenStatement, covered), count(cls, node.elseStatement, covered))
);
}
if (ts.isConditionalExpression(node)) {
return count(cls, node.condition, covered) + Math.max(count(cls, node.whenTrue, covered), count(cls, node.whenFalse, covered));
return (
count(cls, node.condition, covered) +
Math.max(count(cls, node.whenTrue, covered), count(cls, node.whenFalse, covered))
);
}
if (ts.isSwitchStatement(node)) {
let max = 0;
for (const clause of node.caseBlock.clauses) max = Math.max(max, statements(cls, clause.statements, 0, covered));
return count(cls, node.expression, covered) + max;
}
if (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
if (
ts.isForStatement(node) ||
ts.isForOfStatement(node) ||
ts.isForInStatement(node) ||
ts.isWhileStatement(node) ||
ts.isDoStatement(node)
) {
let head = 0;
if (ts.isForStatement(node)) head = count(cls, node.initializer, covered);
else if (!ts.isDoStatement(node) && !ts.isWhileStatement(node)) head = count(cls, node.expression, covered);
@@ -325,7 +340,9 @@ export function analyse(classes) {
function call(cls, node, covered) {
const callee = node.expression;
const method = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
const receiver = ts.isPropertyAccessExpression(callee) ? count(cls, callee.expression, covered) : count(cls, callee, covered);
const receiver = ts.isPropertyAccessExpression(callee)
? count(cls, callee.expression, covered)
: count(cls, callee, covered);
if (method === 'transactional') {
let inner = 0;
frames.push([]); // what runs inside is one unit, not a list of its own
@@ -381,7 +398,7 @@ export function analyse(classes) {
// transactional callback or comes from a method that is bound itself. Grown from
// nothing to a fixed point, so a cycle of methods calling each other binds none.
const bound = new Set();
for (let grew = true; grew; ) {
for (let grew = true; grew;) {
grew = false;
for (const [key, list] of callSites) {
if (bound.has(key) || list.length === 0) continue;
@@ -404,7 +421,10 @@ export function analyse(classes) {
explained[`${cls.file}#${key}`] = sites.get(key);
}
}
return { found: Object.fromEntries(Object.entries(reported).sort(([a], [b]) => a.localeCompare(b))), sites: explained };
return {
found: Object.fromEntries(Object.entries(reported).sort(([a], [b]) => a.localeCompare(b))),
sites: explained,
};
}
/** The baseline as committed. Missing or malformed stops the run instead of reading as empty. */
@@ -420,7 +440,9 @@ export function readBaseline(path) {
}
for (const [key, n] of Object.entries(parsed)) {
if (!Number.isInteger(n) || n < 2) {
throw new Error(`scripts/tx-writes-baseline.json: ${key} holds ${JSON.stringify(n)}, expected an integer of at least 2`);
throw new Error(
`scripts/tx-writes-baseline.json: ${key} holds ${JSON.stringify(n)}, expected an integer of at least 2`,
);
}
}
return parsed;
@@ -479,12 +501,16 @@ function main(argv) {
if (stale.length) {
console.error('Run npm run lint:tx -- --update to lower the baseline with the change that fixed it.');
}
console.log(`tx: ${Object.keys(found).length} method(s) with writes outside one transaction, ${Object.keys(baseline).length} held at their baseline`);
console.log(
`tx: ${Object.keys(found).length} method(s) with writes outside one transaction, ${Object.keys(baseline).length} held at their baseline`,
);
return grown.length || stale.length ? 1 : 0;
}
const isCli =
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
process.exitCode = main(process.argv.slice(2));
+1 -1
View File
@@ -15,7 +15,7 @@ export const ADDON_IDS = {
TOURS: 'tours',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
export type AddonId = (typeof ADDON_IDS)[keyof typeof ADDON_IDS];
/**
* The addons that gate an MCP surface, which is what decides whether switching
+2 -1
View File
@@ -1,6 +1,7 @@
import path from 'node:path';
import { readEnv, type AppEnv } from './env';
import path from 'node:path';
/**
* `<server>/`, the directory holding package.json. `src/app-config` and
* `dist/app-config` both sit two levels below it, so the anchor is the same
+3 -3
View File
@@ -14,7 +14,7 @@
* call, which is what keeps the runtime env-mutation semantics the test suite
* depends on. Zod validation runs once at boot (env.schema.ts), never here.
*/
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import { imageVersion } from './image-version';
import {
csvList,
csvListFiltered,
@@ -29,7 +29,7 @@ import {
resolveSessionTtlMs,
stripTrailingSlashes,
} from './parsers';
import { imageVersion } from './image-version';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
export type RawEnv = Record<string, string | undefined>;
@@ -358,7 +358,7 @@ export function deriveNet(raw: RawEnv) {
https: (raw.HTTPS_PROXY ?? raw.https_proxy)?.trim() || undefined,
noProxy: ((raw.NO_PROXY ?? raw.no_proxy) || '')
.split(',')
.map(entry => entry.trim().toLowerCase())
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
},
};
+4 -6
View File
@@ -9,8 +9,6 @@
* listed here pass through untouched (Zod strips unknown keys, it does not
* reject them).
*/
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import {
isP256PrivateKey,
isUncompressedP256Key,
@@ -18,6 +16,9 @@ import {
parseDurationMs,
parseLinkLocalAllowList,
} from './parsers';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import { z } from 'zod';
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
function optionalWith(test: (v: string) => boolean, message: string) {
@@ -49,10 +50,7 @@ const url = optionalWith((v) => {
return false;
}
}, 'must be a valid URL (with protocol)');
const duration = optionalWith(
(v) => parseDurationMs(v) != null,
'must be a duration like "1h", "7d" or "30d"',
);
const duration = optionalWith((v) => parseDurationMs(v) != null, 'must be a duration like "1h", "7d" or "30d"');
const oneOf = (values: string[]) =>
optionalWith((v) => values.includes(v.toLowerCase()), `must be one of: ${values.join(', ')}`);
+4 -1
View File
@@ -199,7 +199,10 @@ export function synchronousName(level: unknown): string {
export function parseLinkLocalAllowList(raw: string | undefined): { ips: string[]; invalid: string[] } {
const ips: string[] = [];
const invalid: string[] = [];
for (const entry of (raw ?? '').split(',').map((e) => e.trim()).filter(Boolean)) {
for (const entry of (raw ?? '')
.split(',')
.map((e) => e.trim())
.filter(Boolean)) {
const m = /^169\.254\.(\d{1,3})\.(\d{1,3})$/.exec(entry);
const canonical = m !== null && [m[1], m[2]].every((o) => String(Number(o)) === o && Number(o) <= 255);
if (canonical && m[1] !== '169' && m[1] !== '170') ips.push(entry);
+3 -3
View File
@@ -1,12 +1,14 @@
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
import { readEnv } from './app-config';
import { nestLogLevels } from './app-config/nest-log-levels';
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
import { httpConfig } from './nest/app-config';
import { AppModule } from './nest/app.module';
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { validateBodyContracts } from './nest/common/validate-body-contracts';
import { validateManagedRoutes } from './nest/common/validate-managed-routes';
import { validateRouteGuards } from './nest/common/validate-route-guards';
import { DatabaseLifecycle } from './nest/database/database-lifecycle.service';
import { withRequestContext } from './nest/database/request-context';
import { setupApiDocs } from './nest/platform/api-docs';
import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware';
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
@@ -14,8 +16,6 @@ import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
import { SettingsService } from './nest/settings/settings.service';
import { StorageService } from './nest/storage/storage.service';
import { MikroORM } from '@mikro-orm/core';
import { withRequestContext } from './nest/database/request-context';
import { DatabaseLifecycle } from './nest/database/database-lifecycle.service';
import type { INestApplication } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
+12 -5
View File
@@ -1,8 +1,9 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { readEnv } from './app-config';
import { resolveDataPaths } from './app-config/data-paths';
import crypto from 'node:crypto';
import fs from 'node:fs';
const { dataDir, jwtSecretFile, encryptionKeyFile: encKeyFile } = resolveDataPaths();
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
@@ -45,8 +46,12 @@ function persistEnvKey(envKey: string): void {
}
if (storedKey && storedKey !== envKey.trim()) {
console.error(`FATAL: ENCRYPTION_KEY does not match ${encKeyFile}.`);
console.error('The stored secrets are encrypted with the key in that file, so TREK will not start with a different one.');
console.error('To change the key, run scripts/migrate-encryption.ts first; it re-encrypts the secrets and updates the file.');
console.error(
'The stored secrets are encrypted with the key in that file, so TREK will not start with a different one.',
);
console.error(
'To change the key, run scripts/migrate-encryption.ts first; it re-encrypts the secrets and updates the file.',
);
console.error('Otherwise set ENCRYPTION_KEY back to the value in the file, or remove the variable.');
process.exit(1);
}
@@ -79,7 +84,9 @@ if (_encryptionKey) {
if (code === 'EISDIR') {
// Compose creating a missing bind-mount source as a directory is by far
// the most common way this file stops being a file.
console.error('EISDIR: the path is a directory. A bind mount pointed at data/.encryption_key created it as one — remove it and mount the data directory instead.');
console.error(
'EISDIR: the path is a directory. A bind mount pointed at data/.encryption_key created it as one — remove it and mount the data directory instead.',
);
}
console.error('Fix the file permissions or set ENCRYPTION_KEY explicitly.');
process.exit(1);
+26 -9
View File
@@ -1,6 +1,25 @@
import {
isKnownPlatform,
isPostgres,
isSqlite,
PG_ISO_DATE_PREFIX,
PG_TIMESTAMP_FORMAT,
PG_UTC_NOW,
pgTimestampText,
unsupported,
} from './platform';
import type { Platform } from '@mikro-orm/core';
import { sql, type Expression, type ExpressionBuilder, type ExpressionWrapper, type RawBuilder, type ReferenceExpression, type SqlBool, type StringReference } from 'kysely';
import { isKnownPlatform, isPostgres, isSqlite, PG_ISO_DATE_PREFIX, PG_TIMESTAMP_FORMAT, PG_UTC_NOW, pgTimestampText, unsupported } from './platform';
import {
sql,
type Expression,
type ExpressionBuilder,
type ExpressionWrapper,
type RawBuilder,
type ReferenceExpression,
type SqlBool,
type StringReference,
} from 'kysely';
/**
* The Kysely-expression half of the dialect layer: the twins of the MikroORM
@@ -83,9 +102,8 @@ export function substringKysely<DB, TB extends keyof DB>(
throw new Error(`sql-functions: substringKysely needs a non-negative integer length, got ${length}`);
}
if (isKnownPlatform(platform)) {
const args: ReferenceExpression<DB, TB>[] = length === undefined
? [ref, eb.val(start)]
: [ref, eb.val(start), eb.val(length)];
const args: ReferenceExpression<DB, TB>[] =
length === undefined ? [ref, eb.val(start)] : [ref, eb.val(start), eb.val(length)];
return eb.fn<string>('substr', args);
}
return unsupported(platform);
@@ -93,9 +111,7 @@ export function substringKysely<DB, TB extends keyof DB>(
/** A part of a `concatKysely()` expression — a column reference, a bound value, or a nested Kysely `Expression<string>` (composes freely, unlike `concat()`'s MikroORM `RawQueryFragment` form). */
export type KyselyConcatPart<DB, TB extends keyof DB> =
| { column: StringReference<DB, TB> }
| { value: string }
| { expression: Expression<string> };
{ column: StringReference<DB, TB> } | { value: string } | { expression: Expression<string> };
/**
* The Kysely-expression twin of {@link concat}: `<part> || <part> || …`,
@@ -116,7 +132,8 @@ export function concatKysely<DB, TB extends keyof DB>(
throw new Error(`sql-functions: concatKysely needs at least two parts, got ${parts.length}`);
}
if (!isKnownPlatform(platform)) return unsupported(platform);
const value = (text: string): Expression<string> => (isPostgres(platform) ? eb.cast<string>(eb.val(text), 'text') : eb.val(text));
const value = (text: string): Expression<string> =>
isPostgres(platform) ? eb.cast<string>(eb.val(text), 'text') : eb.val(text);
const operand = (part: KyselyConcatPart<DB, TB>): Expression<string> =>
'column' in part ? eb.ref(part.column).$castTo<string>() : 'value' in part ? value(part.value) : part.expression;
let acc: Expression<string> = operand(parts[0]!);
+44 -9
View File
@@ -1,5 +1,14 @@
import {
isKnownPlatform,
isPostgres,
isSqlite,
PG_ISO_DATE_PREFIX,
PG_UTC_NOW,
pgDateText,
pgTimestampText,
unsupported,
} from './platform';
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
import { isKnownPlatform, isPostgres, isSqlite, PG_ISO_DATE_PREFIX, PG_UTC_NOW, pgDateText, pgTimestampText, unsupported } from './platform';
/**
* The only place a repository may spell a database function.
@@ -232,7 +241,11 @@ export function coalesce(platform: Platform, ref: string, fallbackRef: string):
* anywhere a plain `RawQueryFragment` is expected, a strict widening with no
* behaviour change (the function body is untouched).
*/
export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol {
export function coalesceParam(
platform: Platform,
ref: string,
value: string | number | null,
): RawQueryFragment & symbol {
if (isKnownPlatform(platform)) return raw(`COALESCE(${column(ref)}, ?)`, [value]);
return unsupported(platform);
}
@@ -256,7 +269,11 @@ export function coalesceParam(platform: Platform, ref: string, value: string | n
* columns exactly as they were before, since `coalesceParam`'s `COALESCE(col,
* ?)` always preferred the non-null existing value).
*/
export function coalesceOverride(platform: Platform, value: string | number | null, ref: string): RawQueryFragment & symbol {
export function coalesceOverride(
platform: Platform,
value: string | number | null,
ref: string,
): RawQueryFragment & symbol {
if (isKnownPlatform(platform)) return raw(`COALESCE(?, ${column(ref)})`, [value]);
return unsupported(platform);
}
@@ -334,8 +351,15 @@ export function maxOf(platform: Platform, ref: string, aliasName: string): RawQu
* time regardless of this function's own escaping; callers must guard a
* non-finite `value` before calling.)
*/
export function caseWhenEquals(platform: Platform, ref: string, value: number, whenTrue: string, whenFalse: string): RawQueryFragment {
if (isKnownPlatform(platform)) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]);
export function caseWhenEquals(
platform: Platform,
ref: string,
value: number,
whenTrue: string,
whenFalse: string,
): RawQueryFragment {
if (isKnownPlatform(platform))
return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]);
return unsupported(platform);
}
@@ -537,7 +561,9 @@ export function castInteger(platform: Platform, ref: string): RawQueryFragment &
export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol {
if (isSqlite(platform)) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]);
if (isPostgres(platform)) {
return raw(`ABS(EXTRACT(EPOCH FROM (CAST(${column(ref)} AS timestamp) - CAST(? AS timestamp))) / 86400)`, [isoDate]);
return raw(`ABS(EXTRACT(EPOCH FROM (CAST(${column(ref)} AS timestamp) - CAST(? AS timestamp))) / 86400)`, [
isoDate,
]);
}
return unsupported(platform);
}
@@ -779,9 +805,19 @@ export function foundAgainState(platform: Platform, stateRef: string, fileIdRef:
* the fragment has two value slots and `raw()` binds positionally. SQLite's
* null-safe `IS` is `IS NOT DISTINCT FROM` on Postgres.
*/
export function coalesceOverrideWhileSame(platform: Platform, value: number | null, ref: string, keyRef: string, keyValue: string): RawQueryFragment {
export function coalesceOverrideWhileSame(
platform: Platform,
value: number | null,
ref: string,
keyRef: string,
keyValue: string,
): RawQueryFragment {
if (isSqlite(platform)) {
return raw(`CASE WHEN ${column(keyRef)} IS ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`, [keyValue, value, value]);
return raw(`CASE WHEN ${column(keyRef)} IS ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`, [
keyValue,
value,
value,
]);
}
if (isPostgres(platform)) {
const sql = `CASE WHEN ${column(keyRef)} IS NOT DISTINCT FROM ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`;
@@ -790,7 +826,6 @@ export function coalesceOverrideWhileSame(platform: Platform, value: number | nu
return unsupported(platform);
}
// ---------------------------------------------------------------------------
// Tours (#2586): `DayAssignmentsRepository`'s assignment projection carries a
// place's route geometry only when the place is a Tour, so the day list never
-1
View File
@@ -1,4 +1,3 @@
/**
* The five document providers and the fields their connection form asks for.
*
+3 -2
View File
@@ -1,7 +1,8 @@
import type Database from 'better-sqlite3';
import { readEnv } from '../app-config';
import { synchronousName } from '../app-config/parsers';
import type Database from 'better-sqlite3';
export interface ActiveDurability {
journalMode: string;
synchronous: string;
@@ -24,7 +25,7 @@ export interface ActiveDurability {
*/
export function applyDurabilityPragmas(db: Database.Database): ActiveDurability {
const { journalMode, synchronous, durabilityWarnings } = readEnv().db;
durabilityWarnings.forEach(warning => console.warn(`[DB] ${warning}`));
durabilityWarnings.forEach((warning) => console.warn(`[DB] ${warning}`));
db.exec(`PRAGMA journal_mode = ${journalMode}`);
db.exec(`PRAGMA synchronous = ${synchronous}`);
+9 -2
View File
@@ -31,8 +31,15 @@ export async function ensureEmailCaseIndex(connection: Connection): Promise<void
}
await connection.transactional(async (trx) => {
await connection.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email))', [], 'run', trx);
await connection.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email))',
[],
'run',
trx,
);
await connection.execute('DELETE FROM app_settings WHERE key = ?', [PENDING_KEY], 'run', trx);
});
console.log('[DB] Every account has its own email address now; created the case-insensitive unique index on users.email');
console.log(
'[DB] Every account has its own email address now; created the case-insensitive unique index on users.email',
);
}
+4 -2
View File
@@ -1,8 +1,8 @@
import { knownMigrationNames, unknownMigrations } from './known-migrations';
import type { Connection, MigrationInfo } from '@mikro-orm/core';
import type { Migrator } from '@mikro-orm/migrations';
import fs from 'node:fs';
import { knownMigrationNames, unknownMigrations } from './known-migrations';
/**
* Upgrading an install the retired hand-written runner migrated.
@@ -175,7 +175,9 @@ async function withoutToursSteps(connection: Connection, version: number): Promi
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tour_types'`,
);
if (table.length === 0) return version;
console.log(`[DB] schema_version ${version} was written by the pre-ORM Tours branch; baselining to upstream step ${toursLine}`);
console.log(
`[DB] schema_version ${version} was written by the pre-ORM Tours branch; baselining to upstream step ${toursLine}`,
);
return toursLine;
}
@@ -36,6 +36,8 @@ export class Migration20200101040200_trek_photo_cache_meta_cache_key_not_null ex
this.addSql(`ALTER TABLE trek_photo_cache_meta_new RENAME TO trek_photo_cache_meta`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`,
);
}
}
@@ -1,6 +1,6 @@
import { reseatBookedNights } from '../reseat-booked-nights';
import { Migration } from '@mikro-orm/migrations';
import type { SqliteConnection } from '@mikro-orm/sqlite';
import { reseatBookedNights } from '../reseat-booked-nights';
/**
* Legacy migration step 242 (`db/migrations.ts`).
@@ -1,5 +1,5 @@
import { Migration } from '@mikro-orm/migrations';
import { addColumnIfMissing, tableExists } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Tours: a facet on `places` for hikes and other single-day routes.
@@ -54,10 +54,16 @@ export class Migration20200101042200_an_index_on_every_foreign_key extends Migra
this.addSql('CREATE INDEX IF NOT EXISTS idx_collab_message_reactions_user_id ON collab_message_reactions(user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_invite_tokens_trip_id ON invite_tokens(trip_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON invite_tokens(created_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_category_assignees_user_id ON packing_category_assignees(user_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_packing_category_assignees_user_id ON packing_category_assignees(user_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_templates_created_by ON packing_templates(created_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_template_categories_template_id ON packing_template_categories(template_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_template_items_category_id ON packing_template_items(category_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_packing_template_categories_template_id ON packing_template_categories(template_id)',
);
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_packing_template_items_category_id ON packing_template_items(category_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_bags_user_id ON packing_bags(user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_bags_trip_id ON packing_bags(trip_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_bucket_list_user_id ON bucket_list(user_id)');
@@ -84,22 +90,38 @@ export class Migration20200101042200_an_index_on_every_foreign_key extends Migra
this.addSql('CREATE INDEX IF NOT EXISTS idx_day_accommodations_place_id ON day_accommodations(place_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo_id ON journey_photos(photo_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_user_id ON budget_item_payers(user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_budget_settlements_created_by_user_id ON budget_settlements(created_by_user_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_budget_settlements_created_by_user_id ON budget_settlements(created_by_user_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_budget_settlements_to_user_id ON budget_settlements(to_user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_budget_settlements_from_user_id ON budget_settlements(from_user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_packing_item_contributors_user_id ON packing_item_contributors(user_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_packing_item_contributors_user_id ON packing_item_contributors(user_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_created_by ON trip_invite_tokens(created_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_journey_books_updated_by ON journey_books(updated_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_journey_books_created_by ON journey_books(created_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_collab_links_user_id ON collab_links(user_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_matched_bucket_list_item_id ON dawarich_visit_suggestions(matched_bucket_list_item_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_accepted_bucket_list_item_id ON dawarich_visit_suggestions(accepted_bucket_list_item_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_accepted_place_id ON dawarich_visit_suggestions(accepted_place_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_matched_bucket_list_item_id ON dawarich_visit_suggestions(matched_bucket_list_item_id)',
);
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_accepted_bucket_list_item_id ON dawarich_visit_suggestions(accepted_bucket_list_item_id)',
);
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_dawarich_visit_suggestions_accepted_place_id ON dawarich_visit_suggestions(accepted_place_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_document_connections_provider_id ON document_connections(provider_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_document_links_created_by ON trip_document_links(created_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_document_links_connection_id ON trip_document_links(connection_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_trip_document_links_connection_id ON trip_document_links(connection_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_document_sync_items_file_id ON document_sync_items(file_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_boundaries_to_assignment_id ON roadtrip_day_boundaries(to_assignment_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_boundaries_from_assignment_id ON roadtrip_day_boundaries(from_assignment_id)');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_roadtrip_day_boundaries_to_assignment_id ON roadtrip_day_boundaries(to_assignment_id)',
);
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_roadtrip_day_boundaries_from_assignment_id ON roadtrip_day_boundaries(from_assignment_id)',
);
}
}
@@ -1,5 +1,5 @@
import { Migration } from '@mikro-orm/migrations';
import { columnNames } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* `tour_types` holds every key the shared `tourTypeKeySchema` accepts. Only
+1 -1
View File
@@ -97,7 +97,7 @@ import { SqlitePlatform } from '@mikro-orm/sql';
export class NulSafeSqlitePlatform extends SqlitePlatform {
override escape(value: unknown): string {
if (typeof value === 'string' && value.includes('\u0000')) {
const literals = value.split('\u0000').map(part => super.escape(part));
const literals = value.split('\u0000').map((part) => super.escape(part));
return `(${literals.join(' || char(0) || ')})`;
}
if (typeof value === 'number' && !Number.isFinite(value)) {
+1 -1
View File
@@ -1,8 +1,8 @@
import { getRawConnection } from './database';
import { NulSafeSqlitePlatform } from './nul-safe-sqlite-platform';
import type { Configuration } from '@mikro-orm/core';
import type { SqlitePlatform } from '@mikro-orm/sql';
import { SqliteConnection, SqliteDriver } from '@mikro-orm/sqlite';
import { NulSafeSqlitePlatform } from './nul-safe-sqlite-platform';
import type Database from 'better-sqlite3';
import { SqliteDialect, SqliteDriver as KyselySqliteDriver, type Dialect, type SqliteDialectConfig } from 'kysely';
+8 -2
View File
@@ -1,9 +1,9 @@
import mikroOrmConfig from '../mikro-orm.config';
import { withRequestContext } from '../nest/database/request-context';
import { runDemoSeed } from './database';
import { ensureEmailCaseIndex } from './email-case-index';
import { migrateToHead, NO_SAFETY_NET } from './legacy-baseline';
import { preMigrateSnapshot } from './pre-migrate-snapshot';
import { withRequestContext } from '../nest/database/request-context';
import type { AnyEntity, EntityClass, EntityManager, EntitySchema, IDatabaseDriver, MikroORM } from '@mikro-orm/core';
import type { Migrator } from '@mikro-orm/migrations';
import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite';
@@ -45,7 +45,13 @@ export async function runSchemaBootstrap(orm: AnyOrm, { snapshot = true }: { sna
// what it already has is recorded in the same transaction as the run, or the
// run would replay the whole history over it (see legacy-baseline.ts). Every
// other database just gets its pending migrations.
await migrateToHead(connection, migrator, undefined, undefined, snapshot ? preMigrateSnapshot(connection) : NO_SAFETY_NET);
await migrateToHead(
connection,
migrator,
undefined,
undefined,
snapshot ? preMigrateSnapshot(connection) : NO_SAFETY_NET,
);
// The one index a migration may have had to defer (see email-case-index.ts).
await ensureEmailCaseIndex(connection);
@@ -1,5 +1,5 @@
import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity';
import { coalesce, columnRef } from '../dialect/sql-functions';
import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity';
import { TrekRepository } from './_shared/trek-repository';
/** One assignment's participant, from `QueryHelpersService.loadParticipantsByAssignmentIds` (QH3). */
@@ -1,7 +1,7 @@
import { AuditLog } from '../entities/AuditLog.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/**
* An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts`
@@ -105,7 +105,17 @@ export class AuditLogRepository extends TrekRepository<AuditLog> {
return await this.kysely<AuditLogKyselyDB>()
.selectFrom('audit_log as a')
.leftJoin('users as u', 'u.id', 'a.user_id')
.select(['a.id', 'a.created_at', 'a.user_id', 'u.username', 'u.email as user_email', 'a.action', 'a.resource', 'a.details', 'a.ip'])
.select([
'a.id',
'a.created_at',
'a.user_id',
'u.username',
'u.email as user_email',
'a.action',
'a.resource',
'a.details',
'a.ip',
])
.orderBy('a.id', 'desc')
.limit(limit)
.offset(offset)
@@ -1,8 +1,8 @@
import { lowerTrim, lowerTrimParam } from '../dialect/sql-functions';
import type { BucketList } from '../entities/BucketList.entity';
import type { DB } from '../kysely/db';
import { presenceSet } from './_shared/presence-set';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** `bucket_list` — every scalar column, `AT30`/`AT33`/`AT34`'s `SELECT *` shape. */
export interface BucketListRow {
@@ -67,7 +67,12 @@ export class BucketListRepository extends TrekRepository<BucketList> {
/** AT30 (`bucketList`) — `SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC`. */
async listForUser(userId: number): Promise<BucketListRow[]> {
return await this.readDb().selectFrom('bucket_list').selectAll().where('user_id', '=', userId).orderBy('created_at', 'desc').execute();
return await this.readDb()
.selectFrom('bucket_list')
.selectAll()
.where('user_id', '=', userId)
.orderBy('created_at', 'desc')
.execute();
}
/**
@@ -128,18 +133,31 @@ export class BucketListRepository extends TrekRepository<BucketList> {
target_date: string | null;
region_code: string | null;
}): Promise<number> {
const inserted = await this.insertDb().insertInto('bucket_list').values(row).returning('id').executeTakeFirstOrThrow();
const inserted = await this.insertDb()
.insertInto('bucket_list')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
/** AT33 (`createBucketItem`'s post-insert re-select, NOT user-scoped — the legacy statement's own shape) — `SELECT * FROM bucket_list WHERE id = ?`. */
async findById(id: number | string): Promise<BucketListRow | undefined> {
return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).executeTakeFirst();
return await this.readDb()
.selectFrom('bucket_list')
.selectAll()
.where('id', '=', id as number)
.executeTakeFirst();
}
/** AT34/AT36/AT37 (`updateBucketItem`'s guard, its post-update re-select, `deleteBucketItem`'s guard — identical text, three call sites) — `SELECT * FROM bucket_list WHERE id = ? AND user_id = ?`. */
async findForUser(id: number | string, userId: number): Promise<BucketListRow | undefined> {
return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).where('user_id', '=', userId).executeTakeFirst();
return await this.readDb()
.selectFrom('bucket_list')
.selectAll()
.where('id', '=', id as number)
.where('user_id', '=', userId)
.executeTakeFirst();
}
/**
@@ -181,12 +199,21 @@ export class BucketListRepository extends TrekRepository<BucketList> {
target_date: string | null;
}>(write);
if (Object.keys(data).length === 0) return;
await this.writeDb().updateTable('bucket_list').set(data).where('id', '=', id as number).where('user_id', '=', userId).execute();
await this.writeDb()
.updateTable('bucket_list')
.set(data)
.where('id', '=', id as number)
.where('user_id', '=', userId)
.execute();
}
/** AT38 (`deleteBucketItem`) — `DELETE FROM bucket_list WHERE id = ? AND user_id = ?`. */
async deleteForUser(id: number | string, userId: number): Promise<void> {
await this.writeDb().deleteFrom('bucket_list').where('id', '=', id as number).where('user_id', '=', userId).execute();
await this.writeDb()
.deleteFrom('bucket_list')
.where('id', '=', id as number)
.where('user_id', '=', userId)
.execute();
}
// ---------------------------------------------------------------------------
@@ -216,7 +243,9 @@ export class BucketListRepository extends TrekRepository<BucketList> {
* created_at DESC, id DESC`. The boolean-expression `ORDER BY` key is
* portable SQL — no dialect helper needed.
*/
async listForScan(userId: number): Promise<{ id: number; name: string; lat: number | null; lng: number | null; visited_at: string | null }[]> {
async listForScan(
userId: number,
): Promise<{ id: number; name: string; lat: number | null; lng: number | null; visited_at: string | null }[]> {
return await this.readDb()
.selectFrom('bucket_list')
.select(['id', 'name', 'lat', 'lng', 'visited_at'])
@@ -1,6 +1,6 @@
import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type BudgetCategoryOrderKyselyDB = Pick<DB, 'budget_category_order'>;
@@ -1,7 +1,7 @@
import type { BudgetItemMembers } from '../entities/BudgetItemMembers.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `budget_item_members` row — every scalar column, incl. the two `persist(false)` relation mirrors. */
export interface BudgetItemMemberRow {
@@ -43,7 +43,13 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
return await this.kysely<BudgetItemMembersKyselyDB>()
.selectFrom('budget_item_members as bm')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.select(['bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.user_id',
'bm.paid',
'bm.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bm.budget_item_id', '=', budget_item_id)
.execute();
}
@@ -54,7 +60,14 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
return await this.kysely<BudgetItemMembersKyselyDB>()
.selectFrom('budget_item_members as bm')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.select(['bm.budget_item_id', 'bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.budget_item_id',
'bm.user_id',
'bm.paid',
'bm.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bm.budget_item_id', 'in', budget_item_ids)
.execute();
}
@@ -79,12 +92,22 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
}
/** BG73 (`calculateSettlement`'s member read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. No `paid` column — unlike {@link listForItem}, this legacy statement never selected it. */
async listForTripWithUsers(trip_id: number | string): Promise<{ budget_item_id: number; user_id: number; amount: number | null; username: string; avatar: string | null }[]> {
async listForTripWithUsers(
trip_id: number | string,
): Promise<
{ budget_item_id: number; user_id: number; amount: number | null; username: string; avatar: string | null }[]
> {
return await this.kysely<BudgetItemMembersKyselyDB & Pick<DB, 'budget_items'>>()
.selectFrom('budget_item_members as bm')
.innerJoin('budget_items as b', 'b.id', 'bm.budget_item_id')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.budget_item_id',
'bm.user_id',
'bm.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('b.trip_id', '=', trip_id as number)
.execute();
}
@@ -96,12 +119,21 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
* bm.budget_item_id = bi.id JOIN users u ON bm.user_id = u.id WHERE
* bi.trip_id = ?`. Like {@link listForTripWithUsers}, plus `paid`.
*/
async listForTripWithUsersAndPaid(trip_id: number | string): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> {
async listForTripWithUsersAndPaid(
trip_id: number | string,
): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> {
return await this.kysely<BudgetItemMembersKyselyDB & Pick<DB, 'budget_items'>>()
.selectFrom('budget_item_members as bm')
.innerJoin('budget_items as bi', 'bi.id', 'bm.budget_item_id')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.budget_item_id',
'bm.user_id',
'bm.amount',
'bm.paid',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bi.trip_id', '=', trip_id as number)
.execute();
}
@@ -114,12 +146,15 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
.distinct()
.where('user_id', '=', user_id)
.execute();
return rows.map(r => r.budget_item_id);
return rows.map((r) => r.budget_item_id);
}
/** BG65 — `DELETE FROM budget_item_members WHERE user_id = ?`. */
async deleteForUser(user_id: number): Promise<void> {
await this.kysely<BudgetItemMembersKyselyDB>().deleteFrom('budget_item_members').where('user_id', '=', user_id).execute();
await this.kysely<BudgetItemMembersKyselyDB>()
.deleteFrom('budget_item_members')
.where('user_id', '=', user_id)
.execute();
}
/** BG66 (`removeUserFromBudgetItems`'s per-item recount, looped) — `SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?`. */
@@ -133,11 +168,19 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
}
/** BG70 (`toggleMemberPaid`'s re-select) — `SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN users u WHERE bm.budget_item_id = ? AND bm.user_id = ?`. No `amount` column — unlike {@link listForItem}, this legacy statement never selected it. */
async findMemberWithUser(budget_item_id: number, user_id: number): Promise<{ user_id: number; paid: number; username: string; avatar: string | null } | undefined> {
async findMemberWithUser(
budget_item_id: number,
user_id: number,
): Promise<{ user_id: number; paid: number; username: string; avatar: string | null } | undefined> {
return await this.kysely<BudgetItemMembersKyselyDB>()
.selectFrom('budget_item_members as bm')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.select(['bm.user_id', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.user_id',
'bm.paid',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bm.budget_item_id', '=', budget_item_id)
.where('bm.user_id', '=', user_id)
.executeTakeFirst();
@@ -155,7 +198,10 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
/** BG35/BG38/BG59 — `DELETE FROM budget_item_members WHERE budget_item_id = ?` (replace-all before a re-insert). */
async deleteForItem(budget_item_id: number): Promise<void> {
await this.kysely<BudgetItemMembersKyselyDB>().deleteFrom('budget_item_members').where('budget_item_id', '=', budget_item_id).execute();
await this.kysely<BudgetItemMembersKyselyDB>()
.deleteFrom('budget_item_members')
.where('budget_item_id', '=', budget_item_id)
.execute();
}
/**
@@ -168,10 +214,20 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
* with no `amount` column at all — `paid`/`amount` default here to the
* 0/`NULL` shape the omitting call sites bound literally).
*/
async insertIgnore(row: { budget_item_id: number; user_id: number; paid?: number; amount?: number | null }): Promise<void> {
async insertIgnore(row: {
budget_item_id: number;
user_id: number;
paid?: number;
amount?: number | null;
}): Promise<void> {
await this.kysely<BudgetItemMembersWriteKyselyDB>()
.insertInto('budget_item_members')
.values({ budget_item_id: row.budget_item_id, user_id: row.user_id, paid: row.paid ?? 0, amount: row.amount ?? null })
.values({
budget_item_id: row.budget_item_id,
user_id: row.user_id,
paid: row.paid ?? 0,
amount: row.amount ?? null,
})
.onConflict((oc) => oc.doNothing())
.execute();
}
@@ -1,7 +1,7 @@
import type { BudgetItemPayers } from '../entities/BudgetItemPayers.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `budget_item_payers` row — every scalar column, incl. the two `persist(false)` relation mirrors. */
export interface BudgetItemPayerRow {
@@ -38,7 +38,12 @@ export class BudgetItemPayersRepository extends TrekRepository<BudgetItemPayers>
return await this.kysely<BudgetItemPayersKyselyDB>()
.selectFrom('budget_item_payers as bp')
.innerJoin('users as u', 'u.id', 'bp.user_id')
.select(['bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bp.user_id',
'bp.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bp.budget_item_id', '=', budget_item_id)
.execute();
}
@@ -49,7 +54,13 @@ export class BudgetItemPayersRepository extends TrekRepository<BudgetItemPayers>
return await this.kysely<BudgetItemPayersKyselyDB>()
.selectFrom('budget_item_payers as bp')
.innerJoin('users as u', 'u.id', 'bp.user_id')
.select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bp.budget_item_id',
'bp.user_id',
'bp.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('bp.budget_item_id', 'in', budget_item_ids)
.execute();
}
@@ -65,19 +76,30 @@ export class BudgetItemPayersRepository extends TrekRepository<BudgetItemPayers>
}
/** BG74 (`calculateSettlement`'s payer read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. */
async listForTripWithUsers(trip_id: number | string): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> {
async listForTripWithUsers(
trip_id: number | string,
): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> {
return await this.kysely<BudgetItemPayersKyselyDB & Pick<DB, 'budget_items'>>()
.selectFrom('budget_item_payers as bp')
.innerJoin('budget_items as b', 'b.id', 'bp.budget_item_id')
.innerJoin('users as u', 'u.id', 'bp.user_id')
.select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bp.budget_item_id',
'bp.user_id',
'bp.amount',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('b.trip_id', '=', trip_id as number)
.execute();
}
/** BG4 — `DELETE FROM budget_item_payers WHERE budget_item_id = ?` (replace-all before a re-insert). */
async deleteForItem(budget_item_id: number): Promise<void> {
await this.kysely<BudgetItemPayersKyselyDB>().deleteFrom('budget_item_payers').where('budget_item_id', '=', budget_item_id).execute();
await this.kysely<BudgetItemPayersKyselyDB>()
.deleteFrom('budget_item_payers')
.where('budget_item_id', '=', budget_item_id)
.execute();
}
/** BG5/TP65 — `INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?,?,?)`, one row per call (the legacy `prepare(...).run(...)` loop). */
@@ -1,9 +1,9 @@
import type { BudgetItems } from '../entities/BudgetItems.entity';
import { columnRef } from '../dialect/sql-functions';
import type { BudgetItems } from '../entities/BudgetItems.entity';
import type { DB } from '../kysely/db';
import { presenceSet } from './_shared/presence-set';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import { presenceSet } from './_shared/presence-set';
import type { DB } from '../kysely/db';
/** A bare `budget_items` row — every scalar column, incl. the four `persist(false)` relation mirrors (the program-wide trap this class goes through Kysely to avoid). */
export interface BudgetItemRow {
@@ -56,7 +56,10 @@ export interface BudgetItemMoneyRow {
exchange_rate: number;
}
type BudgetKyselyDB = Pick<DB, 'budget_items' | 'budget_item_members' | 'budget_category_order' | 'users' | 'trip_files' | 'file_links'>;
type BudgetKyselyDB = Pick<
DB,
'budget_items' | 'budget_item_members' | 'budget_category_order' | 'users' | 'trip_files' | 'file_links'
>;
/**
* The table `create` (BG25) inserts into. It binds the legacy column list:
@@ -101,7 +104,9 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
async listWithCategoryOrder(trip_id: number | string): Promise<BudgetItemRow[]> {
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items as bi')
.leftJoin('budget_category_order as bco', (join) => join.onRef('bco.trip_id', '=', 'bi.trip_id').onRef('bco.category', '=', 'bi.category'))
.leftJoin('budget_category_order as bco', (join) =>
join.onRef('bco.trip_id', '=', 'bi.trip_id').onRef('bco.category', '=', 'bi.category'),
)
.selectAll('bi')
.where('bi.trip_id', '=', trip_id as number)
.orderBy((eb) => eb.fn.coalesce('bco.sort_order', eb.val(999999)), 'asc')
@@ -111,33 +116,64 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG14 — `SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the program's gate-level id parsing carry: its one caller, `freezeForeignRate`, is only reached with `BudgetController.update`'s `toRowId`-parsed id). */
async getCurrency(id: number, trip_id: number | string): Promise<string | null | undefined> {
const row = await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select('currency').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
const row = await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select('currency')
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
return row?.currency;
}
/** BG31/BG32/BG57 — `SELECT * FROM budget_items WHERE id = ? AND trip_id = ?`, the full-row trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}; `trip_id` stays `number | string`, a separate, still-accepted carry. */
async findInTrip(id: number, trip_id: number | string): Promise<BudgetItemRow | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
}
/** BG50/BG68 — `SELECT id FROM budget_items WHERE id = ? AND trip_id = ?`, the id-only trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */
async existsInTrip(id: number, trip_id: number | string): Promise<{ id: number } | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select('id').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select('id')
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
}
/** BG52 (`deleteBudgetItem`'s pre-image) — `SELECT id, reservation_id FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */
async findForDelete(id: number, trip_id: number | string): Promise<{ id: number; reservation_id: number | null } | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select(['id', 'reservation_id']).where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
async findForDelete(
id: number,
trip_id: number | string,
): Promise<{ id: number; reservation_id: number | null } | undefined> {
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select(['id', 'reservation_id'])
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
}
/** BG30/BG49/BG51/BG63 — `SELECT * FROM budget_items WHERE id = ?`, the post-write re-select (no trip filter — the caller already knows `id` is in-scope, having just written it). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */
async findById(id: number): Promise<BudgetItemRow | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('id', '=', id).executeTakeFirst();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
/** BG21 — `SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?`. */
async maxSortOrder(trip_id: number | string): Promise<number | null> {
const row = await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst();
const row = await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select((eb) => eb.fn.max('sort_order').as('max'))
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
return row?.max ?? null;
}
@@ -149,11 +185,26 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* different signature: an unpersisted-entity builder, not an INSERT).
*/
async insertItem(row: {
trip_id: number | string; category: string; name: string; total_price: number; currency: string | null;
exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null;
sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null;
trip_id: number | string;
category: string;
name: string;
total_price: number;
currency: string | null;
exchange_rate: number;
persons: number | null;
days: number | null;
note: string | null;
ticket_json: string | null;
sort_order: number;
expense_date: string | null;
reservation_id: number | null;
place_id: number | null;
}): Promise<number> {
const inserted = await this.kysely<BudgetItemsInsertKyselyDB>().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<BudgetItemsInsertKyselyDB>()
.insertInto('budget_items')
.values({ ...row, trip_id: row.trip_id as number })
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -166,11 +217,26 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* column survives a copy unexamined, matching legacy exactly).
*/
async insertCopy(row: {
trip_id: number | string; category: string; name: string; total_price: number; persons: number | null; days: number | null;
note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null;
exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null;
trip_id: number | string;
category: string;
name: string;
total_price: number;
persons: number | null;
days: number | null;
note: string | null;
sort_order: number | null;
reservation_id: number | null;
currency: string | null;
exchange_rate: number;
expense_date: string | null;
ticket_json: string | null;
paid_by_user_id: number | null;
}): Promise<number> {
const inserted = await this.kysely<BudgetItemsCopyInsertKyselyDB>().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<BudgetItemsCopyInsertKyselyDB>()
.insertInto('budget_items')
.values({ ...row, trip_id: row.trip_id as number })
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -185,25 +251,38 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* column is a true presence sentinel (`present = data.field !== undefined`).
*/
/** `id: number` — same Plan 4 Task 8b (U6) narrowing as {@link getCurrency} (its one caller, `updateBudgetItem`, is only reached with a `toRowId`-parsed id). */
async update(id: number, write: {
category?: readonly [present: boolean, value: string];
name?: readonly [present: boolean, value: string];
total_price?: readonly [present: boolean, value: number];
currency?: readonly [present: boolean, value: string | null];
exchange_rate?: readonly [present: boolean, value: number];
persons?: readonly [present: boolean, value: number | null];
days?: readonly [present: boolean, value: number | null];
note?: readonly [present: boolean, value: string | null];
ticket_json?: readonly [present: boolean, value: string | null];
sort_order?: readonly [present: boolean, value: number];
expense_date?: readonly [present: boolean, value: string | null];
reservation_id?: readonly [present: boolean, value: number | null];
place_id?: readonly [present: boolean, value: number | null];
}): Promise<void> {
async update(
id: number,
write: {
category?: readonly [present: boolean, value: string];
name?: readonly [present: boolean, value: string];
total_price?: readonly [present: boolean, value: number];
currency?: readonly [present: boolean, value: string | null];
exchange_rate?: readonly [present: boolean, value: number];
persons?: readonly [present: boolean, value: number | null];
days?: readonly [present: boolean, value: number | null];
note?: readonly [present: boolean, value: string | null];
ticket_json?: readonly [present: boolean, value: string | null];
sort_order?: readonly [present: boolean, value: number];
expense_date?: readonly [present: boolean, value: string | null];
reservation_id?: readonly [present: boolean, value: number | null];
place_id?: readonly [present: boolean, value: number | null];
},
): Promise<void> {
const data = presenceSet<{
category: string; name: string; total_price: number; currency: string | null; exchange_rate: number;
persons: number | null; days: number | null; note: string | null; ticket_json: string | null;
sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null;
category: string;
name: string;
total_price: number;
currency: string | null;
exchange_rate: number;
persons: number | null;
days: number | null;
note: string | null;
ticket_json: string | null;
sort_order: number;
expense_date: string | null;
reservation_id: number | null;
place_id: number | null;
}>(write);
if (Object.keys(data).length === 0) return;
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set(data).where('id', '=', id).execute();
@@ -211,17 +290,30 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG6/BG34 — `UPDATE budget_items SET total_price = ? WHERE id = ?`. */
async setTotalPrice(id: number | string, total_price: number): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ total_price }).where('id', '=', id as number).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ total_price })
.where('id', '=', id as number)
.execute();
}
/** BG37/BG40/BG61/BG62 — `UPDATE budget_items SET persons = ? WHERE id = ?` (`persons: null` is BG62's clear branch). */
async setPersons(id: number | string, persons: number | null): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ persons }).where('id', '=', id as number).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ persons })
.where('id', '=', id as number)
.execute();
}
/** BG83 (`reorderItems`, looped) — `UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */
async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ sort_order }).where('id', '=', id as number).where('trip_id', '=', trip_id as number).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ sort_order })
.where('id', '=', id as number)
.where('trip_id', '=', trip_id as number)
.execute();
}
/** BG53 — `DELETE FROM budget_items WHERE id = ?` (no trip scoping in the statement itself — relies on the caller's prior {@link findForDelete} gate, matching legacy). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */
@@ -237,7 +329,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG72 (`calculateSettlement`) — `SELECT * FROM budget_items WHERE trip_id = ?`. */
async listAllForTrip(trip_id: number | string): Promise<BudgetItemRow[]> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).execute();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.selectAll()
.where('trip_id', '=', trip_id as number)
.execute();
}
/**
@@ -246,7 +342,12 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* (unlike BG72's `listAllForTrip` above, which has no `ORDER BY`).
*/
async listPublicForShare(trip_id: number | string): Promise<BudgetItemRow[]> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('category', 'asc').execute();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.selectAll()
.where('trip_id', '=', trip_id as number)
.orderBy('category', 'asc')
.execute();
}
/**
@@ -277,7 +378,9 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
.where('currency', 'is not', null)
.where('currency', '!=', '')
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '!=', trip_currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.limit(1)
.executeTakeFirst();
return row !== undefined;
@@ -300,9 +403,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
.where('currency', 'is not', null)
.where('currency', '!=', '')
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '!=', trip_currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
return rows.map(r => r.cur);
return rows.map((r) => r.cur);
}
/** BG91's `budget_items` half (`freezeMissingRates`) — `SELECT id FROM budget_items WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */
@@ -312,9 +417,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
.select('id')
.where('trip_id', '=', trip_id as number)
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '=', currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
return rows.map(r => r.id);
return rows.map((r) => r.id);
}
/** BG92's `budget_items` half (`freezeMissingRates`) — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */
@@ -324,12 +431,17 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
.set({ exchange_rate })
.where('trip_id', '=', trip_id as number)
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '=', currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
}
/** BG93 (`resyncReservationPrice`) — `SELECT total_price, currency FROM budget_items WHERE trip_id = ? AND reservation_id = ? ORDER BY id`, every expense linked to one booking (#2084). */
async listLinkedToReservation(trip_id: number | string, reservation_id: number): Promise<{ total_price: number | null; currency: string | null }[]> {
async listLinkedToReservation(
trip_id: number | string,
reservation_id: number,
): Promise<{ total_price: number | null; currency: string | null }[]> {
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select(['total_price', 'currency'])
@@ -351,13 +463,24 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG18's `budget_items` half — `SELECT DISTINCT currency AS cur FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL`. */
async listDistinctCurrencies(trip_id: number | string): Promise<string[]> {
const rows = await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select('currency').distinct().where('trip_id', '=', trip_id as number).where('currency', 'is not', null).execute();
return rows.map(r => r.currency as string);
const rows = await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select('currency')
.distinct()
.where('trip_id', '=', trip_id as number)
.where('currency', 'is not', null)
.execute();
return rows.map((r) => r.currency as string);
}
/** BG19's `budget_items` half — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`. */
async setExchangeRateForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ exchange_rate }).where('trip_id', '=', trip_id as number).where('currency', '=', currency).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ exchange_rate })
.where('trip_id', '=', trip_id as number)
.where('currency', '=', currency)
.execute();
}
// ---------------------------------------------------------------------------
@@ -408,7 +531,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG8 (`unlinkReceipts`) — `UPDATE file_links SET budget_item_id = NULL WHERE id = ?`. */
async clearLinkBudgetRef(link_id: number): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('file_links').set({ budget_item_id: null }).where('id', '=', link_id).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('file_links')
.set({ budget_item_id: null })
.where('id', '=', link_id)
.execute();
}
/** BG9 (`unlinkReceipts`) — `DELETE FROM file_links WHERE id = ?`. */
@@ -438,7 +565,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG46 (`updateBudgetItem`'s `adopt`) — `UPDATE file_links SET budget_item_id = ? WHERE id = ?`, re-parenting a spare link row instead of inserting a duplicate. */
async adoptSpareLink(link_id: number, budget_item_id: number | string): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('file_links').set({ budget_item_id: budget_item_id as number }).where('id', '=', link_id).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('file_links')
.set({ budget_item_id: budget_item_id as number })
.where('id', '=', link_id)
.execute();
}
/** BG47 — `SELECT 1 FROM file_links WHERE file_id = ? AND budget_item_id = ?`, the "already this item's receipt" short-circuit. */
@@ -454,7 +585,13 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** BG48 — `SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1` (`spare`), drives {@link adoptSpareLink}'s adopt-vs-insert branch. */
async findSpareLink(file_id: number): Promise<{ id: number } | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('file_links').select('id').where('file_id', '=', file_id).where('budget_item_id', 'is', null).limit(1).executeTakeFirst();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('file_links')
.select('id')
.where('file_id', '=', file_id)
.where('budget_item_id', 'is', null)
.limit(1)
.executeTakeFirst();
}
// ---------------------------------------------------------------------------
@@ -470,7 +607,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* them all.
*/
async listIdsByReservation(reservation_id: number): Promise<{ id: number }[]> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select('id').where('reservation_id', '=', reservation_id).execute();
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select('id')
.where('reservation_id', '=', reservation_id)
.execute();
}
/** PL15 (`linkedExpenseIds`) — `SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (dynamic)`. */
@@ -482,17 +623,29 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
.where('trip_id', '=', trip_id as number)
.where('place_id', 'in', place_ids as number[])
.execute();
return rows.map(r => r.id);
return rows.map((r) => r.id);
}
/** PL18/PL22 — `DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?`. */
async deleteForPlace(trip_id: number | string, place_id: number | string): Promise<void> {
await this.kysely<BudgetKyselyDB>().deleteFrom('budget_items').where('trip_id', '=', trip_id as number).where('place_id', '=', place_id as number).execute();
await this.kysely<BudgetKyselyDB>()
.deleteFrom('budget_items')
.where('trip_id', '=', trip_id as number)
.where('place_id', '=', place_id as number)
.execute();
}
/** RS52/RS53 — `SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?`. */
async findIdByReservationInTrip(trip_id: number | string, reservation_id: number | string): Promise<{ id: number } | undefined> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').select('id').where('trip_id', '=', trip_id as number).where('reservation_id', '=', reservation_id as number).executeTakeFirst();
async findIdByReservationInTrip(
trip_id: number | string,
reservation_id: number | string,
): Promise<{ id: number } | undefined> {
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select('id')
.where('trip_id', '=', trip_id as number)
.where('reservation_id', '=', reservation_id as number)
.executeTakeFirst();
}
/**
@@ -502,7 +655,10 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
* all, the type change re-files each one still on the derived category, and
* the price field is left alone once more than one is linked.
*/
async listIdAndCategoryByReservation(trip_id: number | string, reservation_id: number | string): Promise<{ id: number; category: string }[]> {
async listIdAndCategoryByReservation(
trip_id: number | string,
reservation_id: number | string,
): Promise<{ id: number; category: string }[]> {
return await this.kysely<BudgetKyselyDB>()
.selectFrom('budget_items')
.select(['id', 'category'])
@@ -514,11 +670,19 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
/** RS54 — `UPDATE budget_items SET reservation_id = ? WHERE id = ?` (binds the reservation id verbatim — a string in the legacy call site, matching parity). */
async setReservationId(id: number | string, reservation_id: number | string): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ reservation_id: reservation_id as number }).where('id', '=', id as number).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ reservation_id: reservation_id as number })
.where('id', '=', id as number)
.execute();
}
/** UC5 (`UserCleanupService.cleanupUserReferences`) — `UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?`. */
async clearPaidByUser(user_id: number): Promise<void> {
await this.kysely<BudgetKyselyDB>().updateTable('budget_items').set({ paid_by_user_id: null }).where('paid_by_user_id', '=', user_id).execute();
await this.kysely<BudgetKyselyDB>()
.updateTable('budget_items')
.set({ paid_by_user_id: null })
.where('paid_by_user_id', '=', user_id)
.execute();
}
}
@@ -1,8 +1,8 @@
import type { BudgetSettlements } from '../entities/BudgetSettlements.entity';
import type { DB } from '../kysely/db';
import { presenceSet } from './_shared/presence-set';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import { presenceSet } from './_shared/presence-set';
import type { DB } from '../kysely/db';
/** A bare `budget_settlements` row — every scalar column, incl. the `persist(false)` relation mirrors. */
export interface BudgetSettlementRow {
@@ -50,10 +50,21 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
.innerJoin('users as fu', 'fu.id', 's.from_user_id')
.innerJoin('users as tu', 'tu.id', 's.to_user_id')
.select([
's.id', 's.trip_id', 's.from_user_id', 's.to_user_id', 's.amount', 's.currency', 's.exchange_rate',
's.created_at', 's.settled_at', 's.note', 's.created_by_user_id',
(eb) => eb.fn.coalesce('fu.display_name', 'fu.username').as('from_username'), 'fu.avatar as from_avatar',
(eb) => eb.fn.coalesce('tu.display_name', 'tu.username').as('to_username'), 'tu.avatar as to_avatar',
's.id',
's.trip_id',
's.from_user_id',
's.to_user_id',
's.amount',
's.currency',
's.exchange_rate',
's.created_at',
's.settled_at',
's.note',
's.created_by_user_id',
(eb) => eb.fn.coalesce('fu.display_name', 'fu.username').as('from_username'),
'fu.avatar as from_avatar',
(eb) => eb.fn.coalesce('tu.display_name', 'tu.username').as('to_username'),
'tu.avatar as to_avatar',
]);
}
@@ -98,14 +109,28 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
* `insert` — collides with `TrekRepository`'s own same-named method.
*/
async insertSettlement(row: {
trip_id: number | string; from_user_id: number; to_user_id: number; amount: number;
currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null;
trip_id: number | string;
from_user_id: number;
to_user_id: number;
amount: number;
currency: string | null;
exchange_rate: number;
settled_at: string | null;
note: string | null;
created_by_user_id: number | null;
}): Promise<number> {
const inserted = await this.kysely<BudgetSettlementsWriteKyselyDB>()
.insertInto('budget_settlements')
.values({
trip_id: row.trip_id as number, from_user_id: row.from_user_id, to_user_id: row.to_user_id, amount: row.amount,
currency: row.currency, exchange_rate: row.exchange_rate, settled_at: row.settled_at, note: row.note, created_by_user_id: row.created_by_user_id,
trip_id: row.trip_id as number,
from_user_id: row.from_user_id,
to_user_id: row.to_user_id,
amount: row.amount,
currency: row.currency,
exchange_rate: row.exchange_rate,
settled_at: row.settled_at,
note: row.note,
created_by_user_id: row.created_by_user_id,
})
.returning('id')
.executeTakeFirstOrThrow();
@@ -119,14 +144,24 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
* shape `BudgetItemsRepository.update` (BG33) lands.
*/
/** `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers} (its one caller, `applySettlementUpdate`, is only reached with a `toRowId`-parsed id). */
async update(id: number, write: {
from_user_id: number; to_user_id: number; amount: number;
currency?: [present: boolean, value: string | null];
exchange_rate?: [present: boolean, value: number];
settled_at?: [present: boolean, value: string | null];
note?: [present: boolean, value: string | null];
}): Promise<void> {
const data = presenceSet<{ currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null }>({
async update(
id: number,
write: {
from_user_id: number;
to_user_id: number;
amount: number;
currency?: [present: boolean, value: string | null];
exchange_rate?: [present: boolean, value: number];
settled_at?: [present: boolean, value: string | null];
note?: [present: boolean, value: string | null];
},
): Promise<void> {
const data = presenceSet<{
currency: string | null;
exchange_rate: number;
settled_at: string | null;
note: string | null;
}>({
currency: write.currency,
exchange_rate: write.exchange_rate,
settled_at: write.settled_at,
@@ -177,7 +212,7 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
.where('trip_id', '=', trip_id as number)
.where('currency', 'is not', null)
.execute();
return rows.map(r => r.currency as string);
return rows.map((r) => r.currency as string);
}
/**
@@ -196,9 +231,11 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
.where('currency', 'is not', null)
.where('currency', '!=', '')
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '!=', trip_currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
return rows.map(r => r.cur);
return rows.map((r) => r.cur);
}
/** BG91's `budget_settlements` half (`freezeMissingRates`) — `SELECT id FROM budget_settlements WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */
@@ -208,9 +245,11 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
.select('id')
.where('trip_id', '=', trip_id as number)
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '=', currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
return rows.map(r => r.id);
return rows.map((r) => r.id);
}
/** BG92's `budget_settlements` half (`freezeMissingRates`) — `UPDATE budget_settlements SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */
@@ -220,7 +259,9 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
.set({ exchange_rate })
.where('trip_id', '=', trip_id as number)
.where((eb) => eb(eb.fn<string>('upper', ['currency']), '=', currency))
.where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]))
.where((eb) =>
eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)]),
)
.execute();
}
@@ -40,9 +40,7 @@ export class CategoriesRepository extends TrekRepository<Categories> {
* `ORDER BY` in the legacy statement — none added here.
*/
async listIdName(): Promise<{ id: number; name: string }[]> {
return this.qb('c')
.select(['c.id', 'c.name'])
.execute<{ id: number; name: string }[]>('all', false);
return this.qb('c').select(['c.id', 'c.name']).execute<{ id: number; name: string }[]>('all', false);
}
/**
@@ -1,8 +1,8 @@
import type { CollabLinks } from '../entities/CollabLinks.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import type { CollabLinks } from '../entities/CollabLinks.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `collab_links` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */
export interface CollabLinkRow {
@@ -47,17 +47,38 @@ export class CollabLinksRepository extends TrekRepository<CollabLinks> {
* `string | number` id via `toRowId` before calling (rule 15).
*/
async listForTrip(trip_id: number): Promise<CollabLinkJoinRow[]> {
return await this.joinedQuery().where('l.trip_id', '=', trip_id).orderBy('l.pinned', 'desc').orderBy('l.created_at', 'desc').execute();
return await this.joinedQuery()
.where('l.trip_id', '=', trip_id)
.orderBy('l.pinned', 'desc')
.orderBy('l.created_at', 'desc')
.execute();
}
/** CB39 (`updateLink`'s trip-scoping guard) — `SELECT * FROM collab_links WHERE id = ? AND trip_id = ?`. Full row: the falsy-coercion update (CB40) needs the pre-image to fall back onto. */
async findInTrip(id: number, trip_id: number): Promise<CollabLinkRow | undefined> {
return await this.kysely<CollabLinksKyselyDB>().selectFrom('collab_links').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst();
return await this.kysely<CollabLinksKyselyDB>()
.selectFrom('collab_links')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.executeTakeFirst();
}
/** CB37 (`createLink`) — `INSERT INTO collab_links (trip_id, user_id, title, url, pinned) VALUES (?×5)`. The service trims `title`/`url` before calling (matching the legacy inline `.trim()`). Returns the new row's id. */
async insertLink(row: { trip_id: number | string; user_id: number; title: string; url: string; pinned: number }): Promise<number> {
return await this.insert({ trip: row.trip_id, user: row.user_id, title: row.title, url: row.url, pinned: row.pinned });
async insertLink(row: {
trip_id: number | string;
user_id: number;
title: string;
url: string;
pinned: number;
}): Promise<number> {
return await this.insert({
trip: row.trip_id,
user: row.user_id,
title: row.title,
url: row.url,
pinned: row.pinned,
});
}
/**
@@ -75,7 +96,10 @@ export class CollabLinksRepository extends TrekRepository<CollabLinks> {
*/
async update(id: number, trip_id: number, write: { title: string; url: string; pinned: number }): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id, trip: trip_id }, { title: write.title, url: write.url, pinned: write.pinned, updated_at: currentTimestamp(platform) });
await this.nativeUpdate(
{ id, trip: trip_id },
{ title: write.title, url: write.url, pinned: write.pinned, updated_at: currentTimestamp(platform) },
);
}
/** CB38/CB41 (`createLink`'s post-insert re-select, `updateLink`'s post-write re-select) — `SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?`, same text at both call sites. */
@@ -1,8 +1,8 @@
import type { CollabMessages } from '../entities/CollabMessages.entity';
import type { DB } from '../kysely/db';
import type { TripFileRow } from './TripFiles.repository';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `collab_messages` row — every scalar column of the entity, incl. the three `persist(false)` mirrors (`trip_id`, `user_id`, `reply_to`). */
export interface CollabMessageRow {
@@ -142,12 +142,23 @@ export class CollabMessagesRepository extends TrekRepository<CollabMessages> {
* .deleteMessage` docstring for how that pre-existing gap is preserved.
*/
async findInTrip(id: number, trip_id: number): Promise<CollabMessageRow | undefined> {
return await this.kysely<CollabMessagesKyselyDB>().selectFrom('collab_messages').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst();
return await this.kysely<CollabMessagesKyselyDB>()
.selectFrom('collab_messages')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.executeTakeFirst();
}
/** CB47 (`createMessage`'s reply-target validation) — `SELECT id FROM collab_messages WHERE id = ? AND trip_id = ? AND deleted = 0`. A soft-deleted message is gone as far as anyone replying is concerned. */
async findActiveInTrip(id: number, trip_id: number): Promise<{ id: number } | undefined> {
return await this.kysely<CollabMessagesKyselyDB>().selectFrom('collab_messages').select(['id']).where('id', '=', id).where('trip_id', '=', trip_id).where('deleted', '=', 0).executeTakeFirst();
return await this.kysely<CollabMessagesKyselyDB>()
.selectFrom('collab_messages')
.select(['id'])
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.where('deleted', '=', 0)
.executeTakeFirst();
}
/** CB44 (`countMessages`) — `SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?`. */
@@ -156,7 +167,12 @@ export class CollabMessagesRepository extends TrekRepository<CollabMessages> {
}
/** CB48 (`createMessage`, inside its transaction) — `INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?×4)`. Returns the new row's id. */
async insertMessage(trip_id: number | string, user_id: number, text: string, reply_to: number | null): Promise<number> {
async insertMessage(
trip_id: number | string,
user_id: number,
text: string,
reply_to: number | null,
): Promise<number> {
return await this.insert({ trip: trip_id, user: user_id, text, reply_to });
}
@@ -188,7 +204,15 @@ export class CollabMessagesRepository extends TrekRepository<CollabMessages> {
* `INSERT INTO trip_files (trip_id, message_id, filename, original_name,
* file_size, mime_type, uploaded_by) VALUES (?×7)`.
*/
async insertAttachmentForMessage(row: { trip_id: number | string; message_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string; uploaded_by: number }): Promise<void> {
async insertAttachmentForMessage(row: {
trip_id: number | string;
message_id: number | string;
filename: string;
original_name: string;
file_size: number;
mime_type: string;
uploaded_by: number;
}): Promise<void> {
await this.kysely<MessageAttachmentInsertKyselyDB>()
.insertInto('trip_files')
.values({
@@ -205,11 +229,20 @@ export class CollabMessagesRepository extends TrekRepository<CollabMessages> {
/** CB52 (`deleteMessage`'s pre-tx storage-cleanup list) — `SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?`, run BEFORE the transaction (the legacy ordering). */
async listFilenamesForMessage(message_id: number, trip_id: number): Promise<{ filename: string }[]> {
return await this.kysely<MessageAttachmentsKyselyDB>().selectFrom('trip_files').select(['filename']).where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute();
return await this.kysely<MessageAttachmentsKyselyDB>()
.selectFrom('trip_files')
.select(['filename'])
.where('message_id', '=', message_id)
.where('trip_id', '=', trip_id)
.execute();
}
/** CB53 (`deleteMessage`, inside its transaction, before CB54) — `DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?`. */
async deleteAttachmentsForMessage(message_id: number, trip_id: number): Promise<void> {
await this.kysely<MessageAttachmentsKyselyDB>().deleteFrom('trip_files').where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute();
await this.kysely<MessageAttachmentsKyselyDB>()
.deleteFrom('trip_files')
.where('message_id', '=', message_id)
.where('trip_id', '=', trip_id)
.execute();
}
}
@@ -1,9 +1,9 @@
import type { CollabNotes } from '../entities/CollabNotes.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import type { CollabNotes } from '../entities/CollabNotes.entity';
import type { DB } from '../kysely/db';
import type { TripFileRow } from './TripFiles.repository';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `collab_notes` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */
export interface CollabNoteRow {
@@ -98,12 +98,21 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
* this query at all.
*/
async listForTrip(trip_id: number): Promise<CollabNoteJoinRow[]> {
return await this.joinedQuery().where('n.trip_id', '=', trip_id).orderBy('n.pinned', 'desc').orderBy('n.updated_at', 'desc').execute();
return await this.joinedQuery()
.where('n.trip_id', '=', trip_id)
.orderBy('n.pinned', 'desc')
.orderBy('n.updated_at', 'desc')
.execute();
}
/** CB10/CB13/CB17 (`updateNote`/`deleteNote`/`addNoteFile`'s shared trip-scoping guard) — `SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?`, same text at all three call sites. */
async findInTrip(id: number, trip_id: number): Promise<CollabNoteRow | undefined> {
return await this.kysely<CollabNotesKyselyDB>().selectFrom('collab_notes').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst();
return await this.kysely<CollabNotesKyselyDB>()
.selectFrom('collab_notes')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.executeTakeFirst();
}
/** CB9/CB12 (`createNote`'s post-insert re-select, `updateNote`'s post-write re-select) — `SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?`, no trip filter (the caller already knows `id` is in-scope, having just written it). */
@@ -117,7 +126,16 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
}
/** CB8 (`createNote`) — `INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) VALUES (?×8)`. The service resolves the `|| null`/`|| 'General'`/`|| '#6366f1'` defaults before calling (D2's split). Returns the new row's id. */
async insertNote(row: { trip_id: number | string; user_id: number; title: string; content: string | null; category: string; color: string; website: string | null; pinned: number }): Promise<number> {
async insertNote(row: {
trip_id: number | string;
user_id: number;
title: string;
content: string | null;
category: string;
color: string;
website: string | null;
pinned: number;
}): Promise<number> {
return await this.insert({
trip: row.trip_id,
user: row.user_id,
@@ -150,17 +168,30 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
* `CASE` helper, and this class docstring records that choice for
* whichever task reads it next.
*/
async update(id: number, write: { title: string; content: string | null; category: string | null; color: string | null; pinned: number | null; website: string | null }): Promise<void> {
async update(
id: number,
write: {
title: string;
content: string | null;
category: string | null;
color: string | null;
pinned: number | null;
website: string | null;
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, {
title: write.title,
content: write.content,
category: write.category,
color: write.color,
pinned: write.pinned,
website: write.website,
updated_at: currentTimestamp(platform),
});
await this.nativeUpdate(
{ id },
{
title: write.title,
content: write.content,
category: write.category,
color: write.color,
pinned: write.pinned,
website: write.website,
updated_at: currentTimestamp(platform),
},
);
}
/** CB16 (`deleteNote`, inside its transaction, after CB15's attachment sweep) — `DELETE FROM collab_notes WHERE id = ?`. */
@@ -191,7 +222,11 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
/** CB14 (`deleteNote`'s pre-tx storage-cleanup list) — `SELECT id, filename FROM trip_files WHERE note_id = ?`, run BEFORE the transaction (the legacy ordering: the storage deletes happen outside/before the DB transaction). */
async listFilenamesForNote(note_id: number): Promise<{ id: number; filename: string }[]> {
return await this.kysely<NoteAttachmentsKyselyDB>().selectFrom('trip_files').select(['id', 'filename']).where('note_id', '=', note_id).execute();
return await this.kysely<NoteAttachmentsKyselyDB>()
.selectFrom('trip_files')
.select(['id', 'filename'])
.where('note_id', '=', note_id)
.execute();
}
/** CB15 (`deleteNote`, inside its transaction, before CB16) — `DELETE FROM trip_files WHERE note_id = ?`. */
@@ -206,10 +241,24 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
* supported engine answers, unlike `InsertResult.insertId`) for CB19's
* re-select.
*/
async insertAttachmentForNote(row: { trip_id: number | string; note_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string }): Promise<number> {
async insertAttachmentForNote(row: {
trip_id: number | string;
note_id: number | string;
filename: string;
original_name: string;
file_size: number;
mime_type: string;
}): Promise<number> {
const inserted = await this.kysely<NoteAttachmentInsertKyselyDB>()
.insertInto('trip_files')
.values({ trip_id: row.trip_id as number, note_id: row.note_id as number, filename: row.filename, original_name: row.original_name, file_size: row.file_size, mime_type: row.mime_type })
.values({
trip_id: row.trip_id as number,
note_id: row.note_id as number,
filename: row.filename,
original_name: row.original_name,
file_size: row.file_size,
mime_type: row.mime_type,
})
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
@@ -217,7 +266,11 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
/** CB19 (`addNoteFile`'s re-select, immediately after {@link insertAttachmentForNote}) — `SELECT * FROM trip_files WHERE id = ?`, unscoped (the caller just wrote this exact id). */
async findAttachmentById(id: number): Promise<TripFileRow | undefined> {
return await this.kysely<NoteAttachmentsKyselyDB>().selectFrom('trip_files').selectAll().where('id', '=', id).executeTakeFirst();
return await this.kysely<NoteAttachmentsKyselyDB>()
.selectFrom('trip_files')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
/**
@@ -1,7 +1,7 @@
import type { CollabPolls } from '../entities/CollabPolls.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `collab_polls` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */
export interface CollabPollRow {
@@ -45,7 +45,12 @@ export class CollabPollsRepository extends TrekRepository<CollabPolls> {
/** CB25 (`listPolls`) — `SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC`. */
async listIdsForTrip(trip_id: number): Promise<number[]> {
const rows = await this.kysely<CollabPollsKyselyDB>().selectFrom('collab_polls').select(['id']).where('trip_id', '=', trip_id).orderBy('created_at', 'desc').execute();
const rows = await this.kysely<CollabPollsKyselyDB>()
.selectFrom('collab_polls')
.select(['id'])
.where('trip_id', '=', trip_id)
.orderBy('created_at', 'desc')
.execute();
return rows.map((r) => r.id);
}
@@ -55,12 +60,31 @@ export class CollabPollsRepository extends TrekRepository<CollabPolls> {
* text at all three call sites.
*/
async findInTrip(id: number, trip_id: number): Promise<CollabPollRow | undefined> {
return await this.kysely<CollabPollsKyselyDB>().selectFrom('collab_polls').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst();
return await this.kysely<CollabPollsKyselyDB>()
.selectFrom('collab_polls')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.executeTakeFirst();
}
/** CB26 (`createPoll`) — `INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) VALUES (?×6)`. Returns the new row's id. */
async insertPoll(row: { trip_id: number | string; user_id: number; question: string; options: string; multiple: number; deadline: string | null }): Promise<number> {
return await this.insert({ trip: row.trip_id, user: row.user_id, question: row.question, options: row.options, multiple: row.multiple, deadline: row.deadline });
async insertPoll(row: {
trip_id: number | string;
user_id: number;
question: string;
options: string;
multiple: number;
deadline: string | null;
}): Promise<number> {
return await this.insert({
trip: row.trip_id,
user: row.user_id,
question: row.question,
options: row.options,
multiple: row.multiple,
deadline: row.deadline,
});
}
/** CB33 (`closePoll`) — `UPDATE collab_polls SET closed = 1 WHERE id = ?`. */
@@ -1,7 +1,7 @@
import type { CollectionLabels } from '../entities/CollectionLabels.entity';
import { lower, lowerParam } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
import type { CollectionLabels } from '../entities/CollectionLabels.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** `collection_labels` — `loadLabelsByCollection`'s (CL7) shape. */
export interface CollectionLabelRow {
@@ -61,7 +61,11 @@ export class CollectionLabelsRepository extends TrekRepository<CollectionLabels>
/** CL20 (`insertImportedLabel`) — `INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?,?,?,?)`. Returns the new row's id. Dup text also `createLabel` (Task 2's own call site). */
async insertLabel(row: { collection_id: number; name: string; color: string; sort_order: number }): Promise<number> {
const inserted = await this.kysely<CollectionLabelsInsertKyselyDB>().insertInto('collection_labels').values(row).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<CollectionLabelsInsertKyselyDB>()
.insertInto('collection_labels')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -78,7 +82,11 @@ export class CollectionLabelsRepository extends TrekRepository<CollectionLabels>
* applies here exactly as it does to a bare `.select([...])`.
*/
async collectionIdOf(labelId: number): Promise<number | undefined> {
const row = await this.db_().selectFrom('collection_labels').select('collection_id').where('id', '=', labelId).executeTakeFirst();
const row = await this.db_()
.selectFrom('collection_labels')
.select('collection_id')
.where('id', '=', labelId)
.executeTakeFirst();
return row?.collection_id;
}
@@ -122,7 +130,10 @@ export class CollectionLabelsRepository extends TrekRepository<CollectionLabels>
}
/** CL76 (`updateLabel`) — the allow-listed dynamic `UPDATE collection_labels SET ... WHERE id=?`. No `updated_at` stamp — the table has no such column, and the legacy statement never wrote one. */
async updateFields(labelId: number, write: Partial<{ name: string; color: string; sort_order: number }>): Promise<void> {
async updateFields(
labelId: number,
write: Partial<{ name: string; color: string; sort_order: number }>,
): Promise<void> {
if (Object.keys(write).length === 0) return;
await this.nativeUpdate({ id: labelId }, write);
}
@@ -1,6 +1,6 @@
import type { CollectionMembers } from '../entities/CollectionMembers.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** `buildMembers`'s owner row — `collections` joined to `users` (CL12). */
export interface CollectionOwnerMemberRow {
@@ -108,7 +108,10 @@ export class CollectionMembersRepository extends TrekRepository<CollectionMember
// -------------------------------------------------------------------------
/** CL80 (`sendInvite`) — `SELECT id, status FROM collection_members WHERE collection_id=? AND user_id=?` (dup/pending guard). */
async findByCollectionAndUser(collectionId: number, userId: number): Promise<{ id: number; status: string } | undefined> {
async findByCollectionAndUser(
collectionId: number,
userId: number,
): Promise<{ id: number; status: string } | undefined> {
const row = await this.findOne({ collection: collectionId, user: userId }, { fields: ['id', 'status'] });
return row ?? undefined;
}
@@ -1,6 +1,6 @@
import type { CollectionPlaceRatings } from '../entities/CollectionPlaceRatings.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/**
* `collection_place_ratings` (Plan 3h Task 2, part B — #1435's per-voter
@@ -1,8 +1,8 @@
import type { CollectionPlaces } from '../entities/CollectionPlaces.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import type { CollectionPlaces } from '../entities/CollectionPlaces.entity';
import type { DB } from '../kysely/db';
import type { CollectionPlaceRow } from './Collections.repository';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/**
* `collection_places` (Plan 3h Task 2, part B — saved places CRUD, dedup,
@@ -196,7 +196,10 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
/** CL50 (`updatePlace`'s move branch, TX, dup text also `setPlaceLabels`) — `DELETE FROM collection_place_labels WHERE collection_place_id=?`. Pivot table, Kysely. */
async deleteLabelAssignments(collectionPlaceId: number): Promise<void> {
await this.db_().deleteFrom('collection_place_labels').where('collection_place_id', '=', collectionPlaceId).execute();
await this.db_()
.deleteFrom('collection_place_labels')
.where('collection_place_id', '=', collectionPlaceId)
.execute();
}
// ---------------------------------------------------------------------
@@ -256,7 +259,14 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
async matchingByTripSource(
collectionIds: number[],
tripId: number,
place: { id: number; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null },
place: {
id: number;
lat: number | null;
lng: number | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
},
tolerance: number,
): Promise<{ id: number }[]> {
if (collectionIds.length === 0) return [];
@@ -309,8 +319,23 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
return await this.db_()
.selectFrom('collection_places')
.select([
'id', 'collection_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency',
'notes', 'image_url', 'google_place_id', 'google_ftid', 'osm_id', 'website', 'phone',
'id',
'collection_id',
'name',
'description',
'lat',
'lng',
'address',
'category_id',
'price',
'currency',
'notes',
'image_url',
'google_place_id',
'google_ftid',
'osm_id',
'website',
'phone',
])
.where('id', '=', id)
.executeTakeFirst();
@@ -322,7 +347,11 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
/** CL67 (`copyToTrip`, loop, TX) — `SELECT tag_id FROM collection_place_tags WHERE collection_place_id=?`. Pivot table, Kysely. */
async tagIdsFor(collectionPlaceId: number): Promise<number[]> {
const rows = await this.db_().selectFrom('collection_place_tags').select('tag_id').where('collection_place_id', '=', collectionPlaceId).execute();
const rows = await this.db_()
.selectFrom('collection_place_tags')
.select('tag_id')
.where('collection_place_id', '=', collectionPlaceId)
.execute();
return rows.map((r) => r.tag_id);
}
@@ -433,7 +462,12 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
* `PlacesRepository.existsByImageUrl` for its own table.
*/
async existsByImageUrl(imageUrl: string): Promise<boolean> {
const row = await this.db_().selectFrom('collection_places').select('id').where('image_url', '=', imageUrl).limit(1).executeTakeFirst();
const row = await this.db_()
.selectFrom('collection_places')
.select('id')
.where('image_url', '=', imageUrl)
.limit(1)
.executeTakeFirst();
return !!row;
}
@@ -1,7 +1,7 @@
import type { Collections } from '../entities/Collections.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { dbNow } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** `collections` — every scalar column, `getCollectionRow`'s (CL14) `SELECT *` shape. */
export interface CollectionRow {
@@ -145,7 +145,11 @@ export class CollectionsRepository extends TrekRepository<Collections> {
/** CL5 (`ownerOf`) — `SELECT owner_id FROM collections WHERE id=?`. */
async ownerId(id: number): Promise<number | undefined> {
const row = await this.readDb().selectFrom('collections').select('owner_id').where('id', '=', id).executeTakeFirst();
const row = await this.readDb()
.selectFrom('collections')
.select('owner_id')
.where('id', '=', id)
.executeTakeFirst();
return row?.owner_id;
}
@@ -189,7 +193,11 @@ export class CollectionsRepository extends TrekRepository<Collections> {
links: string | null;
sort_order: number;
}): Promise<number> {
const inserted = await this.insertDb().insertInto('collections').values(row).returning('id').executeTakeFirstOrThrow();
const inserted = await this.insertDb()
.insertInto('collections')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -215,18 +223,30 @@ export class CollectionsRepository extends TrekRepository<Collections> {
}>,
): Promise<void> {
if (Object.keys(write).length === 0) return;
await this.readDb().updateTable('collections').set({ ...write, updated_at: dbNow() }).where('id', '=', id).execute();
await this.readDb()
.updateTable('collections')
.set({ ...write, updated_at: dbNow() })
.where('id', '=', id)
.execute();
}
/** CL31 (`setCollectionCover`) — `SELECT cover_image FROM collections WHERE id=?`. */
async coverImage(id: number): Promise<string | null | undefined> {
const row = await this.readDb().selectFrom('collections').select('cover_image').where('id', '=', id).executeTakeFirst();
const row = await this.readDb()
.selectFrom('collections')
.select('cover_image')
.where('id', '=', id)
.executeTakeFirst();
return row?.cover_image;
}
/** CL32 (`setCollectionCover`) — `UPDATE collections SET cover_image=?, updated_at=<dbNow()> WHERE id=?`. */
async setCoverImage(id: number, coverUrl: string | null): Promise<void> {
await this.readDb().updateTable('collections').set({ cover_image: coverUrl, updated_at: dbNow() }).where('id', '=', id).execute();
await this.readDb()
.updateTable('collections')
.set({ cover_image: coverUrl, updated_at: dbNow() })
.where('id', '=', id)
.execute();
}
/** CL35 (`deleteCollection`) — `DELETE FROM collections WHERE id=?` (CASCADE drops members + places + tags). */
@@ -269,11 +289,35 @@ export class CollectionsRepository extends TrekRepository<Collections> {
.selectFrom('collection_places as cp')
.leftJoin('categories as c', 'c.id', 'cp.category_id')
.select([
'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng',
'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id',
'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id',
'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at',
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
'cp.id',
'cp.collection_id',
'cp.owner_id',
'cp.saved_by',
'cp.name',
'cp.description',
'cp.lat',
'cp.lng',
'cp.address',
'cp.category_id',
'cp.price',
'cp.currency',
'cp.notes',
'cp.image_url',
'cp.google_place_id',
'cp.google_ftid',
'cp.osm_id',
'cp.website',
'cp.phone',
'cp.status',
'cp.source_trip_id',
'cp.source_place_id',
'cp.links',
'cp.sort_order',
'cp.created_at',
'cp.updated_at',
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
])
.where('cp.id', '=', id)
.executeTakeFirst();
@@ -290,11 +334,35 @@ export class CollectionsRepository extends TrekRepository<Collections> {
.selectFrom('collection_places as cp')
.leftJoin('categories as c', 'c.id', 'cp.category_id')
.select([
'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng',
'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id',
'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id',
'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at',
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
'cp.id',
'cp.collection_id',
'cp.owner_id',
'cp.saved_by',
'cp.name',
'cp.description',
'cp.lat',
'cp.lng',
'cp.address',
'cp.category_id',
'cp.price',
'cp.currency',
'cp.notes',
'cp.image_url',
'cp.google_place_id',
'cp.google_ftid',
'cp.osm_id',
'cp.website',
'cp.phone',
'cp.status',
'cp.source_trip_id',
'cp.source_place_id',
'cp.links',
'cp.sort_order',
'cp.created_at',
'cp.updated_at',
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
])
.where('cp.collection_id', '=', collectionId)
.orderBy('cp.sort_order')
@@ -304,7 +372,11 @@ export class CollectionsRepository extends TrekRepository<Collections> {
/** CL11 (`collectionIdOfPlace`) — `SELECT collection_id FROM collection_places WHERE id=?`. */
async collectionIdOfPlace(placeId: number): Promise<number | undefined> {
const row = await this.placesReadDb().selectFrom('collection_places').select('collection_id').where('id', '=', placeId).executeTakeFirst();
const row = await this.placesReadDb()
.selectFrom('collection_places')
.select('collection_id')
.where('id', '=', placeId)
.executeTakeFirst();
return row?.collection_id;
}
@@ -396,7 +468,11 @@ export class CollectionsRepository extends TrekRepository<Collections> {
links: string | null;
sort_order: number;
}): Promise<number> {
const inserted = await this.placesWriteDb().insertInto('collection_places').values(row).returning('id').executeTakeFirstOrThrow();
const inserted = await this.placesWriteDb()
.insertInto('collection_places')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -418,14 +494,23 @@ export class CollectionsRepository extends TrekRepository<Collections> {
.selectFrom('collection_places')
.select(['id', 'name'])
.where('collection_id', '=', collectionId)
.where((eb) => eb.or([eb('google_place_id', '=', externalId), eb('google_ftid', '=', externalId), eb('osm_id', '=', externalId)]))
.where((eb) =>
eb.or([
eb('google_place_id', '=', externalId),
eb('google_ftid', '=', externalId),
eb('osm_id', '=', externalId),
]),
)
.orderBy('id', 'asc')
.limit(1)
.executeTakeFirst();
}
/** CL26 (`findDuplicateCollectionPlace`, name branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lower(trim(name))=? ORDER BY id ASC LIMIT 1`. `normalizedName` is already `lower(trim(...))`-shaped by the caller (`_shared/../dialect/sql-functions.ts`'s `lowerTrim`/`lowerTrimParam` precedent). */
async findDuplicateByName(collectionId: number, normalizedName: string): Promise<{ id: number; name: string } | undefined> {
async findDuplicateByName(
collectionId: number,
normalizedName: string,
): Promise<{ id: number; name: string } | undefined> {
return await this.placesReadDb()
.selectFrom('collection_places')
.select(['id', 'name'])
@@ -60,7 +60,17 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
*/
async findRow(userId: number): Promise<DawarichConnectionRow | null> {
const row = await this.qb('c')
.select(['c.user', 'c.url', 'c.api_key', 'c.allow_insecure_tls', 'c.sync_enabled', 'c.last_sync_at', 'c.last_sync_state', 'c.last_sync_error', 'c.capabilities'])
.select([
'c.user',
'c.url',
'c.api_key',
'c.allow_insecure_tls',
'c.sync_enabled',
'c.last_sync_at',
'c.last_sync_state',
'c.last_sync_error',
'c.capabilities',
])
.where({ user: userId })
.execute<DawarichConnectionRow | undefined>('get', false);
return row ?? null;
@@ -109,7 +119,10 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
* conflict and default to NULL/`'never'` on a genuine INSERT, matching the
* legacy statement's own column list exactly.
*/
async upsertConnection(userId: number, data: { url: string | null; allowInsecureTls: boolean; syncEnabled: boolean }): Promise<void> {
async upsertConnection(
userId: number,
data: { url: string | null; allowInsecureTls: boolean; syncEnabled: boolean },
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.upsert(
{
@@ -119,7 +132,11 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
sync_enabled: data.syncEnabled ? 1 : 0,
updated_at: currentTimestamp(platform),
},
{ onConflictFields: ['user'], onConflictAction: 'merge', onConflictMergeFields: ['url', 'allow_insecure_tls', 'sync_enabled', 'updated_at'] },
{
onConflictFields: ['user'],
onConflictAction: 'merge',
onConflictMergeFields: ['url', 'allow_insecure_tls', 'sync_enabled', 'updated_at'],
},
);
}
@@ -139,7 +156,10 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
* NULL, last_sync_at = NULL WHERE user_id = ?`.
*/
async resetSyncState(userId: number): Promise<void> {
await this.nativeUpdate({ user: userId }, { capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
await this.nativeUpdate(
{ user: userId },
{ capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null },
);
}
/**
@@ -151,7 +171,10 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
* UPDATE with five `SET` clauses).
*/
async clearForRemovedUrl(userId: number): Promise<void> {
await this.nativeUpdate({ user: userId }, { api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
await this.nativeUpdate(
{ user: userId },
{ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null },
);
}
/**
@@ -168,7 +191,10 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
/** DWC10 — `storeCapabilities`: `UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`. */
async storeCapabilities(userId: number, capabilitiesJson: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ user: userId }, { capabilities: capabilitiesJson, updated_at: currentTimestamp(platform) });
await this.nativeUpdate(
{ user: userId },
{ capabilities: capabilitiesJson, updated_at: currentTimestamp(platform) },
);
}
/**
@@ -177,11 +203,19 @@ export class DawarichConnectionsRepository extends TrekRepository<DawarichConnec
* CURRENT_TIMESTAMP WHERE user_id = ?`. **NON-HTTP ENTRYPOINT** — called
* after every cron sync.
*/
async recordSyncResult(userId: number, data: { lastSyncAt: string; state: string; error: string | null }): Promise<void> {
async recordSyncResult(
userId: number,
data: { lastSyncAt: string; state: string; error: string | null },
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ user: userId },
{ last_sync_at: data.lastSyncAt, last_sync_state: data.state, last_sync_error: data.error, updated_at: currentTimestamp(platform) },
{
last_sync_at: data.lastSyncAt,
last_sync_state: data.state,
last_sync_error: data.error,
updated_at: currentTimestamp(platform),
},
);
}
@@ -1,8 +1,8 @@
import { coalesceParam, columnRef } from '../dialect/sql-functions';
import type { DawarichVisitSuggestions } from '../entities/DawarichVisitSuggestions.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** `dawarich_visit_suggestions` — every scalar column, DWS6/DSY3's `SELECT *` shape. */
export interface SuggestionRow {
@@ -1,8 +1,8 @@
import type { DayAccommodations } from '../entities/DayAccommodations.entity';
import { coalesceOverride, columnRef } from '../dialect/sql-functions';
import type { DayAccommodations } from '../entities/DayAccommodations.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/**
* A `day_accommodations` row exactly as AC34 (`AccommodationsService
@@ -103,7 +103,12 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
* raw fragment value (`DayAssignmentsRepository.shiftOrderFrom`'s
* precedent).
*/
async patchTimes(id: number, check_in: string | null, check_in_end: string | null, check_out: string | null): Promise<void> {
async patchTimes(
id: number,
check_in: string | null,
check_in_end: string | null,
check_out: string | null,
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.qb()
.update({
@@ -156,22 +161,28 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
* verbatim (the `PlacesRepository.updatePlace`/PL11 precedent: no SQL-side
* COALESCE here, the legacy statement itself has none for these columns).
*/
async updateFromBooking(id: number, write: {
place_id: number | null;
start_day_id: number;
end_day_id: number;
check_in: string | null;
check_out: string | null;
confirmation: string | null;
}): Promise<void> {
await this.nativeUpdate({ id }, {
place: write.place_id,
startDay: write.start_day_id,
endDay: write.end_day_id,
check_in: write.check_in,
check_out: write.check_out,
confirmation: write.confirmation,
});
async updateFromBooking(
id: number,
write: {
place_id: number | null;
start_day_id: number;
end_day_id: number;
check_in: string | null;
check_out: string | null;
confirmation: string | null;
},
): Promise<void> {
await this.nativeUpdate(
{ id },
{
place: write.place_id,
startDay: write.start_day_id,
endDay: write.end_day_id,
check_in: write.check_in,
check_out: write.check_out,
confirmation: write.confirmation,
},
);
}
/** RS47 — `DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?`, run AFTER `accommodations.dropStayStops` (ordering load-bearing, kept in the service). `id`/`trip_id: number` (typed filter, program rule 23; rule 21 — the SAME `toRowId`-parsed trip id `existsInTrip`'s gate read used). */
@@ -216,7 +227,14 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
// means an unqualified `id` key is ambiguous between `a.id` and `p.id`.
return await this.qb('a')
.leftJoin('a.place', 'p')
.select(['a.*', 'p.name as place_name', 'p.address as place_address', 'p.image_url as place_image', 'p.lat as place_lat', 'p.lng as place_lng'])
.select([
'a.*',
'p.name as place_name',
'p.address as place_address',
'p.image_url as place_image',
'p.lat as place_lat',
'p.lng as place_lng',
])
.where({ 'a.id': id })
.execute<DayAccommodationWithPlaceRow | undefined>('get', false);
}
@@ -307,26 +325,32 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
* (note: NO `|| null` here, unlike AC30 — the legacy statement itself has
* none for these columns, so `''` is stored as `''`).
*/
async updateStay(id: number, write: {
place_id: number | null;
start_day_id: number;
end_day_id: number;
check_in: string | null;
check_in_end: string | null;
check_out: string | null;
confirmation: string | null;
notes: string | null;
}): Promise<void> {
await this.nativeUpdate({ id }, {
place: write.place_id,
startDay: write.start_day_id,
endDay: write.end_day_id,
check_in: write.check_in,
check_in_end: write.check_in_end,
check_out: write.check_out,
confirmation: write.confirmation,
notes: write.notes,
});
async updateStay(
id: number,
write: {
place_id: number | null;
start_day_id: number;
end_day_id: number;
check_in: string | null;
check_in_end: string | null;
check_out: string | null;
confirmation: string | null;
notes: string | null;
},
): Promise<void> {
await this.nativeUpdate(
{ id },
{
place: write.place_id,
startDay: write.start_day_id,
endDay: write.end_day_id,
check_in: write.check_in,
check_in_end: write.check_in_end,
check_out: write.check_out,
confirmation: write.confirmation,
notes: write.notes,
},
);
}
/**
@@ -364,10 +388,17 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
* `RoadtripDayBoundariesRepository` trap, Task 1's report), so both go
* through `columnRef`.
*/
async listForResync(trip_id: number): Promise<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]> {
async listForResync(
trip_id: number,
): Promise<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('a')
.select(['a.id', columnRef(platform, 'a.start_day_id').as('start_day_id'), columnRef(platform, 'a.end_day_id').as('end_day_id'), 'a.check_in'])
.select([
'a.id',
columnRef(platform, 'a.start_day_id').as('start_day_id'),
columnRef(platform, 'a.end_day_id').as('end_day_id'),
'a.check_in',
])
.where({ trip: trip_id })
.execute<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]>('all', false);
}
@@ -419,10 +450,7 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
* own `findInTrip` projection), reused here rather than declared again.
*/
async listAllForTrip(trip_id: number): Promise<DayAccommodationRow[]> {
return await this.qb('a')
.select(['a.*'])
.where({ trip: trip_id })
.execute<DayAccommodationRow[]>('all', false);
return await this.qb('a').select(['a.*']).where({ trip: trip_id }).execute<DayAccommodationRow[]>('all', false);
}
// ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
import type { DayNotes } from '../entities/DayNotes.entity';
import type { DB } from '../kysely/db';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
export interface DayNoteRow {
id: number;
@@ -87,10 +87,7 @@ export class DayNotesRepository extends TrekRepository<DayNotes> {
* relies on read order).
*/
async listByTrip(trip_id: number | string): Promise<DayNoteRow[]> {
return await this.qb('n')
.select(['n.*'])
.where('n.trip_id = ?', [trip_id])
.execute<DayNoteRow[]>('all', false);
return await this.qb('n').select(['n.*']).where('n.trip_id = ?', [trip_id]).execute<DayNoteRow[]>('all', false);
}
/**
@@ -145,7 +142,11 @@ export class DayNotesRepository extends TrekRepository<DayNotes> {
* AND day_id = ? AND trip_id = ?`. Same raw-bind seam as
* {@link listByDayAndTrip}.
*/
async findByIdDayTrip(id: number | string, day_id: number | string, trip_id: number | string): Promise<DayNoteRow | undefined> {
async findByIdDayTrip(
id: number | string,
day_id: number | string,
trip_id: number | string,
): Promise<DayNoteRow | undefined> {
return await this.qb('n')
.select(['n.*'])
.where('n.id = ? AND n.day_id = ? AND n.trip_id = ?', [id, day_id, trip_id])
@@ -160,29 +161,32 @@ export class DayNotesRepository extends TrekRepository<DayNotes> {
* already resolves each one's `fields.x !== undefined ? fields.x :
* current.x` fallback, same shape as `createNote`'s docstring).
*/
async updateNote(id: number | string, fields: {
text: string;
time: string | null;
icon: string | null;
sort_order: number | null;
color: string | null;
}): Promise<DayNoteRow | undefined> {
async updateNote(
id: number | string,
fields: {
text: string;
time: string | null;
icon: string | null;
sort_order: number | null;
color: string | null;
},
): Promise<DayNoteRow | undefined> {
await this.qb('n')
.update({ text: fields.text, time: fields.time, icon: fields.icon, sort_order: fields.sort_order, color: fields.color })
.update({
text: fields.text,
time: fields.time,
icon: fields.icon,
sort_order: fields.sort_order,
color: fields.color,
})
.where('n.id = ?', [id])
.execute('run');
return await this.qb('n')
.select(['n.*'])
.where('n.id = ?', [id])
.execute<DayNoteRow | undefined>('get', false);
return await this.qb('n').select(['n.*']).where('n.id = ?', [id]).execute<DayNoteRow | undefined>('get', false);
}
/** `day-notes.service.ts::remove` — `DELETE FROM day_notes WHERE id = ?`. */
async deleteById(id: number | string): Promise<void> {
await this.qb('n')
.delete()
.where('n.id = ?', [id])
.execute('run');
await this.qb('n').delete().where('n.id = ?', [id]).execute('run');
}
/**
@@ -1,8 +1,8 @@
import type { Days } from '../entities/Days.entity';
import { columnRef } from '../dialect/sql-functions';
import type { Days } from '../entities/Days.entity';
import type { DB } from '../kysely/db';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A `days` row as the API emits it. */
export interface DayRow {
@@ -317,10 +317,12 @@ export class DaysRepository extends TrekRepository<Days> {
.selectFrom('days as d')
.select(['d.id', 'd.day_number', 'd.date'])
.select((eb) =>
eb.or([
eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.day_id', '=', 'd.id')),
eb.exists(eb.selectFrom('day_notes as dn').select('dn.id').whereRef('dn.day_id', '=', 'd.id')),
]).as('has_plan_items'),
eb
.or([
eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.day_id', '=', 'd.id')),
eb.exists(eb.selectFrom('day_notes as dn').select('dn.id').whereRef('dn.day_id', '=', 'd.id')),
])
.as('has_plan_items'),
)
.where('d.trip_id', '=', trip_id)
.orderBy('d.day_number', 'asc')
+97 -16
View File
@@ -1,16 +1,24 @@
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
import { Users } from '../entities/Users.entity';
import type { NewAdminUserRow } from './Users.repository';
import { currentTimestampKysely } from '../dialect/kysely-functions';
import { AppSettings } from '../entities/AppSettings.entity';
import { Trips } from '../entities/Trips.entity';
import { currentTimestampKysely } from '../dialect/kysely-functions';
import { Users } from '../entities/Users.entity';
import type { DB } from '../kysely/db';
import type { NewAdminUserRow } from './Users.repository';
import type { EntityManager } from '@mikro-orm/core';
import type { SqlEntityManager } from '@mikro-orm/sql';
/** The tables the example-trip seed writes through Kysely. */
type DemoSeedKyselyDB = Pick<
DB,
'trip_members' | 'trips' | 'days' | 'places' | 'day_assignments' | 'packing_items' | 'budget_items' | 'reservations' | 'day_notes'
| 'trip_members'
| 'trips'
| 'days'
| 'places'
| 'day_assignments'
| 'packing_items'
| 'budget_items'
| 'reservations'
| 'day_notes'
>;
/** DMR1's projection (`resetDemoUser`'s pre-close credential read). */
@@ -165,7 +173,14 @@ export class DemoRepository {
}
/** DMS7 — `INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)`. Returns the generated trip id. */
async insertTrip(userId: number, title: string, description: string, startDate: string, endDate: string, currency: string): Promise<number> {
async insertTrip(
userId: number,
title: string,
description: string,
startDate: string,
endDate: string,
currency: string,
): Promise<number> {
const inserted = await this.kysely()
.insertInto('trips')
.values({ user_id: userId, title, description, start_date: startDate, end_date: endDate, currency })
@@ -186,11 +201,37 @@ export class DemoRepository {
/** DMS9 — the 13-column `places` insert (see {@link NewDemoPlaceRow}). Returns the generated place id. */
async insertPlace(row: NewDemoPlaceRow): Promise<number> {
const [trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone] = row;
const [
trip_id,
name,
lat,
lng,
address,
category_id,
place_time,
duration_minutes,
notes,
image_url,
google_place_id,
website,
phone,
] = row;
const inserted = await this.kysely()
.insertInto('places')
.values({
trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone,
trip_id,
name,
lat,
lng,
address,
category_id,
place_time,
duration_minutes,
notes,
image_url,
google_place_id,
website,
phone,
})
.returning('id')
.executeTakeFirstOrThrow();
@@ -199,20 +240,43 @@ export class DemoRepository {
/** DMS10 — `INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)`. */
async insertDayAssignment(dayId: number, placeId: number, orderIndex: number): Promise<void> {
await this.kysely().insertInto('day_assignments').values({ day_id: dayId, place_id: placeId, order_index: orderIndex }).execute();
await this.kysely()
.insertInto('day_assignments')
.values({ day_id: dayId, place_id: placeId, order_index: orderIndex })
.execute();
}
/** DMS11 — `INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`. */
async insertPackingItem(tripId: number, name: string, checked: number, category: string, sortOrder: number): Promise<void> {
async insertPackingItem(
tripId: number,
name: string,
checked: number,
category: string,
sortOrder: number,
): Promise<void> {
const platform = this.em.getPlatform();
await this.kysely()
.insertInto('packing_items')
.values({ trip_id: tripId, name, checked, category, sort_order: sortOrder, updated_at: currentTimestampKysely(platform) })
.values({
trip_id: tripId,
name,
checked,
category,
sort_order: sortOrder,
updated_at: currentTimestampKysely(platform),
})
.execute();
}
/** DMS12 — `INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)`. */
async insertBudgetItem(tripId: number, category: string, name: string, totalPrice: number, persons: number, note: string | null): Promise<void> {
async insertBudgetItem(
tripId: number,
category: string,
name: string,
totalPrice: number,
persons: number,
note: string | null,
): Promise<void> {
await this.kysely()
.insertInto('budget_items')
.values({ trip_id: tripId, category, name, total_price: totalPrice, persons, note })
@@ -238,13 +302,27 @@ export class DemoRepository {
await this.kysely()
.insertInto('reservations')
.values({
trip_id: tripId, day_id: dayId, title, reservation_time: reservationTime, confirmation_number: confirmationNumber, status, type, location,
trip_id: tripId,
day_id: dayId,
title,
reservation_time: reservationTime,
confirmation_number: confirmationNumber,
status,
type,
location,
})
.execute();
}
/** DMS14 — `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)`. */
async insertDayNote(dayId: number, tripId: number, text: string, time: string, icon: string, sortOrder: number): Promise<void> {
async insertDayNote(
dayId: number,
tripId: number,
text: string,
time: string,
icon: string,
sortOrder: number,
): Promise<void> {
await this.kysely()
.insertInto('day_notes')
.values({ day_id: dayId, trip_id: tripId, text, time, icon, sort_order: sortOrder })
@@ -265,7 +343,10 @@ export class DemoRepository {
async getAdminCredentials(email: string): Promise<DemoAdminCredentialsRow | null> {
const row = await this.em
.getRepository(Users)
.findOne({ email }, { fields: ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar'] });
.findOne(
{ email },
{ fields: ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar'] },
);
return row
? {
password_hash: row.password_hash,
@@ -1,8 +1,8 @@
import { currentTimestamp, coalesceOverride } from '../dialect/sql-functions';
import type { DocumentConnections } from '../entities/DocumentConnections.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/**
* A bare `document_connections` row. `secrets` is opaque encrypted TEXT here
@@ -72,7 +72,13 @@ export class DocumentConnectionsRepository extends TrekRepository<DocumentConnec
*/
async updateConnection(
id: number,
data: { base_url: string; secrets: string | null; settings: string; allow_insecure_tls: number; owner_user_id: number },
data: {
base_url: string;
secrets: string | null;
settings: string;
allow_insecure_tls: number;
owner_user_id: number;
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
@@ -156,15 +162,12 @@ export class DocumentConnectionsRepository extends TrekRepository<DocumentConnec
.selectFrom('document_connections as c')
.select('c.id')
.where((eb) =>
eb(
'c.owner_user_id',
'not in',
(qb) =>
qb
.selectFrom('trip_members as tm')
.select('tm.user_id')
.whereRef('tm.trip_id', '=', 'c.trip_id')
.union(qb.selectFrom('trips as t').select('t.user_id').whereRef('t.id', '=', 'c.trip_id')),
eb('c.owner_user_id', 'not in', (qb) =>
qb
.selectFrom('trip_members as tm')
.select('tm.user_id')
.whereRef('tm.trip_id', '=', 'c.trip_id')
.union(qb.selectFrom('trips as t').select('t.user_id').whereRef('t.id', '=', 'c.trip_id')),
),
)
.execute();
@@ -67,7 +67,12 @@ export class DocumentProvidersRepository extends TrekRepository<DocumentProvider
{ enabled: 1 },
{ fields: ['id', 'name', 'description', 'icon'], orderBy: { sort_order: 'asc' } },
);
return rows.map((r) => ({ id: r.id as string, name: r.name, description: r.description ?? null, icon: r.icon ?? null }));
return rows.map((r) => ({
id: r.id as string,
name: r.name,
description: r.description ?? null,
icon: r.icon ?? null,
}));
}
// ---------------------------------------------------------------------
@@ -1,10 +1,10 @@
import type { EntityData } from '@mikro-orm/core';
import { coalesceOverride, currentTimestamp, foundAgainState, nowPlusSeconds } from '../dialect/sql-functions';
import { currentTimestampKysely, nowPlusSecondsKysely } from '../dialect/kysely-functions';
import { coalesceOverride, currentTimestamp, foundAgainState, nowPlusSeconds } from '../dialect/sql-functions';
import type { DocumentSyncItems } from '../entities/DocumentSyncItems.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import type { EntityData } from '@mikro-orm/core';
/** A bare `document_sync_items` row (every scalar column). */
export interface DocumentSyncItemRow {
@@ -98,7 +98,10 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
// ── applyAction's eight state-machine UPDATEs (DS2-DS12) ──────────────────
/** DS2 (`applyAction` 'relocate') — `UPDATE ... SET remote_id=?, remote_version=?, remote_size=COALESCE(?,remote_size), remote_modified_at=COALESCE(?,remote_modified_at), ${FOUND_AGAIN}, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
async applyRelocate(id: number, data: { remote_id: string; remote_version: string; remote_size: number | null; remote_modified_at: string | null }): Promise<void> {
async applyRelocate(
id: number,
data: { remote_id: string; remote_version: string; remote_size: number | null; remote_modified_at: string | null },
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
@@ -152,7 +155,14 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
/** DS7 (`applyAction` 'mark_remote_missing') — `UPDATE ... SET state='remote_missing', remote_missing_at=CURRENT_TIMESTAMP, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
async markRemoteMissing(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { state: 'remote_missing', remote_missing_at: currentTimestamp(platform), last_seen_at: currentTimestamp(platform) });
await this.nativeUpdate(
{ id },
{
state: 'remote_missing',
remote_missing_at: currentTimestamp(platform),
last_seen_at: currentTimestamp(platform),
},
);
}
/**
@@ -201,7 +211,14 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{ state: 'pending', remote_id: null, remote_version: null, remote_trashed_at: null, error_code: null, last_seen_at: currentTimestamp(platform) },
{
state: 'pending',
remote_id: null,
remote_version: null,
remote_trashed_at: null,
error_code: null,
last_seen_at: currentTimestamp(platform),
},
);
}
@@ -214,7 +231,13 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
/** DS12 (`applyAction` 'touch') — the most-used state-machine action: `UPDATE ... SET last_seen_at=CURRENT_TIMESTAMP, remote_version=COALESCE(?,remote_version), remote_name=COALESCE(?,remote_name), remote_id=COALESCE(?,remote_id), remote_size=COALESCE(?,remote_size), remote_modified_at=COALESCE(?,remote_modified_at), ${FOUND_AGAIN} WHERE id=?`. */
async applyTouch(
id: number,
data: { remote_version: string | null; remote_name: string | null; remote_id: string | null; remote_size: number | null; remote_modified_at: string | null },
data: {
remote_version: string | null;
remote_name: string | null;
remote_id: string | null;
remote_size: number | null;
remote_modified_at: string | null;
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
@@ -235,13 +258,21 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
// ── Reads ───────────────────────────────────────────────────────────────
/** DS13 (`pull`'s pre-image read) — `SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id=?`. */
async findPairing(id: number): Promise<{ file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined> {
async findPairing(
id: number,
): Promise<{ file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined> {
const row = await this.kysely<DocumentSyncItemsKyselyDB>()
.selectFrom('document_sync_items')
.select(['file_id', 'remote_name', 'content_sha256'])
.where('id', '=', id)
.executeTakeFirst();
return row ? { file_id: row.file_id ?? null, remote_name: row.remote_name ?? null, content_sha256: row.content_sha256 ?? null } : undefined;
return row
? {
file_id: row.file_id ?? null,
remote_name: row.remote_name ?? null,
content_sha256: row.content_sha256 ?? null,
}
: undefined;
}
/**
@@ -261,8 +292,21 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
const rows = await this.kysely<DocumentSyncItemsKyselyDB>()
.selectFrom('document_sync_items')
.select([
'id', 'file_id', 'trek_doc_uid', 'remote_id', 'remote_version', 'remote_name', 'remote_size', 'remote_modified_at',
'content_sha256', 'pushed_sha256', 'state', 'attempts', 'next_attempt_at', 'remote_missing_at', 'remote_trashed_at',
'id',
'file_id',
'trek_doc_uid',
'remote_id',
'remote_version',
'remote_name',
'remote_size',
'remote_modified_at',
'content_sha256',
'pushed_sha256',
'state',
'attempts',
'next_attempt_at',
'remote_missing_at',
'remote_trashed_at',
])
.where('link_id', '=', linkId)
.execute();
@@ -309,7 +353,11 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
/** DS22 (`upsertItem`'s read-before-write attempt counter) — `SELECT attempts FROM document_sync_items WHERE id=?`. */
async getAttempts(id: number): Promise<number | undefined> {
const row = await this.kysely<DocumentSyncItemsKyselyDB>().selectFrom('document_sync_items').select('attempts').where('id', '=', id).executeTakeFirst();
const row = await this.kysely<DocumentSyncItemsKyselyDB>()
.selectFrom('document_sync_items')
.select('attempts')
.where('id', '=', id)
.executeTakeFirst();
return row?.attempts;
}
@@ -374,7 +422,8 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
content_sha256: coalesceOverride(platform, patch.content_sha256, 'content_sha256'),
pushed_sha256: coalesceOverride(platform, patch.pushed_sha256, 'pushed_sha256'),
attempts: patch.attempts,
next_attempt_at: patch.next_attempt_after_seconds !== null ? nowPlusSeconds(platform, patch.next_attempt_after_seconds) : null,
next_attempt_at:
patch.next_attempt_after_seconds !== null ? nowPlusSeconds(platform, patch.next_attempt_after_seconds) : null,
remote_missing_at: null,
last_seen_at: currentTimestamp(platform),
};
@@ -444,7 +493,9 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
error_code: data.error_code,
attempts: data.attempts,
next_attempt_at:
data.next_attempt_after_seconds !== null ? nowPlusSecondsKysely(platform, eb, data.next_attempt_after_seconds) : null,
data.next_attempt_after_seconds !== null
? nowPlusSecondsKysely(platform, eb, data.next_attempt_after_seconds)
: null,
synced_at: data.synced_now ? currentTimestampKysely(platform) : null,
last_seen_at: currentTimestampKysely(platform),
}))
@@ -456,9 +507,12 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
state: (eb) => eb.ref('excluded.state'),
error_code: (eb) => eb.ref('excluded.error_code'),
file_id: (eb) => eb.fn.coalesce(eb.ref('excluded.file_id'), eb.ref('document_sync_items.file_id')),
remote_version: (eb) => eb.fn.coalesce(eb.ref('excluded.remote_version'), eb.ref('document_sync_items.remote_version')),
content_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.content_sha256'), eb.ref('document_sync_items.content_sha256')),
pushed_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.pushed_sha256'), eb.ref('document_sync_items.pushed_sha256')),
remote_version: (eb) =>
eb.fn.coalesce(eb.ref('excluded.remote_version'), eb.ref('document_sync_items.remote_version')),
content_sha256: (eb) =>
eb.fn.coalesce(eb.ref('excluded.content_sha256'), eb.ref('document_sync_items.content_sha256')),
pushed_sha256: (eb) =>
eb.fn.coalesce(eb.ref('excluded.pushed_sha256'), eb.ref('document_sync_items.pushed_sha256')),
last_seen_at: () => currentTimestampKysely(platform),
}),
)
@@ -470,7 +524,10 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
/** DS27 (`renameRemoteTo`) — `UPDATE ... SET remote_name=?, remote_version=?, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. No `FOUND_AGAIN` — a distinct, simpler shape from DS3 (both rename the same two columns, but only DS3's caller is inside the reconciler's state machine). */
async setRemoteName(id: number, data: { remote_name: string; remote_version: string }): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { remote_name: data.remote_name, remote_version: data.remote_version, last_seen_at: currentTimestamp(platform) });
await this.nativeUpdate(
{ id },
{ remote_name: data.remote_name, remote_version: data.remote_version, last_seen_at: currentTimestamp(platform) },
);
}
/** DS28 (`resolveConflict`'s pre-image read) — `SELECT * FROM document_sync_items WHERE id=?`. */
@@ -511,7 +568,14 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
return await this.kysely<DocumentSyncItemsKyselyDB>()
.selectFrom('document_sync_items as i')
.leftJoin('trip_files as f', 'f.id', 'i.file_id')
.select(['i.id', 'i.state', 'i.error_code', 'i.remote_name', 'i.remote_missing_at', 'f.original_name as file_name'])
.select([
'i.id',
'i.state',
'i.error_code',
'i.remote_name',
'i.remote_missing_at',
'f.original_name as file_name',
])
.where('i.trip_id', '=', tripId)
.where('i.state', 'in', [...ISSUE_STATES])
.orderBy('i.id', 'desc')
@@ -548,7 +612,9 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
.sum<number>(
eb
.case()
.when(eb.and([eb('file_id', 'is not', null), eb('remote_id', 'is not', null), eb('state', '=', 'synced')]))
.when(
eb.and([eb('file_id', 'is not', null), eb('remote_id', 'is not', null), eb('state', '=', 'synced')]),
)
.then(1)
.else(0)
.end(),
@@ -571,7 +637,16 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
.end(),
)
.as('atProvider'),
eb.fn.sum<number>(eb.case().when(eb('state', '=', 'remote_missing')).then(1).else(0).end()).as('missing'),
eb.fn
.sum<number>(
eb
.case()
.when(eb('state', '=', 'remote_missing'))
.then(1)
.else(0)
.end(),
)
.as('missing'),
])
.where('trip_id', '=', tripId)
.groupBy('link_id')
@@ -590,7 +665,12 @@ export class DocumentSyncItemsRepository extends TrekRepository<DocumentSyncItem
* stay three methods, the WHERE/LIMIT shapes are not identical).
*/
async listForTripByState(tripId: number, state: string): Promise<DocumentSyncItemWithFileRow[]> {
return await this.itemsWithFileQuery().where('i.trip_id', '=', tripId).where('i.state', '=', state).orderBy('i.id', 'desc').limit(500).execute();
return await this.itemsWithFileQuery()
.where('i.trip_id', '=', tripId)
.where('i.state', '=', state)
.orderBy('i.id', 'desc')
.limit(500)
.execute();
}
/** DSCTRL5 (`DocSyncController.items`, unfiltered) — same as {@link listForTripByState} minus the state predicate. */
@@ -1,7 +1,7 @@
import type { FileLinks } from '../entities/FileLinks.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `file_links` row — every scalar column of the entity, incl. its five `persist(false)` relation mirrors. */
export interface FileLinkRow {
@@ -77,7 +77,13 @@ export class FileLinksRepository extends TrekRepository<FileLinks> {
* legacy sites (FL10/FL13 pass only `budget_item_id`; FL24 passes any
* combination the caller supplies) — one method, not three.
*/
async insertIgnore(row: { file_id: number; reservation_id?: number | null; assignment_id?: number | null; place_id?: number | null; budget_item_id?: number | null }): Promise<void> {
async insertIgnore(row: {
file_id: number;
reservation_id?: number | null;
assignment_id?: number | null;
place_id?: number | null;
budget_item_id?: number | null;
}): Promise<void> {
await this.kysely<FileLinksWriteKyselyDB>()
.insertInto('file_links')
.values({
@@ -111,7 +117,11 @@ export class FileLinksRepository extends TrekRepository<FileLinks> {
/** FL25 (`createFileLink`'s bare re-select) — `SELECT * FROM file_links WHERE file_id = ?`. */
async listForFile(file_id: number): Promise<FileLinkRow[]> {
return await this.kysely<FileLinksKyselyDB>().selectFrom('file_links').selectAll().where('file_id', '=', file_id).execute();
return await this.kysely<FileLinksKyselyDB>()
.selectFrom('file_links')
.selectAll()
.where('file_id', '=', file_id)
.execute();
}
/** FL27 (`FilesService.getFileLinks`) — `SELECT fl.*, r.title as reservation_title FROM file_links fl LEFT JOIN reservations r ON fl.reservation_id = r.id WHERE fl.file_id = ?`. */
@@ -1,8 +1,8 @@
import type { GoogleApiUsage } from '../entities/GoogleApiUsage.entity';
import { nowDateOffset } from '../dialect/sql-functions';
import type { GoogleApiUsage } from '../entities/GoogleApiUsage.entity';
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A `google_api_usage` row: Google API calls counted per UTC day (#1582). */
export interface GoogleApiUsageRow {
@@ -1,6 +1,6 @@
import type { HiddenCountries } from '../entities/HiddenCountries.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type HiddenCountriesKyselyDB = Pick<DB, 'hidden_countries'>;
@@ -17,7 +17,11 @@ export class HiddenCountriesRepository extends TrekRepository<HiddenCountries> {
/** AT11 (`getHiddenCountries`) — `SELECT country_code FROM hidden_countries WHERE user_id = ?`. */
async listForUser(userId: number): Promise<string[]> {
const rows = await this.db().selectFrom('hidden_countries').select('country_code').where('user_id', '=', userId).execute();
const rows = await this.db()
.selectFrom('hidden_countries')
.select('country_code')
.where('user_id', '=', userId)
.execute();
return rows.map((r) => r.country_code);
}
@@ -32,6 +36,10 @@ export class HiddenCountriesRepository extends TrekRepository<HiddenCountries> {
/** AT13/AT20 (`markCountry`'s and `markRegion`'s "lift a prior removal" delete — identical text, two call sites) — `DELETE FROM hidden_countries WHERE user_id = ? AND country_code = ?`. */
async unhide(userId: number, countryCode: string): Promise<void> {
await this.db().deleteFrom('hidden_countries').where('user_id', '=', userId).where('country_code', '=', countryCode).execute();
await this.db()
.deleteFrom('hidden_countries')
.where('user_id', '=', userId)
.where('country_code', '=', countryCode)
.execute();
}
}
@@ -1,6 +1,6 @@
import type { HiddenRegions } from '../entities/HiddenRegions.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type HiddenRegionsKyselyDB = Pick<DB, 'hidden_regions'>;
@@ -18,7 +18,11 @@ export class HiddenRegionsRepository extends TrekRepository<HiddenRegions> {
/** AT22 (`getHiddenRegions`) — `SELECT region_code FROM hidden_regions WHERE user_id = ?`. */
async listForUser(userId: number): Promise<string[]> {
const rows = await this.db().selectFrom('hidden_regions').select('region_code').where('user_id', '=', userId).execute();
const rows = await this.db()
.selectFrom('hidden_regions')
.select('region_code')
.where('user_id', '=', userId)
.execute();
return rows.map((r) => r.region_code);
}
@@ -33,6 +37,10 @@ export class HiddenRegionsRepository extends TrekRepository<HiddenRegions> {
/** AT18 (`markRegion`, inside its transaction) — `DELETE FROM hidden_regions WHERE user_id = ? AND region_code = ?`. */
async unhide(userId: number, regionCode: string): Promise<void> {
await this.db().deleteFrom('hidden_regions').where('user_id', '=', userId).where('region_code', '=', regionCode).execute();
await this.db()
.deleteFrom('hidden_regions')
.where('user_id', '=', userId)
.where('region_code', '=', regionCode)
.execute();
}
}
@@ -19,11 +19,13 @@ export class IdempotencyKeysRepository extends TrekRepository<IdempotencyKeys> {
* response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND
* method = ? AND path = ?`.
*/
async findResponse(key: string, userId: number, method: string, path: string): Promise<IdempotencyResponseRow | null> {
const row = await this.findOne(
{ key, user: userId, method, path },
{ fields: ['status_code', 'response_body'] },
);
async findResponse(
key: string,
userId: number,
method: string,
path: string,
): Promise<IdempotencyResponseRow | null> {
const row = await this.findOne({ key, user: userId, method, path }, { fields: ['status_code', 'response_body'] });
return row ? { status_code: row.status_code, response_body: row.response_body } : null;
}
@@ -1,6 +1,6 @@
import { columnIncrementedBy, columnRef } from '../dialect/sql-functions';
import type { InviteTokens } from '../entities/InviteTokens.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { columnIncrementedBy, columnRef } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
/** An `invite_tokens` row as the API emits it. */
@@ -84,7 +84,11 @@ export class JourneyBooksRepository extends TrekRepository<JourneyBooks> {
* means a conflict — the service re-reads via {@link findById} for the
* `{conflict}` response, it does not throw.
*/
async casUpdate(id: number, baseVersion: number, data: { title: string; document: string; updatedBy: number }): Promise<number> {
async casUpdate(
id: number,
baseVersion: number,
data: { title: string; document: string; updatedBy: number },
): Promise<number> {
const platform = this.getEntityManager().getPlatform();
return await this.nativeUpdate(
{ id, version: baseVersion },
@@ -1,6 +1,6 @@
import type { JourneyContributors } from '../entities/JourneyContributors.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */
export interface JourneyContributorListRow {
@@ -1,7 +1,7 @@
import type { JourneyEntry } from '../../types';
import type { JourneyEntries } from '../entities/JourneyEntries.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** JG55's wider reconciliation projection (`reconcileTripSkeletons`) — a distinct column set from JG35's dedup-key-only read. */
export interface ReconcileEntryRow {
@@ -42,7 +42,10 @@ export interface PhotoPlacementRow {
}
/** The `trek_photos`/`journey_photos`/`journey_entry_photos`/`journey_entries`/`journeys` tables {@link JourneyEntriesRepository.listPhotoPlacementCandidates} reads. */
type PhotoPlacementKyselyDB = Pick<DB, 'trek_photos' | 'journey_photos' | 'journey_entry_photos' | 'journey_entries' | 'journeys'>;
type PhotoPlacementKyselyDB = Pick<
DB,
'trek_photos' | 'journey_photos' | 'journey_entry_photos' | 'journey_entries' | 'journeys'
>;
/** `journey_entry_photos`, read cross-table (no `JourneyEntryPhotosRepository` yet — Task 2's own; the two `hasPhotos` guards in Part A only ever check existence). */
type JourneyEntryPhotosExistsKyselyDB = Pick<DB, 'journey_entry_photos'>;
@@ -200,7 +203,10 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
* "fixed" into a redundant one.
*/
async detachAllFilledForTrip(tripId: number): Promise<void> {
await this.nativeUpdate({ sourceTrip: tripId }, { sourcePlace: null, sourceTrip: null, source_assignment_id: null });
await this.nativeUpdate(
{ sourceTrip: tripId },
{ sourcePlace: null, sourceTrip: null, source_assignment_id: null },
);
}
/**
@@ -214,7 +220,10 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
}
/** JG35 — `syncTripPlaces`'s existing-skeleton dedup-key read: `SELECT source_place_id, source_assignment_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?`. */
async listSourceKeysForTrip(journeyId: number, tripId: number): Promise<{ source_place_id: number; source_assignment_id: number | null }[]> {
async listSourceKeysForTrip(
journeyId: number,
tripId: number,
): Promise<{ source_place_id: number; source_assignment_id: number | null }[]> {
return await this.qb('je')
.select(['je.sourcePlace', 'je.source_assignment_id'])
.where({ journey: journeyId, sourceTrip: tripId })
@@ -323,7 +332,13 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
/** JG47/JG59 — the location-only silent update (`onPlaceUpdated`'s filled-entry branch, `reconcileTripSkeletons`'s stale-filled-entry branch), one statement text. */
async updateLocationOnly(
id: number,
patch: { location_name: string; location_lat: number | null; location_lng: number | null; country_code: string | null; updated_at: number },
patch: {
location_name: string;
location_lat: number | null;
location_lng: number | null;
country_code: string | null;
updated_at: number;
},
): Promise<void> {
await this.nativeUpdate({ id }, patch);
}
@@ -390,7 +405,17 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
*/
async listStatsRows(journeyId: number): Promise<StatsEntryRow[]> {
return await this.qb('je')
.select(['je.id', 'je.title', 'je.location_name', 'je.location_lat', 'je.location_lng', 'je.entry_date', 'je.sourceTrip', 'je.sourcePlace', 'je.stats_excluded'])
.select([
'je.id',
'je.title',
'je.location_name',
'je.location_lat',
'je.location_lng',
'je.entry_date',
'je.sourceTrip',
'je.sourcePlace',
'je.stats_excluded',
])
.where({ journey: journeyId, dismissed: 0 })
.orderBy({ entry_date: 'asc', sort_order: 'asc', id: 'asc' })
.execute<StatsEntryRow[]>('all', false);
@@ -431,7 +456,15 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
.innerJoin('places as p', 'p.trip_id', 'jt.trip_id')
.leftJoin('day_assignments as da', 'da.place_id', 'p.id')
.leftJoin('days as d', 'd.id', 'da.day_id')
.select((eb) => ['p.id', 'p.name', 'p.lat', 'p.lng', 'p.trip_id as tripId', eb.fn.min<string | null>('d.date').as('day'), eb.fn.min<number | null>('da.order_index').as('ord')])
.select((eb) => [
'p.id',
'p.name',
'p.lat',
'p.lng',
'p.trip_id as tripId',
eb.fn.min<string | null>('d.date').as('day'),
eb.fn.min<number | null>('da.order_index').as('ord'),
])
.where('jt.journey_id', '=', journeyId)
.groupBy('p.id')
.orderBy((eb) => eb(eb.fn.min<string | null>('d.date'), 'is', null), 'asc')
@@ -466,7 +499,9 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
* (r.country_code)` filter and the `.toUpperCase()` call both stay in the
* SERVICE, matching the legacy code's own JS-side handling.
*/
async listCachedCountriesForPlaceIds(placeIds: number[]): Promise<{ place_id: number; country_code: string | null }[]> {
async listCachedCountriesForPlaceIds(
placeIds: number[],
): Promise<{ place_id: number; country_code: string | null }[]> {
const out: { place_id: number; country_code: string | null }[] = [];
for (let i = 0; i < placeIds.length; i += 400) {
// M4 (task-5-review.md) — `chunk` can never be empty here: the loop
@@ -597,7 +632,18 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
/** JG85 — `deleteEntry`'s revert-to-skeleton write (a filled, trip-sourced entry is "deleted" by reverting it, not removed — {@link deleteById} (JG50/61/86) is the true hard delete, for entries with no trip origin). */
async revertToSkeleton(id: number, updatedAt: number): Promise<void> {
await this.nativeUpdate({ id }, { type: 'skeleton', story: null, mood: null, weather: null, pros_cons: null, visibility: 'private', updated_at: updatedAt });
await this.nativeUpdate(
{ id },
{
type: 'skeleton',
story: null,
mood: null,
weather: null,
pros_cons: null,
visibility: 'private',
updated_at: updatedAt,
},
);
}
/** JG87 — `promoteSkeletonIfNeeded`'s write: `UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?`, called only when the entry is still a skeleton (the SERVICE's own `if (entry.type !== 'skeleton') return;` guard, unchanged). */
@@ -1,17 +1,33 @@
import type { JourneyPhoto } from '../../types';
import type { JourneyEntryPhotos } from '../entities/JourneyEntryPhotos.entity';
import type { DB } from '../kysely/db';
import type { JourneyPublicGalleryRow } from './JourneyShareTokens.repository';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** The `journey_entry_photos`/`journey_photos`/`trek_photos`/`journey_entries` tables the `JP_SELECT`/`JP_JOIN`-shaped reads read. */
type JourneyPhotoJoinKyselyDB = Pick<DB, 'journey_entry_photos' | 'journey_photos' | 'trek_photos' | 'journey_entries'>;
/** `JP_SELECT`'s exact column list (`journey-domain.service.ts`'s module const), aliased `jep`/`gp`/`tp` — shared by every `JP_SELECT`/`JP_JOIN`-shaped read below. */
const JP_COLUMNS = [
'gp.id', 'jep.entry_id', 'gp.photo_id', 'gp.caption', 'jep.sort_order', 'gp.shared', 'gp.created_at',
'tp.provider', 'tp.asset_id', 'tp.owner_id', 'tp.file_path', 'tp.thumbnail_path', 'tp.width', 'tp.height',
'tp.media_type', 'tp.duration_ms', 'tp.taken_at', 'tp.lat', 'tp.lng',
'gp.id',
'jep.entry_id',
'gp.photo_id',
'gp.caption',
'jep.sort_order',
'gp.shared',
'gp.created_at',
'tp.provider',
'tp.asset_id',
'tp.owner_id',
'tp.file_path',
'tp.thumbnail_path',
'tp.width',
'tp.height',
'tp.media_type',
'tp.duration_ms',
'tp.taken_at',
'tp.lat',
'tp.lng',
] as const;
/**
@@ -1,11 +1,12 @@
import type { Platform } from '@mikro-orm/core';
import type { ExpressionBuilder } from 'kysely';
import type { GalleryPhoto } from '../../types';
import type { JourneyPhotos } from '../entities/JourneyPhotos.entity';
import { concatKysely, unixEpochToIsoKysely } from '../dialect/kysely-functions';
import type { JourneyPhotos } from '../entities/JourneyPhotos.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import type { Platform } from '@mikro-orm/core';
import type { ExpressionBuilder } from 'kysely';
/** `SELECT * FROM journey_photos WHERE id = ?`'s row (JG105) — every scalar column of the entity. */
export interface JourneyPhotoFullRow {
@@ -24,7 +25,10 @@ export interface JourneyPhotoFullRow {
const _journeyPhotoFullRowKeys: AssertRowKeys<JourneyPhotoFullRow, JourneyPhotos> = true;
/** The `journey_photos`/`trek_photos`/`journey_entry_photos`/`journey_entries` tables the gallery reads (JG19/JG101/JG102, `galleryChronologicalOrderExpr`) read. */
type GalleryKyselyDB = Pick<DB, 'journey_photos' | 'trek_photos' | 'journey_entry_photos' | 'journey_entries' | 'journeys'>;
type GalleryKyselyDB = Pick<
DB,
'journey_photos' | 'trek_photos' | 'journey_entry_photos' | 'journey_entries' | 'journeys'
>;
/** JS7 (Plan 4 Task 8b relocation) — the public photo-validation join's row (`journey_photos` + `trek_photos`). */
export interface JourneyPublicPhotoValidationRow {
@@ -41,9 +45,25 @@ export interface JourneyPublicAssetValidationRow {
/** `GALLERY_SELECT`'s exact column list (`journey-domain.service.ts`'s module const), aliased `gp`/`tp` — shared by every gallery-shaped read below. */
const GALLERY_COLUMNS = [
'gp.id', 'gp.journey_id', 'gp.photo_id', 'gp.caption', 'gp.shared', 'gp.sort_order', 'gp.created_at',
'tp.provider', 'tp.asset_id', 'tp.owner_id', 'tp.file_path', 'tp.thumbnail_path', 'tp.width', 'tp.height',
'tp.media_type', 'tp.duration_ms', 'tp.taken_at', 'tp.lat', 'tp.lng',
'gp.id',
'gp.journey_id',
'gp.photo_id',
'gp.caption',
'gp.shared',
'gp.sort_order',
'gp.created_at',
'tp.provider',
'tp.asset_id',
'tp.owner_id',
'tp.file_path',
'tp.thumbnail_path',
'tp.width',
'tp.height',
'tp.media_type',
'tp.duration_ms',
'tp.taken_at',
'tp.lat',
'tp.lng',
] as const;
/**
@@ -63,7 +83,10 @@ const GALLERY_COLUMNS = [
*/
function galleryChronologicalOrderExpr(
platform: Platform,
eb: ExpressionBuilder<GalleryKyselyDB & { gp: GalleryKyselyDB['journey_photos']; tp: GalleryKyselyDB['trek_photos'] }, 'gp' | 'tp'>,
eb: ExpressionBuilder<
GalleryKyselyDB & { gp: GalleryKyselyDB['journey_photos']; tp: GalleryKyselyDB['trek_photos'] },
'gp' | 'tp'
>,
) {
return eb.fn.coalesce(
eb.fn<string | null>('nullif', [eb.ref('tp.taken_at'), eb.val('')]),
@@ -78,7 +101,12 @@ function galleryChronologicalOrderExpr(
eb2,
{ column: 'je.entry_date' },
{ value: 'T' },
{ expression: eb2.fn.coalesce(eb2.fn<string | null>('nullif', [eb2.ref('je.entry_time'), eb2.val('')]), eb2.val('00:00')) },
{
expression: eb2.fn.coalesce(
eb2.fn<string | null>('nullif', [eb2.ref('je.entry_time'), eb2.val('')]),
eb2.val('00:00'),
),
},
),
)
.as('min_dt'),
@@ -147,7 +175,10 @@ export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
* already converted onto `TripPhotosRepository.existsForPhoto`).
*/
async existsForPhoto(photoId: number): Promise<boolean> {
const row = await this.qb('gp').select(['gp.id']).where({ photo: photoId }).execute<{ id: number } | undefined>('get', false);
const row = await this.qb('gp')
.select(['gp.id'])
.where({ photo: photoId })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
@@ -205,7 +236,10 @@ export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
* fallback stub from 3g Task 3 (this repository was still mid-flight when
* that task landed) — relocated here now that it is stable.
*/
async findGalleryPhotoForValidation(photoId: number, journeyId: number): Promise<JourneyPublicPhotoValidationRow | undefined> {
async findGalleryPhotoForValidation(
photoId: number,
journeyId: number,
): Promise<JourneyPublicPhotoValidationRow | undefined> {
return await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
@@ -227,7 +261,10 @@ export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
* resolves `ownerId`. Relocated the same way as {@link
* findGalleryPhotoForValidation}/JS7.
*/
async findAssetForValidation(assetId: string, journeyId: number): Promise<JourneyPublicAssetValidationRow | undefined> {
async findAssetForValidation(
assetId: string,
journeyId: number,
): Promise<JourneyPublicAssetValidationRow | undefined> {
return await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
@@ -323,10 +360,7 @@ export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
/** JG105 — `deleteGalleryPhoto`'s pre-delete read: `SELECT * FROM journey_photos WHERE id = ?`. */
async findFull(id: number): Promise<JourneyPhotoFullRow | undefined> {
return await this.qb('gp')
.select(['gp.*'])
.where({ id })
.execute<JourneyPhotoFullRow | undefined>('get', false);
return await this.qb('gp').select(['gp.*']).where({ id }).execute<JourneyPhotoFullRow | undefined>('get', false);
}
/** JG108 — `setPhotoProvider`'s `trek_photos.id` resolution: `SELECT photo_id FROM journey_photos WHERE id = ?`. Relation property (`gp.photo`), not the shadow column — see {@link findScopeById}'s docstring. */
@@ -114,7 +114,10 @@ export class JourneyShareTokensRepository extends TrekRepository<JourneyShareTok
/** JS4 — `getJourneyShareLink`: `SELECT * FROM journey_share_tokens WHERE journey_id = ?`. */
async findByJourneyId(journeyId: number): Promise<JourneyShareTokenRow | undefined> {
return await this.qb('jst').select(['jst.*']).where({ journey: journeyId }).execute<JourneyShareTokenRow | undefined>('get', false);
return await this.qb('jst')
.select(['jst.*'])
.where({ journey: journeyId })
.execute<JourneyShareTokenRow | undefined>('get', false);
}
/** JS5 — `deleteJourneyShareLink`: `DELETE FROM journey_share_tokens WHERE journey_id = ?`. */
@@ -146,7 +149,10 @@ export class JourneyShareTokensRepository extends TrekRepository<JourneyShareTok
/** JS11 — `getPublicJourney`'s entrypoint read: `SELECT * FROM journey_share_tokens WHERE token = ?`. Exact-match only (R4), same as {@link findAccessByToken}. */
async findByToken(token: string): Promise<JourneyShareTokenRow | undefined> {
return await this.qb('jst').select(['jst.*']).where({ token }).execute<JourneyShareTokenRow | undefined>('get', false);
return await this.qb('jst')
.select(['jst.*'])
.where({ token })
.execute<JourneyShareTokenRow | undefined>('get', false);
}
// JS7/JS10 — `validateShareTokenForPhoto`'s/`validateShareTokenForAsset`'s
@@ -1,6 +1,6 @@
import type { JourneyTrips } from '../entities/JourneyTrips.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** JG17's trip-link row (`getJourneyFull`) — the junction plus the joined trip summary and its place count. */
export interface JourneyTripLinkRow {
@@ -126,7 +126,17 @@ export class JourneyTripsRepository extends TrekRepository<JourneyTrips> {
.selectFrom('places as p')
.innerJoin('day_assignments as da', 'da.place_id', 'p.id')
.innerJoin('days as d', 'd.id', 'da.day_id')
.select(['p.id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'da.id as assignment_id', 'd.date as day_date', 'da.assignment_time'])
.select([
'p.id',
'p.name',
'p.address',
'p.lat',
'p.lng',
'p.place_time',
'da.id as assignment_id',
'd.date as day_date',
'da.assignment_time',
])
.where('p.trip_id', '=', tripId)
.orderBy('d.day_number', 'asc')
.orderBy('da.order_index', 'asc')
@@ -140,7 +150,17 @@ export class JourneyTripsRepository extends TrekRepository<JourneyTrips> {
.selectFrom('places as p')
.innerJoin('day_assignments as da', 'da.place_id', 'p.id')
.innerJoin('days as d', 'd.id', 'da.day_id')
.select(['p.id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'da.id as assignment_id', 'd.date as day_date', 'da.assignment_time'])
.select([
'p.id',
'p.name',
'p.address',
'p.lat',
'p.lng',
'p.place_time',
'da.id as assignment_id',
'd.date as day_date',
'da.assignment_time',
])
.where('p.id', '=', placeId)
.orderBy('d.day_number', 'asc')
.orderBy('da.order_index', 'asc')
@@ -1,6 +1,6 @@
import type { Journeys } from '../entities/Journeys.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/**
* `SELECT * FROM journeys WHERE id = ?`'s row — every scalar column the
@@ -41,7 +41,10 @@ export interface JourneyListRow extends JourneyRow {
}
/** The `journeys`/`journey_contributors`/`journey_entries`/`journey_photos`/`journey_trips`/`trips` tables {@link JourneysRepository.listForUser} reads. */
type JourneyListKyselyDB = Pick<DB, 'journeys' | 'journey_contributors' | 'journey_entries' | 'journey_photos' | 'journey_trips' | 'trips'>;
type JourneyListKyselyDB = Pick<
DB,
'journeys' | 'journey_contributors' | 'journey_entries' | 'journey_photos' | 'journey_trips' | 'trips'
>;
/** The `journeys`/`journey_contributors` tables {@link JourneysRepository.listRecipientUserIds} reads. */
type JourneyRecipientsKyselyDB = Pick<DB, 'journeys' | 'journey_contributors'>;
@@ -130,7 +133,9 @@ export class JourneysRepository extends TrekRepository<Journeys> {
async listForUser(user_id: number): Promise<JourneyListRow[]> {
const rows = await this.kysely<JourneyListKyselyDB>()
.selectFrom('journeys as j')
.leftJoin('journey_contributors as jc', (join) => join.onRef('jc.journey_id', '=', 'j.id').on('jc.user_id', '=', user_id))
.leftJoin('journey_contributors as jc', (join) =>
join.onRef('jc.journey_id', '=', 'j.id').on('jc.user_id', '=', user_id),
)
.selectAll('j')
.select((eb) => [
eb
@@ -171,7 +176,13 @@ export class JourneysRepository extends TrekRepository<Journeys> {
}
/** JG9 — `createJourney`'s INSERT: `(user_id, title, subtitle, status, created_at, updated_at)`, `status` hard-coded `'active'` like the legacy statement. */
async insertJourney(data: { user_id: number; title: string; subtitle: string | null; created_at: number; updated_at: number }): Promise<number> {
async insertJourney(data: {
user_id: number;
title: string;
subtitle: string | null;
created_at: number;
updated_at: number;
}): Promise<number> {
return await this.insert({
user: data.user_id,
title: data.title,
@@ -105,7 +105,9 @@ export class MaintenanceRepository {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try {
await this.em.getConnection().execute(`DELETE FROM ${table} WHERE user_id = ?`, [userId], 'run', ctx);
} catch { /* table absent (slim schema) */ }
} catch {
/* table absent (slim schema) */
}
}
}
}
@@ -1,6 +1,6 @@
import { columnRef, currentTimestamp } from '../dialect/sql-functions';
import { McpTokens } from '../entities/McpTokens.entity';
import type { AssertRowKeys } from './_shared/rows';
import { columnRef, currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
/**
@@ -312,7 +312,15 @@ export class McpTokensRepository extends TrekRepository<McpTokens> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.qb('t')
.innerJoin('t.user', 'u')
.select(['t.id', 't.name', 't.token_prefix', 't.created_at', 't.last_used_at', columnRef(platform, 't.user_id'), 'u.username'])
.select([
't.id',
't.name',
't.token_prefix',
't.created_at',
't.last_used_at',
columnRef(platform, 't.user_id'),
'u.username',
])
.orderBy({ 't.created_at': 'desc' })
.execute<McpTokenWithUsernameRow[]>('all', false);
return rows.map((row) => ({
@@ -1,7 +1,7 @@
import type { Notifications } from '../entities/Notifications.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/**
* A bare `notifications` row — every scalar column of the entity, incl. the
@@ -178,7 +178,11 @@ export class NotificationsRepository extends TrekRepository<Notifications> {
/** NT7/NT9/NT19 — `SELECT * FROM notifications WHERE id = ?`, full row incl. the persist(false) mirrors. */
async findById(id: number): Promise<NotificationRow | undefined> {
return await this.kysely<NotificationsKyselyDB>().selectFrom('notifications').selectAll().where('id', '=', id).executeTakeFirst();
return await this.kysely<NotificationsKyselyDB>()
.selectFrom('notifications')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
/** NT19 — `respond`'s recipient-scoped initial read: `SELECT * FROM notifications WHERE id = ? AND recipient_id = ?`. */
@@ -192,7 +196,12 @@ export class NotificationsRepository extends TrekRepository<Notifications> {
}
/** NT10 — `listInApp`'s joined page read, `unreadOnly` toggling the extra `is_read = 0` predicate (the legacy statement's two dynamic-WHERE variants). */
async listForRecipient(recipientId: number, limit: number, offset: number, unreadOnly: boolean): Promise<NotificationJoinRow[]> {
async listForRecipient(
recipientId: number,
limit: number,
offset: number,
unreadOnly: boolean,
): Promise<NotificationJoinRow[]> {
let q = this.joinedQuery().where('n.recipient_id', '=', recipientId);
if (unreadOnly) q = q.where('n.is_read', '=', 0);
return await q.orderBy('n.created_at', 'desc').limit(limit).offset(offset).execute();
@@ -15,10 +15,10 @@ export interface OauthClientRow {
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string | null;
is_public: number; // 0 | 1
is_public: number; // 0 | 1
created_via: string;
allows_client_credentials: number; // 0 | 1
}
@@ -129,7 +129,18 @@ export class OauthClientsRepository extends TrekRepository<OauthClients> {
const rows = await this.find(
{ user: userId },
{
fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'],
fields: [
'id',
'user',
'name',
'client_id',
'redirect_uris',
'allowed_scopes',
'created_at',
'is_public',
'created_via',
'allows_client_credentials',
],
orderBy: { created_at: 'desc' },
},
);
@@ -213,7 +224,18 @@ export class OauthClientsRepository extends TrekRepository<OauthClients> {
const row = await this.findOne(
{ id },
{
fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'],
fields: [
'id',
'user',
'name',
'client_id',
'redirect_uris',
'allowed_scopes',
'created_at',
'is_public',
'created_via',
'allows_client_credentials',
],
},
);
return row
@@ -247,10 +269,7 @@ export class OauthClientsRepository extends TrekRepository<OauthClients> {
* unscoped read.
*/
async findOwned(id: string, userId: number): Promise<OauthClientOwnedRow | null> {
const row = await this.findOne(
{ id, user: userId },
{ fields: ['id', 'client_id', 'is_public'] },
);
const row = await this.findOne({ id, user: userId }, { fields: ['id', 'client_id', 'is_public'] });
return row ? { id: row.id as string, client_id: row.client_id, is_public: row.is_public } : null;
}
@@ -288,7 +307,14 @@ export class OauthClientsRepository extends TrekRepository<OauthClients> {
{ fields: ['client_id', 'name', 'redirect_uris', 'allowed_scopes', 'is_public', 'created_via'] },
);
return row
? { client_id: row.client_id, name: row.name, redirect_uris: row.redirect_uris, allowed_scopes: row.allowed_scopes, is_public: row.is_public, created_via: row.created_via }
? {
client_id: row.client_id,
name: row.name,
redirect_uris: row.redirect_uris,
allowed_scopes: row.allowed_scopes,
is_public: row.is_public,
created_via: row.created_via,
}
: null;
}
@@ -309,7 +335,9 @@ export class OauthClientsRepository extends TrekRepository<OauthClients> {
{ client_id: clientId },
{ fields: ['client_id', 'client_secret_hash', 'is_public'] },
);
return row ? { client_id: row.client_id, client_secret_hash: row.client_secret_hash, is_public: row.is_public } : null;
return row
? { client_id: row.client_id, client_secret_hash: row.client_secret_hash, is_public: row.is_public }
: null;
}
// ---------------------------------------------------------------------
@@ -1,5 +1,5 @@
import { OauthConsents } from '../entities/OauthConsents.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { OauthConsents } from '../entities/OauthConsents.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -52,10 +52,7 @@ export class OauthConsentsRepository extends TrekRepository<OauthConsents> {
* method (same rule `upsertGrant` below follows for `#upsert`).
*/
async findScopes(clientId: string, userId: number): Promise<{ scopes: string } | null> {
const row = await this.findOne(
{ client: clientId, user: userId },
{ fields: ['scopes'] },
);
const row = await this.findOne({ client: clientId, user: userId }, { fields: ['scopes'] });
return row ? { scopes: row.scopes } : null;
}
@@ -1,9 +1,9 @@
import { OauthTokens } from '../entities/OauthTokens.entity';
import { type AssertRowKeys } from './_shared/rows';
import { columnRef, currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
import { OauthTokens } from '../entities/OauthTokens.entity';
import type { DB } from '../kysely/db';
import type { DbTimestamp } from '../types';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/**
* The Kysely-side table shape `em.getKysely()` needs for `collectChainIds`'s
@@ -259,9 +259,15 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
const row = await this.qb('ot')
.innerJoin('ot.user', 'u')
.select([
'ot.scopes', 'ot.audience', 'ot.revoked_at', 'ot.access_token_expires_at',
columnRef(platform, 'ot.user_id'), columnRef(platform, 'ot.client_id'),
'u.username', 'u.email', 'u.role',
'ot.scopes',
'ot.audience',
'ot.revoked_at',
'ot.access_token_expires_at',
columnRef(platform, 'ot.user_id'),
columnRef(platform, 'ot.client_id'),
'u.username',
'u.email',
'u.role',
])
.where({ 'ot.access_token_hash': hash })
.execute<OauthTokenWithUserRow | undefined>('get', false);
@@ -327,11 +333,13 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
async collectChainIds(rootId: number): Promise<number[]> {
const rows = await this.kysely<OauthTokensKyselyDB>()
.withRecursive('chain', (db) =>
db.selectFrom('oauth_tokens')
db
.selectFrom('oauth_tokens')
.select('id')
.where('id', '=', rootId)
.unionAll((eb) =>
eb.selectFrom('oauth_tokens as ot2')
eb
.selectFrom('oauth_tokens as ot2')
.innerJoin('chain as c', 'c.id', 'ot2.parent_token_id')
.select('ot2.id'),
),
@@ -364,7 +372,10 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
.deleteFrom('oauth_tokens')
.where('refresh_token_expires_at', '<', cutoff)
.where('id', 'not in', (eb) =>
eb.selectFrom('oauth_tokens as child').select('child.parent_token_id').where('child.parent_token_id', 'is not', null),
eb
.selectFrom('oauth_tokens as child')
.select('child.parent_token_id')
.where('child.parent_token_id', 'is not', null),
)
.executeTakeFirst();
const deleted = Number(result.numDeletedRows ?? 0);
@@ -433,7 +444,9 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
async findByRefreshTokenHash(hash: string): Promise<OauthTokenRefreshRow | null> {
const row = await this.findOne(
{ refresh_token_hash: hash },
{ fields: ['id', 'client', 'user', 'scopes', 'audience', 'refresh_token_expires_at', 'revoked_at', 'parentToken'] },
{
fields: ['id', 'client', 'user', 'scopes', 'audience', 'refresh_token_expires_at', 'revoked_at', 'parentToken'],
},
);
return row
? {
@@ -548,7 +561,11 @@ export class OauthTokensRepository extends TrekRepository<OauthTokens> {
'ot.refresh_token_expires_at',
'ot.created_at',
])
.where({ 'ot.user_id': userId, 'ot.revoked_at': null, 'ot.refresh_token_expires_at': { $gt: currentTimestamp(platform) } })
.where({
'ot.user_id': userId,
'ot.revoked_at': null,
'ot.refresh_token_expires_at': { $gt: currentTimestamp(platform) },
})
.orderBy({ 'ot.created_at': 'desc' })
.execute<OauthSessionRow[]>('all', false);
return rows.map((row) => ({
@@ -1,8 +1,8 @@
import type { PackingBags } from '../entities/PackingBags.entity';
import type { DB } from '../kysely/db';
import { presenceSet } from './_shared/presence-set';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import { presenceSet } from './_shared/presence-set';
import type { DB } from '../kysely/db';
/** A bare `packing_bags` row — every scalar column, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */
export interface PackingBagRow {
@@ -80,23 +80,41 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* still-accepted carry.
*/
async findInTrip(id: number, trip_id: number | string): Promise<PackingBagRow | undefined> {
return await this.db().selectFrom('packing_bags').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
return await this.db()
.selectFrom('packing_bags')
.selectAll()
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
}
/** PK30 (`bulkImport`'s bag-by-name resolution) — `SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?`. */
async byNameInTrip(trip_id: number | string, name: string): Promise<{ id: number } | undefined> {
return await this.db().selectFrom('packing_bags').select('id').where('trip_id', '=', trip_id as number).where('name', '=', name).executeTakeFirst();
return await this.db()
.selectFrom('packing_bags')
.select('id')
.where('trip_id', '=', trip_id as number)
.where('name', '=', name)
.executeTakeFirst();
}
/** PK31 (`bulkImport`, picks the next `BAG_COLORS` entry) — `SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?`. */
async countForTrip(trip_id: number | string): Promise<number> {
const row = await this.db().selectFrom('packing_bags').select((eb) => eb.fn.countAll<number>().as('c')).where('trip_id', '=', trip_id as number).executeTakeFirst();
const row = await this.db()
.selectFrom('packing_bags')
.select((eb) => eb.fn.countAll<number>().as('c'))
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
return row?.c ?? 0;
}
/** PK41 (`createBag`) — `SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?`. */
async maxSortOrder(trip_id: number | string): Promise<number | null> {
const row = await this.db().selectFrom('packing_bags').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst();
const row = await this.db()
.selectFrom('packing_bags')
.select((eb) => eb.fn.max('sort_order').as('max'))
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
return row?.max ?? null;
}
@@ -112,7 +130,11 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* the new row's id.
*/
async insertMinimal(trip_id: number | string, name: string, color: string): Promise<number> {
const inserted = await this.kysely<PackingBagsMinimalInsertKyselyDB>().insertInto('packing_bags').values({ trip_id: trip_id as number, name, color }).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<PackingBagsMinimalInsertKyselyDB>()
.insertInto('packing_bags')
.values({ trip_id: trip_id as number, name, color })
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
@@ -121,14 +143,28 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* sort_order, weight_limit_grams) VALUES (?×5)`, a distinct, wider column
* set from {@link insertMinimal}'s. Returns the new row's id.
*/
async insertBag(row: { trip_id: number | string; name: string; color: string; sort_order: number | null; weight_limit_grams: number | null }): Promise<number> {
const inserted = await this.kysely<PackingBagsInsertKyselyDB>().insertInto('packing_bags').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow();
async insertBag(row: {
trip_id: number | string;
name: string;
color: string;
sort_order: number | null;
weight_limit_grams: number | null;
}): Promise<number> {
const inserted = await this.kysely<PackingBagsInsertKyselyDB>()
.insertInto('packing_bags')
.values({ ...row, trip_id: row.trip_id as number })
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
/** PK43 (`createBag`'s post-insert re-select) — `SELECT * FROM packing_bags WHERE id = ?`. */
async findById(id: number | string): Promise<PackingBagRow | undefined> {
return await this.db().selectFrom('packing_bags').selectAll().where('id', '=', id as number).executeTakeFirst();
return await this.db()
.selectFrom('packing_bags')
.selectAll()
.where('id', '=', id as number)
.executeTakeFirst();
}
/**
@@ -148,13 +184,21 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* (`:222`) and `TodoItems` (`:125`) already carry.
*/
/** `id: number`, same Plan 4 Task 8b (U6) narrowing as {@link findInTrip} (its one caller, `updateBag`, is only reached with a `toRowId`-parsed/Zod-typed id). */
async update(id: number, write: {
name?: readonly [present: boolean, value: string | null];
color?: readonly [present: boolean, value: string | null];
weight_limit_grams?: readonly [present: boolean, value: number | null];
user_id?: readonly [present: boolean, value: number | null];
}): Promise<void> {
const data = presenceSet<{ name: string; color: string; weight_limit_grams: number | null; user_id: number | null }>(write);
async update(
id: number,
write: {
name?: readonly [present: boolean, value: string | null];
color?: readonly [present: boolean, value: string | null];
weight_limit_grams?: readonly [present: boolean, value: number | null];
user_id?: readonly [present: boolean, value: number | null];
},
): Promise<void> {
const data = presenceSet<{
name: string;
color: string;
weight_limit_grams: number | null;
user_id: number | null;
}>(write);
if (Object.keys(data).length === 0) return;
await this.db().updateTable('packing_bags').set(data).where('id', '=', id).execute();
}
@@ -177,7 +221,13 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
/** PK35 (`decorateBags`) — `SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id`. */
async listForTrip(trip_id: number | string): Promise<PackingBagRow[]> {
return await this.db().selectFrom('packing_bags').selectAll().where('trip_id', '=', trip_id as number).orderBy('sort_order', 'asc').orderBy('id', 'asc').execute();
return await this.db()
.selectFrom('packing_bags')
.selectAll()
.where('trip_id', '=', trip_id as number)
.orderBy('sort_order', 'asc')
.orderBy('id', 'asc')
.execute();
}
// ---------------------------------------------------------------------------
@@ -194,14 +244,23 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
.selectFrom('packing_bag_members as bm')
.innerJoin('users as u', 'u.id', 'bm.user_id')
.innerJoin('packing_bags as b', 'b.id', 'bm.bag_id')
.select(['bm.bag_id', 'bm.user_id', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'bm.bag_id',
'bm.user_id',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('b.trip_id', '=', trip_id as number)
.execute();
}
/** PK26 (`bagForCloner`) — `SELECT user_id FROM packing_bag_members WHERE bag_id = ?`. */
async listMemberIdsForBag(bag_id: number): Promise<number[]> {
const rows = await this.db().selectFrom('packing_bag_members').select('user_id').where('bag_id', '=', bag_id).execute();
const rows = await this.db()
.selectFrom('packing_bag_members')
.select('user_id')
.where('bag_id', '=', bag_id)
.execute();
return rows.map((r) => r.user_id);
}
@@ -252,6 +311,10 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* directly — same column set, called once per row.
*/
async listAllForTrip(trip_id: number | string): Promise<PackingBagRow[]> {
return await this.db().selectFrom('packing_bags').selectAll().where('trip_id', '=', trip_id as number).execute();
return await this.db()
.selectFrom('packing_bags')
.selectAll()
.where('trip_id', '=', trip_id as number)
.execute();
}
}
@@ -1,6 +1,6 @@
import type { PackingCategoryAssignees } from '../entities/PackingCategoryAssignees.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** PK58/PK61's joined projection. */
export interface PackingCategoryAssigneeRow {
@@ -46,7 +46,12 @@ export class PackingCategoryAssigneesRepository extends TrekRepository<PackingCa
return await this.kysely<PackingCategoryAssigneesKyselyDB>()
.selectFrom('packing_category_assignees as pca')
.innerJoin('users as u', 'u.id', 'pca.user_id')
.select(['pca.category_name', 'pca.user_id', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar'])
.select([
'pca.category_name',
'pca.user_id',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
'u.avatar',
])
.where('pca.trip_id', '=', trip_id as number)
.execute();
}
@@ -78,7 +83,10 @@ export class PackingCategoryAssigneesRepository extends TrekRepository<PackingCa
}
/** PK61 (`updateCategoryAssignees`'s post-write re-select) — `SELECT pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar FROM packing_category_assignees pca JOIN users u ON pca.user_id = u.id WHERE pca.trip_id = ? AND pca.category_name = ?`. */
async listForCategory(trip_id: number | string, category_name: string): Promise<Omit<PackingCategoryAssigneeRow, 'category_name'>[]> {
async listForCategory(
trip_id: number | string,
category_name: string,
): Promise<Omit<PackingCategoryAssigneeRow, 'category_name'>[]> {
return await this.kysely<PackingCategoryAssigneesKyselyDB>()
.selectFrom('packing_category_assignees as pca')
.innerJoin('users as u', 'u.id', 'pca.user_id')
@@ -1,6 +1,6 @@
import type { PackingItemContributors } from '../entities/PackingItemContributors.entity';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** PK3's contributor projection (`enrichItems`). */
export interface PackingItemContributorRow {
@@ -42,7 +42,12 @@ export class PackingItemContributorsRepository extends TrekRepository<PackingIte
return await this.kysely<PackingItemContributorsKyselyDB>()
.selectFrom('packing_item_contributors as c')
.innerJoin('users as u', 'u.id', 'c.user_id')
.select(['c.item_id', 'c.user_id', 'c.status', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username')])
.select([
'c.item_id',
'c.user_id',
'c.status',
(eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'),
])
.where('c.item_id', 'in', item_ids)
.execute();
}
@@ -1,10 +1,10 @@
import type { PackingItems } from '../entities/PackingItems.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import type { PackingItems } from '../entities/PackingItems.entity';
import type { DB } from '../kysely/db';
import { packingVisibleToActorExpr, type PackingVisibilityKyselyDB } from './_shared/packing-visibility';
import { presenceSet } from './_shared/presence-set';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import { presenceSet } from './_shared/presence-set';
import { packingVisibleToActorExpr, type PackingVisibilityKyselyDB } from './_shared/packing-visibility';
import type { DB } from '../kysely/db';
/** A bare `packing_items` row — every scalar column, incl. the three `persist(false)` relation mirrors (`trip_id`, `bag_id`, `owner_id`) — the program-wide trap this class goes through Kysely throughout to avoid. */
export interface PackingItemRow {
@@ -49,7 +49,8 @@ export interface PackingItemExportRow {
* `CalendarStayKyselyDB`/`ReservationVisibilityKyselyDB` precedent,
* `Reservations.repository.ts`).
*/
type PackingItemsKyselyDB = PackingVisibilityKyselyDB & Pick<DB, 'packing_items' | 'packing_item_recipients' | 'users' | 'packing_bags'>;
type PackingItemsKyselyDB = PackingVisibilityKyselyDB &
Pick<DB, 'packing_items' | 'packing_item_recipients' | 'users' | 'packing_bags'>;
/**
* `packing_items` — the checklist rows themselves, plus (this class's own
@@ -114,7 +115,13 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
/** PK4 (`listItems`'s no-viewer branch) — `SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC`, unfiltered — only for genuinely viewer-less internal reads. */
async listForTrip(trip_id: number | string): Promise<PackingItemRow[]> {
return await this.db().selectFrom('packing_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('sort_order', 'asc').orderBy('created_at', 'asc').execute();
return await this.db()
.selectFrom('packing_items')
.selectAll()
.where('trip_id', '=', trip_id as number)
.orderBy('sort_order', 'asc')
.orderBy('created_at', 'asc')
.execute();
}
/**
@@ -156,13 +163,25 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
}
/** PK6 (`getItemPrivacy`) — `SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the program's gate-level id parsing carry: its one caller, `PackingService.getItemPrivacy`, is only reached with a `toRowId`-parsed id); `trip_id` stays `number | string`, a separate, still-accepted carry. */
async getPrivacy(id: number, trip_id: number | string): Promise<{ is_private: number; owner_id: number | null } | undefined> {
return await this.db().selectFrom('packing_items').select(['is_private', 'owner_id']).where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst();
async getPrivacy(
id: number,
trip_id: number | string,
): Promise<{ is_private: number; owner_id: number | null } | undefined> {
return await this.db()
.selectFrom('packing_items')
.select(['is_private', 'owner_id'])
.where('id', '=', id)
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
}
/** PK7/PK28/PK51 (`createItem`/`bulkImport`/`applyTemplate`) — `SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?`, one method, three call sites. */
async maxSortOrder(trip_id: number | string): Promise<number | null> {
const row = await this.db().selectFrom('packing_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst();
const row = await this.db()
.selectFrom('packing_items')
.select((eb) => eb.fn.max('sort_order').as('max'))
.where('trip_id', '=', trip_id as number)
.executeTakeFirst();
return row?.max ?? null;
}
@@ -190,8 +209,16 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
* `day`/`place` precedent).
*/
async insertItem(row: {
trip_id: number | string; name: string; checked: number; category: string; sort_order: number;
quantity: number; weight_grams: number | null; bag_id: number | null; is_private: number; owner_id: number | null;
trip_id: number | string;
name: string;
checked: number;
category: string;
sort_order: number;
quantity: number;
weight_grams: number | null;
bag_id: number | null;
is_private: number;
owner_id: number | null;
}): Promise<number> {
const platform = this.getEntityManager().getPlatform();
const id = await this.insert({
@@ -219,8 +246,15 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
* same reason as {@link insertItem}'s docstring. Returns the new row's id.
*/
async insertFromTemplate(row: {
trip_id: number | string; name: string; category: string; sort_order: number; is_private: number; owner_id: number | null;
weight_grams: number | null; quantity: number; bag_id: number | null;
trip_id: number | string;
name: string;
category: string;
sort_order: number;
is_private: number;
owner_id: number | null;
weight_grams: number | null;
quantity: number;
bag_id: number | null;
}): Promise<number> {
const platform = this.getEntityManager().getPlatform();
const id = await this.insert({
@@ -279,7 +313,11 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
* sites now thread a `toRowId`-parsed/Zod-typed id); `trip_id` stays
* `number | string`, a separate, still-accepted carry.
*/
async findVisibleInTrip(id: number, trip_id: number | string, actorId: number | undefined): Promise<PackingItemRow | undefined> {
async findVisibleInTrip(
id: number,
trip_id: number | string,
actorId: number | undefined,
): Promise<PackingItemRow | undefined> {
if (actorId == null) return undefined;
return await this.db()
.selectFrom('packing_items')
@@ -312,21 +350,31 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
* no `updated_at` column at all).
*/
/** `id: number`, same Plan 4 Task 8b (U6) narrowing as {@link findById} (its one caller, `updateItem`, is only reached with a `toRowId`-parsed/Zod-typed id). */
async update(id: number, write: {
name?: readonly [present: boolean, value: string | null];
checked?: readonly [present: boolean, value: number];
packed_quantity?: readonly [present: boolean, value: number | null];
category?: readonly [present: boolean, value: string | null];
weight_grams?: readonly [present: boolean, value: number | null];
bag_id?: readonly [present: boolean, value: number | null];
quantity?: readonly [present: boolean, value: number];
is_private?: readonly [present: boolean, value: number];
owner_id?: readonly [present: boolean, value: number | null];
}): Promise<void> {
async update(
id: number,
write: {
name?: readonly [present: boolean, value: string | null];
checked?: readonly [present: boolean, value: number];
packed_quantity?: readonly [present: boolean, value: number | null];
category?: readonly [present: boolean, value: string | null];
weight_grams?: readonly [present: boolean, value: number | null];
bag_id?: readonly [present: boolean, value: number | null];
quantity?: readonly [present: boolean, value: number];
is_private?: readonly [present: boolean, value: number];
owner_id?: readonly [present: boolean, value: number | null];
},
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
const data = presenceSet<{
name: string | null; checked: number; packed_quantity: number | null; category: string | null; weight_grams: number | null;
bag: number | null; quantity: number; is_private: number; owner: number | null;
name: string | null;
checked: number;
packed_quantity: number | null;
category: string | null;
weight_grams: number | null;
bag: number | null;
quantity: number;
is_private: number;
owner: number | null;
}>({
name: write.name,
checked: write.checked,
@@ -353,7 +401,9 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
*/
async updateSharing(id: number, is_private: number, claimOwnerId?: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
const data = presenceSet<{ owner: number }>({ owner: claimOwnerId !== undefined ? [true, claimOwnerId] : undefined });
const data = presenceSet<{ owner: number }>({
owner: claimOwnerId !== undefined ? [true, claimOwnerId] : undefined,
});
await this.nativeUpdate({ id }, { is_private, ...data, updated_at: currentTimestamp(platform) });
}
@@ -464,7 +514,16 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
* stay restricted, owned by the copier; recipient rows are not carried
* over (the caller's own note, unchanged).
*/
async insertCopy(row: { trip_id: number | string; name: string; category: string | null; sort_order: number | null; weight_grams: number | null; bag_id: number | null; is_private: number; owner_id: number | null }): Promise<number> {
async insertCopy(row: {
trip_id: number | string;
name: string;
category: string | null;
sort_order: number | null;
weight_grams: number | null;
bag_id: number | null;
is_private: number;
owner_id: number | null;
}): Promise<number> {
const platform = this.getEntityManager().getPlatform();
const id = await this.insert({
trip: row.trip_id,
@@ -1,7 +1,7 @@
import type { PackingTemplateCategories } from '../entities/PackingTemplateCategories.entity';
import type { DB } from '../kysely/db';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { DB } from '../kysely/db';
/** A bare `packing_template_categories` row — every scalar column, incl. the `persist(false)` `template_id` relation mirror. */
export interface PackingTemplateCategoryRow {
@@ -26,7 +26,13 @@ export class PackingTemplateCategoriesRepository extends TrekRepository<PackingT
/** PK66 (`getPackingTemplate`) — `SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id`. */
async listForTemplate(template_id: number | string): Promise<PackingTemplateCategoryRow[]> {
return await this.db().selectFrom('packing_template_categories').selectAll().where('template_id', '=', template_id as number).orderBy('sort_order', 'asc').orderBy('id', 'asc').execute();
return await this.db()
.selectFrom('packing_template_categories')
.selectAll()
.where('template_id', '=', template_id as number)
.orderBy('sort_order', 'asc')
.orderBy('id', 'asc')
.execute();
}
/**
@@ -37,29 +43,53 @@ export class PackingTemplateCategoriesRepository extends TrekRepository<PackingT
* with `TrekRepository`/`EntityRepository`'s own same-named method.
*/
async insertCategory(template_id: number | string, name: string, sort_order: number): Promise<number> {
const inserted = await this.kysely<PackingTemplateCategoriesInsertKyselyDB>().insertInto('packing_template_categories').values({ template_id: template_id as number, name, sort_order }).returning('id').executeTakeFirstOrThrow();
const inserted = await this.kysely<PackingTemplateCategoriesInsertKyselyDB>()
.insertInto('packing_template_categories')
.values({ template_id: template_id as number, name, sort_order })
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
/** PK76 (`createTemplateCategory`) — `SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?`. */
async maxSortOrder(template_id: number | string): Promise<number | null> {
const row = await this.db().selectFrom('packing_template_categories').select((eb) => eb.fn.max('sort_order').as('max')).where('template_id', '=', template_id as number).executeTakeFirst();
const row = await this.db()
.selectFrom('packing_template_categories')
.select((eb) => eb.fn.max('sort_order').as('max'))
.where('template_id', '=', template_id as number)
.executeTakeFirst();
return row?.max ?? null;
}
/** PK78/PK81 — `SELECT * FROM packing_template_categories WHERE id = ?`, same text at two call sites (`createTemplateCategory`'s re-select, `updateTemplateCategory`'s re-select). */
async findById(id: number | string): Promise<PackingTemplateCategoryRow | undefined> {
return await this.db().selectFrom('packing_template_categories').selectAll().where('id', '=', id as number).executeTakeFirst();
return await this.db()
.selectFrom('packing_template_categories')
.selectAll()
.where('id', '=', id as number)
.executeTakeFirst();
}
/** PK79/PK82 — `SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?`, the scope-check text at two call sites (`updateTemplateCategory`, `deleteTemplateCategory`). */
async findInTemplate(id: number | string, template_id: number | string): Promise<PackingTemplateCategoryRow | undefined> {
return await this.db().selectFrom('packing_template_categories').selectAll().where('id', '=', id as number).where('template_id', '=', template_id as number).executeTakeFirst();
async findInTemplate(
id: number | string,
template_id: number | string,
): Promise<PackingTemplateCategoryRow | undefined> {
return await this.db()
.selectFrom('packing_template_categories')
.selectAll()
.where('id', '=', id as number)
.where('template_id', '=', template_id as number)
.executeTakeFirst();
}
/** PK80 (`updateTemplateCategory`) — `UPDATE packing_template_categories SET name = ? WHERE id = ?`. */
async updateName(id: number | string, name: string): Promise<void> {
await this.db().updateTable('packing_template_categories').set({ name }).where('id', '=', id as number).execute();
await this.db()
.updateTable('packing_template_categories')
.set({ name })
.where('id', '=', id as number)
.execute();
}
/** PK83 (`deleteTemplateCategory`) — `DELETE FROM packing_template_categories WHERE id = ?`. */

Some files were not shown because too many files have changed in this diff Show More