- 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
227 lines
7.3 KiB
JavaScript
227 lines
7.3 KiB
JavaScript
import { ReadonlyPath, stringifyPath } from "@ark/util";
|
|
import { ArkError, ArkErrors } from "./errors.js";
|
|
import { isNode } from "./utils.js";
|
|
export class Traversal {
|
|
/**
|
|
* #### the path being validated or morphed
|
|
*
|
|
* ✅ array indices represented as numbers
|
|
* ⚠️ mutated during traversal - use `path.slice(0)` to snapshot
|
|
* 🔗 use {@link propString} for a stringified version
|
|
*/
|
|
path = [];
|
|
/**
|
|
* #### {@link ArkErrors} that will be part of this traversal's finalized result
|
|
*
|
|
* ✅ will always be an empty array for a valid traversal
|
|
*/
|
|
errors = new ArkErrors(this);
|
|
/**
|
|
* #### the original value being traversed
|
|
*/
|
|
root;
|
|
/**
|
|
* #### configuration for this traversal
|
|
*
|
|
* ✅ options can affect traversal results and error messages
|
|
* ✅ defaults < global config < scope config
|
|
* ✅ does not include options configured on individual types
|
|
*/
|
|
config;
|
|
queuedMorphs = [];
|
|
branches = [];
|
|
seen = {};
|
|
constructor(root, config) {
|
|
this.root = root;
|
|
this.config = config;
|
|
}
|
|
/**
|
|
* #### the data being validated or morphed
|
|
*
|
|
* ✅ extracted from {@link root} at {@link path}
|
|
*/
|
|
get data() {
|
|
let result = this.root;
|
|
for (const segment of this.path)
|
|
result = result?.[segment];
|
|
return result;
|
|
}
|
|
/**
|
|
* #### a string representing {@link path}
|
|
*
|
|
* @propString
|
|
*/
|
|
get propString() {
|
|
return stringifyPath(this.path);
|
|
}
|
|
/**
|
|
* #### add an {@link ArkError} and return `false`
|
|
*
|
|
* ✅ useful for predicates like `.narrow`
|
|
*/
|
|
reject(input) {
|
|
this.error(input);
|
|
return false;
|
|
}
|
|
/**
|
|
* #### add an {@link ArkError} from a description and return `false`
|
|
*
|
|
* ✅ useful for predicates like `.narrow`
|
|
* 🔗 equivalent to {@link reject}({ expected })
|
|
*/
|
|
mustBe(expected) {
|
|
this.error(expected);
|
|
return false;
|
|
}
|
|
error(input) {
|
|
const errCtx = typeof input === "object" ?
|
|
input.code ?
|
|
input
|
|
: { ...input, code: "predicate" }
|
|
: { code: "predicate", expected: input };
|
|
return this.errorFromContext(errCtx);
|
|
}
|
|
/**
|
|
* #### whether {@link currentBranch} (or the traversal root, outside a union) has one or more errors
|
|
*/
|
|
hasError() {
|
|
return this.currentErrorCount !== 0;
|
|
}
|
|
get currentBranch() {
|
|
return this.branches[this.branches.length - 1];
|
|
}
|
|
queueMorphs(morphs) {
|
|
const input = {
|
|
path: new ReadonlyPath(...this.path),
|
|
morphs
|
|
};
|
|
if (this.currentBranch)
|
|
this.currentBranch.queuedMorphs.push(input);
|
|
else
|
|
this.queuedMorphs.push(input);
|
|
}
|
|
finalize(onFail) {
|
|
if (this.queuedMorphs.length) {
|
|
if (typeof this.root === "object" &&
|
|
this.root !== null &&
|
|
this.config.clone)
|
|
this.root = this.config.clone(this.root);
|
|
this.applyQueuedMorphs();
|
|
}
|
|
if (this.hasError())
|
|
return onFail ? onFail(this.errors) : this.errors;
|
|
return this.root;
|
|
}
|
|
get currentErrorCount() {
|
|
return (this.currentBranch ?
|
|
this.currentBranch.error ?
|
|
1
|
|
: 0
|
|
: this.errors.count);
|
|
}
|
|
get failFast() {
|
|
return this.branches.length !== 0;
|
|
}
|
|
pushBranch() {
|
|
this.branches.push({
|
|
error: undefined,
|
|
queuedMorphs: []
|
|
});
|
|
}
|
|
popBranch() {
|
|
return this.branches.pop();
|
|
}
|
|
/**
|
|
* @internal
|
|
* Convenience for casting from InternalTraversal to Traversal
|
|
* for cases where the extra methods on the external type are expected, e.g.
|
|
* a morph or predicate.
|
|
*/
|
|
get external() {
|
|
return this;
|
|
}
|
|
errorFromNodeContext(input) {
|
|
return this.errorFromContext(input);
|
|
}
|
|
errorFromContext(errCtx) {
|
|
const error = new ArkError(errCtx, this);
|
|
if (this.currentBranch)
|
|
this.currentBranch.error = error;
|
|
else
|
|
this.errors.add(error);
|
|
return error;
|
|
}
|
|
applyQueuedMorphs() {
|
|
// invoking morphs that are Nodes will reuse this context, potentially
|
|
// adding additional morphs, so we have to continue looping until
|
|
// queuedMorphs is empty rather than iterating over the list once
|
|
while (this.queuedMorphs.length) {
|
|
const queuedMorphs = this.queuedMorphs;
|
|
this.queuedMorphs = [];
|
|
for (const { path, morphs } of queuedMorphs) {
|
|
// even if we already have an error, apply morphs that are not at a path
|
|
// with errors to capture potential validation errors
|
|
if (this.errors.affectsPath(path))
|
|
continue;
|
|
this.applyMorphsAtPath(path, morphs);
|
|
}
|
|
}
|
|
}
|
|
applyMorphsAtPath(path, morphs) {
|
|
const key = path[path.length - 1];
|
|
let parent;
|
|
if (key !== undefined) {
|
|
// find the object on which the key to be morphed exists
|
|
parent = this.root;
|
|
for (let pathIndex = 0; pathIndex < path.length - 1; pathIndex++)
|
|
parent = parent[path[pathIndex]];
|
|
}
|
|
for (const morph of morphs) {
|
|
// ensure morphs are applied relative to the correct path
|
|
// in case previous operations modified this.path
|
|
this.path = [...path];
|
|
const morphIsNode = isNode(morph);
|
|
const result = morph((parent === undefined ? this.root : parent[key]), this);
|
|
if (result instanceof ArkError) {
|
|
// if an ArkError was returned, ensure it has been added to errors
|
|
// (it may have already been added via ctx.error() within the morph)
|
|
// Only add if it's not already in the errors collection
|
|
if (!this.errors.includes(result))
|
|
this.errors.add(result);
|
|
// skip any remaining morphs at the current path
|
|
break;
|
|
}
|
|
if (result instanceof ArkErrors) {
|
|
// if the morph was a direct reference to another node,
|
|
// errors will have been added directly via this piped context
|
|
if (!morphIsNode) {
|
|
// otherwise, we have to ensure each error has been added
|
|
this.errors.merge(result);
|
|
}
|
|
// skip any remaining morphs at the current path
|
|
this.queuedMorphs = [];
|
|
break;
|
|
}
|
|
// if the morph was successful, assign the result to the
|
|
// corresponding property, or to root if path is empty
|
|
if (parent === undefined)
|
|
this.root = result;
|
|
else
|
|
parent[key] = result;
|
|
// if the current morph queued additional morphs,
|
|
// applying them before subsequent morphs
|
|
this.applyQueuedMorphs();
|
|
}
|
|
}
|
|
}
|
|
export const traverseKey = (key, fn,
|
|
// ctx will be undefined if this node isn't context-dependent
|
|
ctx) => {
|
|
if (!ctx)
|
|
return fn();
|
|
ctx.path.push(key);
|
|
const result = fn();
|
|
ctx.path.pop();
|
|
return result;
|
|
};
|