- 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
253 lines
8.4 KiB
JavaScript
253 lines
8.4 KiB
JavaScript
import { CastableBase, ReadonlyArray, ReadonlyPath, append, conflatenateAll, defineProperties, flatMorph, stringifyPath } from "@ark/util";
|
|
import { arkKind } from "./utils.js";
|
|
export class ArkError extends CastableBase {
|
|
[arkKind] = "error";
|
|
path;
|
|
data;
|
|
nodeConfig;
|
|
input;
|
|
ctx;
|
|
// TS gets confused by <code>, so internally we just use the base type for input
|
|
constructor({ prefixPath, relativePath, ...input }, ctx) {
|
|
super();
|
|
this.input = input;
|
|
this.ctx = ctx;
|
|
defineProperties(this, input);
|
|
const data = ctx.data;
|
|
if (input.code === "union") {
|
|
input.errors = input.errors.flatMap(innerError => {
|
|
// flatten union errors to avoid repeating context like "foo must be foo must be"...
|
|
const flat = innerError.hasCode("union") ? innerError.errors : [innerError];
|
|
if (!prefixPath && !relativePath)
|
|
return flat;
|
|
return flat.map(e => e.transform(e => ({
|
|
...e,
|
|
path: conflatenateAll(prefixPath, e.path, relativePath)
|
|
})));
|
|
});
|
|
}
|
|
this.nodeConfig = ctx.config[this.code];
|
|
const basePath = [...(input.path ?? ctx.path)];
|
|
if (relativePath)
|
|
basePath.push(...relativePath);
|
|
if (prefixPath)
|
|
basePath.unshift(...prefixPath);
|
|
this.path = new ReadonlyPath(...basePath);
|
|
this.data = "data" in input ? input.data : data;
|
|
}
|
|
transform(f) {
|
|
return new ArkError(f({
|
|
data: this.data,
|
|
path: this.path,
|
|
...this.input
|
|
}), this.ctx);
|
|
}
|
|
hasCode(code) {
|
|
return this.code === code;
|
|
}
|
|
get propString() {
|
|
return stringifyPath(this.path);
|
|
}
|
|
get expected() {
|
|
if (this.input.expected)
|
|
return this.input.expected;
|
|
const config = this.meta?.expected ?? this.nodeConfig.expected;
|
|
return typeof config === "function" ? config(this.input) : config;
|
|
}
|
|
get actual() {
|
|
if (this.input.actual)
|
|
return this.input.actual;
|
|
const config = this.meta?.actual ?? this.nodeConfig.actual;
|
|
return typeof config === "function" ? config(this.data) : config;
|
|
}
|
|
get problem() {
|
|
if (this.input.problem)
|
|
return this.input.problem;
|
|
const config = this.meta?.problem ?? this.nodeConfig.problem;
|
|
return typeof config === "function" ? config(this) : config;
|
|
}
|
|
get message() {
|
|
if (this.input.message)
|
|
return this.input.message;
|
|
const config = this.meta?.message ?? this.nodeConfig.message;
|
|
return typeof config === "function" ? config(this) : config;
|
|
}
|
|
get flat() {
|
|
return this.hasCode("intersection") ? [...this.errors] : [this];
|
|
}
|
|
toJSON() {
|
|
return {
|
|
data: this.data,
|
|
path: this.path,
|
|
...this.input,
|
|
expected: this.expected,
|
|
actual: this.actual,
|
|
problem: this.problem,
|
|
message: this.message
|
|
};
|
|
}
|
|
toString() {
|
|
return this.message;
|
|
}
|
|
throw() {
|
|
throw this;
|
|
}
|
|
}
|
|
/**
|
|
* A ReadonlyArray of `ArkError`s returned by a Type on invalid input.
|
|
*
|
|
* Subsequent errors added at an existing path are merged into an
|
|
* ArkError intersection.
|
|
*/
|
|
export class ArkErrors extends ReadonlyArray {
|
|
[arkKind] = "errors";
|
|
ctx;
|
|
constructor(ctx) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
/**
|
|
* Errors by a pathString representing their location.
|
|
*/
|
|
byPath = Object.create(null);
|
|
/**
|
|
* {@link byPath} flattened so that each value is an array of ArkError instances at that path.
|
|
*
|
|
* ✅ Since "intersection" errors will be flattened to their constituent `.errors`,
|
|
* they will never be directly present in this representation.
|
|
*/
|
|
get flatByPath() {
|
|
return flatMorph(this.byPath, (k, v) => [k, v.flat]);
|
|
}
|
|
/**
|
|
* {@link byPath} flattened so that each value is an array of problem strings at that path.
|
|
*/
|
|
get flatProblemsByPath() {
|
|
return flatMorph(this.byPath, (k, v) => [k, v.flat.map(e => e.problem)]);
|
|
}
|
|
/**
|
|
* All pathStrings at which errors are present mapped to the errors occuring
|
|
* at that path or any nested path within it.
|
|
*/
|
|
byAncestorPath = Object.create(null);
|
|
count = 0;
|
|
mutable = this;
|
|
/**
|
|
* Throw a TraversalError based on these errors.
|
|
*/
|
|
throw() {
|
|
throw this.toTraversalError();
|
|
}
|
|
/**
|
|
* Converts ArkErrors to TraversalError, a subclass of `Error` suitable for throwing with nice
|
|
* formatting.
|
|
*/
|
|
toTraversalError() {
|
|
return new TraversalError(this);
|
|
}
|
|
/**
|
|
* Append an ArkError to this array, ignoring duplicates.
|
|
*/
|
|
add(error) {
|
|
const existing = this.byPath[error.propString];
|
|
if (existing) {
|
|
if (error === existing)
|
|
return;
|
|
// If the existing error is an error for a value constrained to "never",
|
|
// then we don't want to intersect the error messages.
|
|
if (existing.hasCode("union") && existing.errors.length === 0)
|
|
return;
|
|
// If the new error is an error for a value constrained to "never",
|
|
// then we want to override any existing errors.
|
|
const errorIntersection = error.hasCode("union") && error.errors.length === 0 ?
|
|
error
|
|
: new ArkError({
|
|
code: "intersection",
|
|
errors: existing.hasCode("intersection") ?
|
|
[...existing.errors, error]
|
|
: [existing, error]
|
|
}, this.ctx);
|
|
const existingIndex = this.indexOf(existing);
|
|
this.mutable[existingIndex === -1 ? this.length : existingIndex] =
|
|
errorIntersection;
|
|
this.byPath[error.propString] = errorIntersection;
|
|
// add the original error here rather than the intersection
|
|
// since the intersection is reflected by the array of errors at
|
|
// this path
|
|
this.addAncestorPaths(error);
|
|
}
|
|
else {
|
|
this.byPath[error.propString] = error;
|
|
this.addAncestorPaths(error);
|
|
this.mutable.push(error);
|
|
}
|
|
this.count++;
|
|
}
|
|
transform(f) {
|
|
const result = new ArkErrors(this.ctx);
|
|
for (const e of this)
|
|
result.add(f(e));
|
|
return result;
|
|
}
|
|
/**
|
|
* Add all errors from an ArkErrors instance, ignoring duplicates and
|
|
* prefixing their paths with that of the current Traversal.
|
|
*/
|
|
merge(errors) {
|
|
for (const e of errors) {
|
|
this.add(new ArkError({ ...e, path: [...this.ctx.path, ...e.path] }, this.ctx));
|
|
}
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
affectsPath(path) {
|
|
if (this.length === 0)
|
|
return false;
|
|
return (
|
|
// this would occur if there is an existing error at a prefix of path
|
|
// e.g. the path is ["foo", "bar"] and there is an error at ["foo"]
|
|
path.stringifyAncestors().some(s => s in this.byPath) ||
|
|
// this would occur if there is an existing error at a suffix of path
|
|
// e.g. the path is ["foo"] and there is an error at ["foo", "bar"]
|
|
path.stringify() in this.byAncestorPath);
|
|
}
|
|
/**
|
|
* A human-readable summary of all errors.
|
|
*/
|
|
get summary() {
|
|
return this.toString();
|
|
}
|
|
/**
|
|
* Alias of this ArkErrors instance for StandardSchema compatibility.
|
|
*/
|
|
get issues() {
|
|
return this;
|
|
}
|
|
toJSON() {
|
|
return [...this.map(e => e.toJSON())];
|
|
}
|
|
toString() {
|
|
return this.join("\n");
|
|
}
|
|
addAncestorPaths(error) {
|
|
for (const propString of error.path.stringifyAncestors()) {
|
|
this.byAncestorPath[propString] = append(this.byAncestorPath[propString], error);
|
|
}
|
|
}
|
|
}
|
|
export class TraversalError extends Error {
|
|
name = "TraversalError";
|
|
constructor(errors) {
|
|
if (errors.length === 1)
|
|
super(errors.summary);
|
|
else
|
|
super("\n" + errors.map(error => ` • ${indent(error)}`).join("\n"));
|
|
Object.defineProperty(this, "arkErrors", {
|
|
value: errors,
|
|
enumerable: false
|
|
});
|
|
}
|
|
}
|
|
const indent = (error) => error.toString().split("\n").join("\n ");
|