feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit de2d83092e
28274 changed files with 3816354 additions and 90 deletions

View File

@@ -0,0 +1,13 @@
import type { SuperStruct } from '../superStruct.js';
import type { JSONSchema } from './index.js';
export type InputConstraint = Partial<{
pattern: string;
min: number | string;
max: number | string;
required: boolean;
step: number | 'any';
minlength: number;
maxlength: number;
}>;
export type InputConstraints<T> = SuperStruct<T, InputConstraint>;
export declare function constraints<T>(schema: JSONSchema): InputConstraints<T>;

View File

@@ -0,0 +1,102 @@
import { schemaInfo } from './schemaInfo.js';
import { merge as deepMerge } from 'ts-deepmerge';
export function constraints(schema) {
return _constraints(schemaInfo(schema, false, []), []);
}
function merge(...constraints) {
const filtered = constraints.filter((c) => !!c);
if (!filtered.length)
return undefined;
if (filtered.length == 1)
return filtered[0];
return deepMerge(...filtered);
}
function _constraints(info, path) {
if (!info)
return undefined;
let output = undefined;
// Union
if (info.union && info.union.length) {
const infos = info.union.map((s) => schemaInfo(s, info.isOptional, path));
const merged = infos.map((i) => _constraints(i, path));
output = merge(output, ...merged);
// Delete required if any part of the union is optional
if (output &&
(info.isNullable || info.isOptional || infos.some((i) => i?.isNullable || i?.isOptional))) {
delete output.required;
}
}
// Arrays
if (info.array) {
output = merge(output, ...info.array.map((i) => _constraints(schemaInfo(i, info.isOptional, path), path)));
}
// Objects
if (info.properties) {
const obj = {};
for (const [key, prop] of Object.entries(info.properties)) {
const propInfo = schemaInfo(prop, !info.required?.includes(key) || prop.default !== undefined, [key]);
const propConstraint = _constraints(propInfo, [...path, key]);
if (typeof propConstraint === 'object' && Object.values(propConstraint).length > 0) {
obj[key] = propConstraint;
}
}
output = merge(output, obj);
}
return output ?? constraint(info);
}
function constraint(info) {
const output = {};
const schema = info.schema;
const type = schema.type;
const format = schema.format;
// Must be before type check
if (type == 'integer' &&
format == 'unix-time' //||
//format == 'date-time' ||
//format == 'date' ||
//format == 'time'
) {
const date = schema;
if (date.minimum !== undefined)
output.min = new Date(date.minimum).toISOString();
if (date.maximum !== undefined)
output.max = new Date(date.maximum).toISOString();
}
else if (type == 'string') {
const str = schema;
const patterns = [
str.pattern,
...(str.allOf ? str.allOf.map((s) => (typeof s == 'boolean' ? undefined : s.pattern)) : [])
].filter((s) => s !== undefined);
if (patterns.length > 0)
output.pattern = patterns[0];
if (str.minLength !== undefined)
output.minlength = str.minLength;
if (str.maxLength !== undefined)
output.maxlength = str.maxLength;
}
else if (type == 'number' || type == 'integer') {
const num = schema;
if (num.minimum !== undefined)
output.min = num.minimum;
else if (num.exclusiveMinimum !== undefined)
output.min = num.exclusiveMinimum + (type == 'integer' ? 1 : Number.MIN_VALUE);
if (num.maximum !== undefined)
output.max = num.maximum;
else if (num.exclusiveMaximum !== undefined)
output.max = num.exclusiveMaximum - (type == 'integer' ? 1 : Number.MIN_VALUE);
if (num.multipleOf !== undefined)
output.step = num.multipleOf;
}
else if (type == 'array') {
const arr = schema;
if (arr.minItems !== undefined)
output.min = arr.minItems;
if (arr.maxItems !== undefined)
output.max = arr.maxItems;
}
if (!info.isNullable && !info.isOptional) {
output.required = true;
}
return Object.keys(output).length > 0 ? output : undefined;
}

View File

@@ -0,0 +1 @@
export type { JSONSchema7 as JSONSchema } from 'json-schema';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,13 @@
import type { JSONSchema } from './index.js';
import type { SchemaType } from './schemaInfo.js';
export declare function defaultValues<T = Record<string, unknown>>(schema: JSONSchema, isOptional?: boolean, path?: string[]): T;
export declare function defaultValue(type: SchemaType, enumType: unknown[] | undefined): unknown;
export declare function defaultTypes(schema: JSONSchema, path?: string[]): SchemaTypeObject;
export type SchemaFieldType = {
__types: (SchemaType | 'null' | 'undefined')[];
__items?: SchemaTypeObject;
};
type SchemaTypeObject = {
[Key in Exclude<string, '_types' | '_items'>]: SchemaTypeObject;
} & SchemaFieldType;
export {};

View File

@@ -0,0 +1,289 @@
import { SchemaError } from '../errors.js';
import { assertSchema } from '../utils.js';
import { merge } from 'ts-deepmerge';
import { schemaInfo } from './schemaInfo.js';
export function defaultValues(schema, isOptional = false, path = []) {
return _defaultValues(schema, isOptional, path);
}
function _defaultValues(schema, isOptional, path) {
if (!schema) {
throw new SchemaError('Schema was undefined', path);
}
const info = schemaInfo(schema, isOptional, path);
if (!info)
return undefined;
//if (schema.type == 'object') console.log('--- OBJECT ---');
//else console.dir({ path, schema, isOptional }, { depth: 10 });
let objectDefaults = undefined;
// Default takes (early) priority.
if ('default' in schema) {
// Test for object defaults.
// Cannot be returned directly, since undefined fields
// may have to be replaced with correct default values.
if (info.types.includes('object') &&
schema.default &&
typeof schema.default == 'object' &&
!Array.isArray(schema.default)) {
objectDefaults = schema.default;
}
else {
if (info.types.length > 1) {
if (info.types.includes('unix-time') &&
(info.types.includes('integer') || info.types.includes('number')))
throw new SchemaError('Cannot resolve a default value with a union that includes a date and a number/integer.', path);
}
const [type] = info.types;
return formatDefaultValue(type, schema.default);
}
}
let _multiType;
const isMultiTypeUnion = () => {
if (!info.union || info.union.length < 2)
return false;
if (info.union.some((i) => i.enum))
return true;
if (!_multiType) {
_multiType = new Set(info.types.map((i) => {
return ['integer', 'unix-time'].includes(i) ? 'number' : i;
}));
}
return _multiType.size > 1;
};
let output = undefined;
// Check unions first, so default values can take precedence over nullable and optional
if (!objectDefaults && info.union) {
const singleDefault = info.union.filter((s) => typeof s !== 'boolean' && s.default !== undefined);
if (singleDefault.length == 1) {
return _defaultValues(singleDefault[0], isOptional, path);
}
else if (singleDefault.length > 1) {
throw new SchemaError('Only one default value can exist in a union, or set a default value for the whole union.', path);
}
else {
// Null takes priority over undefined
if (info.isNullable)
return null;
if (info.isOptional)
return undefined;
if (isMultiTypeUnion()) {
throw new SchemaError('Multi-type unions must have a default value, or exactly one of the union types must have.', path);
}
// Objects must have default values to avoid setting undefined properties on nested data
if (info.union.length) {
if (info.types[0] == 'object') {
if (output === undefined)
output = {};
output =
info.union.length > 1
? merge.withOptions({ allowUndefinedOverrides: true }, ...info.union.map((s) => _defaultValues(s, isOptional, path)))
: _defaultValues(info.union[0], isOptional, path);
}
else {
return _defaultValues(info.union[0], isOptional, path);
}
}
}
}
if (!objectDefaults) {
// Null takes priority over undefined
if (info.isNullable)
return null;
if (info.isOptional)
return undefined;
}
// Objects
if (info.properties) {
for (const [key, objSchema] of Object.entries(info.properties)) {
assertSchema(objSchema, [...path, key]);
// Determine the default value for this property. If an object-level default
// was provided, attempt to convert it to the correct typed form using the
// property's schema information (e.g. convert ISO strings back to Date).
let def;
if (objectDefaults && objectDefaults[key] !== undefined) {
try {
const propInfo = schemaInfo(objSchema, !info.required?.includes(key), [...path, key]);
if (propInfo) {
// Use the first resolved type to format simple values (dates, sets, maps, bigint, symbol)
const propType = propInfo.types[0];
// If property is an object and the provided default is an object,
// we need to recursively process it to convert nested values (e.g. Date strings to Dates)
if (propType === 'object' &&
typeof objectDefaults[key] === 'object' &&
objectDefaults[key] !== null &&
!Array.isArray(objectDefaults[key])) {
// Create a temporary schema with the parent's default to recursively process it
const schemaWithDefault = {
...objSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: objectDefaults[key]
};
def = _defaultValues(schemaWithDefault, !info.required?.includes(key), [
...path,
key
]);
}
else {
def = formatDefaultValue(propType, objectDefaults[key]);
}
}
else {
def = objectDefaults[key];
}
}
catch {
// If anything goes wrong determining/formatting, fall back to provided value
def = objectDefaults[key];
}
}
else {
def = _defaultValues(objSchema, !info.required?.includes(key), [...path, key]);
}
//if (def !== undefined) output[key] = def;
if (output === undefined)
output = {};
output[key] = def;
}
}
else if (objectDefaults) {
return objectDefaults;
}
// TODO: [v3] Handle default values for array elements
// if (info.array && info.array.length) {
// console.log('===== Array default =====');
// console.dir(info.array, { depth: 10 }); //debug
// //if (info.array.length > 1) throw new SchemaError('Only one array type is supported.', path);
// console.dir(_defaultValues(info.array[0], info.isOptional, path), { depth: 10 }); //debug
// }
// Enums, return the first value so it can be a required field
if (schema.enum) {
return schema.enum[0];
}
// Constants
if ('const' in schema) {
return schema.const;
}
// Basic type
if (isMultiTypeUnion()) {
throw new SchemaError('Default values cannot have more than one type.', path);
}
else if (info.types.length == 0) {
//console.warn('No type or format for property:', path); //debug
//console.dir(schema, { depth: 10 }); //debug
return undefined;
}
const [formatType] = info.types;
return output ?? defaultValue(formatType, schema.enum);
}
function formatDefaultValue(type, value) {
switch (type) {
case 'set':
return Array.isArray(value) ? new Set(value) : value;
case 'map':
return Array.isArray(value) ? new Map(value) : value;
case 'Date':
case 'date':
case 'unix-time':
if (typeof value === 'string' || typeof value === 'number')
return new Date(value);
break;
case 'bigint':
if (typeof value === 'string' || typeof value === 'number')
return BigInt(value);
break;
case 'symbol':
if (typeof value === 'string' || typeof value === 'number')
return Symbol(value);
break;
}
return value;
}
export function defaultValue(type, enumType) {
switch (type) {
case 'string':
return enumType && enumType.length > 0 ? enumType[0] : '';
case 'number':
case 'integer':
return enumType && enumType.length > 0 ? enumType[0] : 0;
case 'boolean':
return false;
case 'array':
return [];
case 'object':
return {};
case 'null':
return null;
case 'Date':
case 'date':
case 'unix-time':
// Cannot add default for Date due to https://github.com/Rich-Harris/devalue/issues/51
return undefined;
case 'int64':
case 'bigint':
return BigInt(0);
case 'stringbool':
// For stringbool, return empty string - let Zod validation handle it
// The schema should define a default if one is needed
return '';
case 'set':
return new Set();
case 'map':
return new Map();
case 'symbol':
return Symbol();
case 'undefined':
case 'any':
return undefined;
default:
throw new SchemaError('Schema type or format not supported, requires explicit default value: ' + type);
}
}
////////////////////////////////////////////////////////////////////////////
export function defaultTypes(schema, path = []) {
return _defaultTypes(schema, false, path);
}
function _defaultTypes(schema, isOptional, path) {
if (!schema) {
throw new SchemaError('Schema was undefined', path);
}
const info = schemaInfo(schema, isOptional, path);
let output = {
__types: info.types
};
if (info.union) {
output = merge(output, ...info.union.map((u) => _defaultTypes(u, info.isOptional, path)));
}
//if (schema.type == 'object') console.log('--- OBJECT ---'); //debug
//else console.dir({ path, info }, { depth: 10 }); //debug
// schema.items cannot be an array according to
// https://www.learnjsonschema.com/2020-12/applicator/items/
if (info.schema.items &&
typeof info.schema.items == 'object' &&
!Array.isArray(info.schema.items)) {
output.__items = _defaultTypes(info.schema.items, info.isOptional, path);
}
if (info.properties) {
for (const [key, value] of Object.entries(info.properties)) {
assertSchema(value, [...path, key]);
output[key] = _defaultTypes(info.properties[key], !info.required?.includes(key), [
...path,
key
]);
}
}
// Check if a Record type is used for additionalProperties
if (info.additionalProperties && info.types.includes('object')) {
const additionalInfo = schemaInfo(info.additionalProperties, info.isOptional, path);
if (additionalInfo.properties && additionalInfo.types.includes('object')) {
for (const [key] of Object.entries(additionalInfo.properties)) {
output[key] = _defaultTypes(additionalInfo.properties[key], !additionalInfo.required?.includes(key), [...path, key]);
}
}
}
if (info.isNullable && !output.__types.includes('null')) {
output.__types.push('null');
}
if (info.isOptional && !output.__types.includes('undefined')) {
output.__types.push('undefined');
}
return output;
}

View File

@@ -0,0 +1,2 @@
import type { JSONSchema } from './index.js';
export declare function schemaHash(schema: JSONSchema): string;

View File

@@ -0,0 +1,56 @@
import { schemaInfo } from './schemaInfo.js';
export function schemaHash(schema) {
return hashCode(_schemaHash(schemaInfo(schema, false, []), 0, []));
}
function _schemaHash(info, depth, path) {
if (!info)
return '';
function tab() {
return ' '.repeat(depth);
}
function mapSchemas(schemas) {
return schemas
.map((s) => _schemaHash(schemaInfo(s, info?.isOptional ?? false, path), depth + 1, path))
.filter((s) => s)
.join('|');
}
function nullish() {
const output = [];
if (info?.isNullable)
output.push('null');
if (info?.isOptional)
output.push('undefined');
return !output.length ? '' : '|' + output.join('|');
}
// Union
if (info.union) {
return 'Union {\n ' + tab() + mapSchemas(info.union) + '\n' + tab() + '}' + nullish();
}
// Objects
if (info.properties) {
const output = [];
for (const [key, prop] of Object.entries(info.properties)) {
const propInfo = schemaInfo(prop, !info.required?.includes(key) || prop.default !== undefined, [key]);
output.push(key + ': ' + _schemaHash(propInfo, depth + 1, path));
}
return 'Object {\n ' + tab() + output.join(',\n ') + '\n' + tab() + '}' + nullish();
}
// Arrays
if (info.array) {
return 'Array[' + mapSchemas(info.array) + ']' + nullish();
}
return info.types.join('|') + nullish();
}
// https://stackoverflow.com/a/8831937/70894
function hashCode(str) {
let hash = 0;
for (let i = 0, len = str.length; i < len; i++) {
const chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
// Make it unsigned, for the hash appearance
if (hash < 0)
hash = hash >>> 0;
return hash.toString(36);
}

View File

@@ -0,0 +1,22 @@
import type { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from 'json-schema';
export type SchemaType = JSONSchema7TypeName | 'Date' | 'date' | 'unix-time' | 'bigint' | 'int64' | 'any' | 'symbol' | 'set' | 'map' | 'null' | 'undefined' | 'stringbool';
export type SchemaInfo = {
types: Exclude<SchemaType, 'null'>[];
isOptional: boolean;
isNullable: boolean;
schema: JSONSchema7;
union?: JSONSchema7[];
array?: JSONSchema7[];
properties?: {
[key: string]: JSONSchema7;
};
additionalProperties?: {
[key: string]: JSONSchema7;
};
required?: string[];
};
/**
* Normalizes the different kind of schema variations (anyOf, oneOf, union, const null, etc)
* to figure out the field type, optional, nullable, etc.
*/
export declare function schemaInfo(schema: JSONSchema7Definition, isOptional: boolean, path: (string | number | symbol)[]): SchemaInfo;

View File

@@ -0,0 +1,105 @@
import { assertSchema } from '../utils.js';
import { merge } from 'ts-deepmerge';
const conversionFormatTypes = [
'unix-time',
'bigint',
'any',
'symbol',
'set',
'map',
'int64',
'stringbool'
];
/**
* Normalizes the different kind of schema variations (anyOf, oneOf, union, const null, etc)
* to figure out the field type, optional, nullable, etc.
*/
export function schemaInfo(schema, isOptional, path) {
assertSchema(schema, path);
const types = schemaTypes(schema, path);
const array = schema.items && types.includes('array')
? (Array.isArray(schema.items) ? schema.items : [schema.items]).filter((s) => typeof s !== 'boolean')
: undefined;
const additionalProperties = schema.additionalProperties &&
typeof schema.additionalProperties === 'object' &&
types.includes('object')
? Object.fromEntries(Object.entries(schema.additionalProperties).filter(([, value]) => typeof value !== 'boolean'))
: undefined;
const properties = schema.properties && types.includes('object')
? Object.fromEntries(Object.entries(schema.properties).filter(([, value]) => typeof value !== 'boolean'))
: undefined;
const union = unionInfo(schema)?.filter((u) => u.type !== 'null' && u.const !== null);
const result = {
types: types.filter((s) => s !== 'null'),
isOptional,
isNullable: types.includes('null'),
schema,
union: union?.length ? union : undefined,
array,
properties,
additionalProperties,
required: schema.required
};
if (!schema.allOf || !schema.allOf.length) {
return result;
}
return {
...merge.withOptions({ allowUndefinedOverrides: false }, result, ...schema.allOf.map((s) => schemaInfo(s, false, []))),
schema
};
}
function schemaTypes(schema, path) {
assertSchema(schema, path);
let types = schema.const === null ? ['null'] : [];
if (schema.type) {
types = Array.isArray(schema.type) ? schema.type : [schema.type];
}
if (schema.anyOf) {
types = schema.anyOf.flatMap((s) => schemaTypes(s, path));
}
if (schema.oneOf) {
types = schema.oneOf.flatMap((s) => schemaTypes(s, path));
}
if (types.includes('array') && schema.uniqueItems) {
const i = types.findIndex((t) => t === 'array');
if (i !== -1)
types[i] = 'set';
}
else if (schema.format && conversionFormatTypes.includes(schema.format)) {
types.unshift(schema.format);
// For dates and int64 (bigint), remove the integer type, as the schema format will be used
// instead in the following cases
if (schema.format == 'unix-time' || schema.format == 'int64') {
const i = types.findIndex((t) => t == 'integer');
types.splice(i, 1);
}
// For bigint, remove the string type, as the schema format will be used
// instead in the following cases
if (schema.format == 'bigint') {
const i = types.findIndex((t) => t == 'string');
types.splice(i, 1);
}
// For stringbool, remove the string type, as the schema format will be used
// stringbool should be treated as a special string that validates to boolean
if (schema.format == 'stringbool') {
const i = types.findIndex((t) => t == 'string');
if (i !== -1)
types.splice(i, 1);
}
}
if (schema.const && schema.const !== null && typeof schema.const !== 'function') {
types.push(typeof schema.const);
}
return Array.from(new Set(types));
}
function unionInfo(schema) {
if (!schema.oneOf && !schema.anyOf)
return undefined;
if (schema.oneOf && schema.oneOf.length) {
return schema.oneOf.filter((s) => typeof s !== 'boolean');
}
if (schema.anyOf && schema.anyOf.length) {
return schema.anyOf.filter((s) => typeof s !== 'boolean');
}
return undefined;
}

View File

@@ -0,0 +1,11 @@
import type { JSONSchema } from './index.js';
/**
* A tree structure where the existence of a node means that the field is an array or an object.
* Used in error mapping to determine whether to add errors to an _error field
* (as in arrays and objects), or directly on the field itself.
*/
export type SchemaShape = {
[K in string]: SchemaShape;
};
export declare function schemaShape(schema: JSONSchema, path?: string[]): SchemaShape;
export declare function shapeFromObject(obj: object): SchemaShape;

View File

@@ -0,0 +1,46 @@
import { SchemaError } from '../errors.js';
import { schemaInfo } from './schemaInfo.js';
import { assertSchema } from '../utils.js';
export function schemaShape(schema, path = []) {
const output = _schemaShape(schema, path);
if (!output)
throw new SchemaError('No shape could be created for schema.', path);
return output;
}
function _schemaShape(schema, path) {
assertSchema(schema, path);
const info = schemaInfo(schema, false, path);
if (info.array || info.union) {
const arr = info.array || [];
const union = info.union || [];
return arr.concat(union).reduce((shape, next) => {
const nextShape = _schemaShape(next, path);
if (nextShape)
shape = { ...(shape ?? {}), ...nextShape };
return shape;
}, arr.length ? {} : undefined);
}
if (info.properties) {
const output = {};
for (const [key, prop] of Object.entries(info.properties)) {
const shape = _schemaShape(prop, [...path, key]);
if (shape)
output[key] = shape;
}
return output;
}
return info.types.includes('array') || info.types.includes('object') ? {} : undefined;
}
export function shapeFromObject(obj) {
let output = {};
const isArray = Array.isArray(obj);
for (const [key, value] of Object.entries(obj)) {
if (!value || typeof value !== 'object')
continue;
if (isArray)
output = { ...output, ...shapeFromObject(value) };
else
output[key] = shapeFromObject(value);
}
return output;
}