- 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
522 lines
23 KiB
JavaScript
522 lines
23 KiB
JavaScript
import { append, conflatenate, printable, throwInternalError, throwParseError } from "@ark/util";
|
|
import { BaseConstraint } from "../constraint.js";
|
|
import { appendUniqueFlatRefs, flatRef } from "../node.js";
|
|
import { Disjoint } from "../shared/disjoint.js";
|
|
import { defaultValueSerializer, implementNode } from "../shared/implement.js";
|
|
import { intersectOrPipeNodes } from "../shared/intersections.js";
|
|
import { $ark, registeredReference } from "../shared/registry.js";
|
|
import { traverseKey } from "../shared/traversal.js";
|
|
import { assertDefaultValueAssignability, computeDefaultValueMorph } from "./optional.js";
|
|
import { writeDefaultIntersectionMessage } from "./prop.js";
|
|
const implementation = implementNode({
|
|
kind: "sequence",
|
|
hasAssociatedError: false,
|
|
collapsibleKey: "variadic",
|
|
keys: {
|
|
prefix: {
|
|
child: true,
|
|
parse: (schema, ctx) => {
|
|
// empty affixes are omitted. an empty array should therefore
|
|
// be specified as `{ proto: Array, length: 0 }`
|
|
if (schema.length === 0)
|
|
return undefined;
|
|
return schema.map(element => ctx.$.parseSchema(element));
|
|
}
|
|
},
|
|
optionals: {
|
|
child: true,
|
|
parse: (schema, ctx) => {
|
|
if (schema.length === 0)
|
|
return undefined;
|
|
return schema.map(element => ctx.$.parseSchema(element));
|
|
}
|
|
},
|
|
defaultables: {
|
|
child: defaultables => defaultables.map(element => element[0]),
|
|
parse: (defaultables, ctx) => {
|
|
if (defaultables.length === 0)
|
|
return undefined;
|
|
return defaultables.map(element => {
|
|
const node = ctx.$.parseSchema(element[0]);
|
|
assertDefaultValueAssignability(node, element[1], null);
|
|
return [node, element[1]];
|
|
});
|
|
},
|
|
serialize: defaults => defaults.map(element => [
|
|
element[0].collapsibleJson,
|
|
defaultValueSerializer(element[1])
|
|
]),
|
|
reduceIo: (ioKind, inner, defaultables) => {
|
|
if (ioKind === "in") {
|
|
inner.optionals = defaultables.map(d => d[0].rawIn);
|
|
return;
|
|
}
|
|
inner.prefix = defaultables.map(d => d[0].rawOut);
|
|
return;
|
|
}
|
|
},
|
|
variadic: {
|
|
child: true,
|
|
parse: (schema, ctx) => ctx.$.parseSchema(schema, ctx)
|
|
},
|
|
minVariadicLength: {
|
|
// minVariadicLength is reflected in the id of this node,
|
|
// but not its IntersectionNode parent since it is superceded by the minLength
|
|
// node it implies
|
|
parse: min => (min === 0 ? undefined : min)
|
|
},
|
|
postfix: {
|
|
child: true,
|
|
parse: (schema, ctx) => {
|
|
if (schema.length === 0)
|
|
return undefined;
|
|
return schema.map(element => ctx.$.parseSchema(element));
|
|
}
|
|
}
|
|
},
|
|
normalize: schema => {
|
|
if (typeof schema === "string")
|
|
return { variadic: schema };
|
|
if ("variadic" in schema ||
|
|
"prefix" in schema ||
|
|
"defaultables" in schema ||
|
|
"optionals" in schema ||
|
|
"postfix" in schema ||
|
|
"minVariadicLength" in schema) {
|
|
if (schema.postfix?.length) {
|
|
if (!schema.variadic)
|
|
return throwParseError(postfixWithoutVariadicMessage);
|
|
if (schema.optionals?.length || schema.defaultables?.length)
|
|
return throwParseError(postfixAfterOptionalOrDefaultableMessage);
|
|
}
|
|
if (schema.minVariadicLength && !schema.variadic) {
|
|
return throwParseError("minVariadicLength may not be specified without a variadic element");
|
|
}
|
|
return schema;
|
|
}
|
|
return { variadic: schema };
|
|
},
|
|
reduce: (raw, $) => {
|
|
let minVariadicLength = raw.minVariadicLength ?? 0;
|
|
const prefix = raw.prefix?.slice() ?? [];
|
|
const defaultables = raw.defaultables?.slice() ?? [];
|
|
const optionals = raw.optionals?.slice() ?? [];
|
|
const postfix = raw.postfix?.slice() ?? [];
|
|
if (raw.variadic) {
|
|
// optional elements equivalent to the variadic parameter are redundant
|
|
while (optionals[optionals.length - 1]?.equals(raw.variadic))
|
|
optionals.pop();
|
|
if (optionals.length === 0 && defaultables.length === 0) {
|
|
// If there are no optional, normalize prefix
|
|
// elements adjacent and equivalent to variadic:
|
|
// { variadic: number, prefix: [string, number] }
|
|
// reduces to:
|
|
// { variadic: number, prefix: [string], minVariadicLength: 1 }
|
|
while (prefix[prefix.length - 1]?.equals(raw.variadic)) {
|
|
prefix.pop();
|
|
minVariadicLength++;
|
|
}
|
|
}
|
|
// Normalize postfix elements adjacent and equivalent to variadic:
|
|
// { variadic: number, postfix: [number, number, 5] }
|
|
// reduces to:
|
|
// { variadic: number, postfix: [5], minVariadicLength: 2 }
|
|
while (postfix[0]?.equals(raw.variadic)) {
|
|
postfix.shift();
|
|
minVariadicLength++;
|
|
}
|
|
}
|
|
else if (optionals.length === 0 && defaultables.length === 0) {
|
|
// if there's no variadic, optional or defaultable elements,
|
|
// postfix can just be appended to prefix
|
|
prefix.push(...postfix.splice(0));
|
|
}
|
|
if (
|
|
// if any variadic adjacent elements were moved to minVariadicLength
|
|
minVariadicLength !== raw.minVariadicLength ||
|
|
// or any postfix elements were moved to prefix
|
|
(raw.prefix && raw.prefix.length !== prefix.length)) {
|
|
// reparse the reduced def
|
|
return $.node("sequence", {
|
|
...raw,
|
|
// empty lists will be omitted during parsing
|
|
prefix,
|
|
defaultables,
|
|
optionals,
|
|
postfix,
|
|
minVariadicLength
|
|
}, { prereduced: true });
|
|
}
|
|
},
|
|
defaults: {
|
|
description: node => {
|
|
if (node.isVariadicOnly)
|
|
return `${node.variadic.nestableExpression}[]`;
|
|
const innerDescription = node.tuple
|
|
.map(element => element.kind === "defaultables" ?
|
|
`${element.node.nestableExpression} = ${printable(element.default)}`
|
|
: element.kind === "optionals" ?
|
|
`${element.node.nestableExpression}?`
|
|
: element.kind === "variadic" ?
|
|
`...${element.node.nestableExpression}[]`
|
|
: element.node.expression)
|
|
.join(", ");
|
|
return `[${innerDescription}]`;
|
|
}
|
|
},
|
|
intersections: {
|
|
sequence: (l, r, ctx) => {
|
|
const rootState = _intersectSequences({
|
|
l: l.tuple,
|
|
r: r.tuple,
|
|
disjoint: new Disjoint(),
|
|
result: [],
|
|
fixedVariants: [],
|
|
ctx
|
|
});
|
|
const viableBranches = rootState.disjoint.length === 0 ?
|
|
[rootState, ...rootState.fixedVariants]
|
|
: rootState.fixedVariants;
|
|
return (viableBranches.length === 0 ? rootState.disjoint
|
|
: viableBranches.length === 1 ?
|
|
ctx.$.node("sequence", sequenceTupleToInner(viableBranches[0].result))
|
|
: ctx.$.node("union", viableBranches.map(state => ({
|
|
proto: Array,
|
|
sequence: sequenceTupleToInner(state.result)
|
|
}))));
|
|
}
|
|
// exactLength, minLength, and maxLength don't need to be defined
|
|
// here since impliedSiblings guarantees they will be added
|
|
// directly to the IntersectionNode parent of the SequenceNode
|
|
// they exist on
|
|
}
|
|
});
|
|
export class SequenceNode extends BaseConstraint {
|
|
impliedBasis = $ark.intrinsic.Array.internal;
|
|
tuple = sequenceInnerToTuple(this.inner);
|
|
prefixLength = this.prefix?.length ?? 0;
|
|
defaultablesLength = this.defaultables?.length ?? 0;
|
|
optionalsLength = this.optionals?.length ?? 0;
|
|
postfixLength = this.postfix?.length ?? 0;
|
|
defaultablesAndOptionals = [];
|
|
prevariadic = this.tuple.filter((el) => {
|
|
if (el.kind === "defaultables" || el.kind === "optionals") {
|
|
// populate defaultablesAndOptionals while filtering prevariadic
|
|
this.defaultablesAndOptionals.push(el.node);
|
|
return true;
|
|
}
|
|
return el.kind === "prefix";
|
|
});
|
|
variadicOrPostfix = conflatenate(this.variadic && [this.variadic], this.postfix);
|
|
// have to wait until prevariadic and variadicOrPostfix are set to calculate
|
|
flatRefs = this.addFlatRefs();
|
|
addFlatRefs() {
|
|
appendUniqueFlatRefs(this.flatRefs, this.prevariadic.flatMap((element, i) => append(element.node.flatRefs.map(ref => flatRef([`${i}`, ...ref.path], ref.node)), flatRef([`${i}`], element.node))));
|
|
appendUniqueFlatRefs(this.flatRefs, this.variadicOrPostfix.flatMap(element =>
|
|
// a postfix index can't be directly represented as a type
|
|
// key, so we just use the same matcher for variadic
|
|
append(element.flatRefs.map(ref => flatRef([$ark.intrinsic.nonNegativeIntegerString.internal, ...ref.path], ref.node)), flatRef([$ark.intrinsic.nonNegativeIntegerString.internal], element))));
|
|
return this.flatRefs;
|
|
}
|
|
isVariadicOnly = this.prevariadic.length + this.postfixLength === 0;
|
|
minVariadicLength = this.inner.minVariadicLength ?? 0;
|
|
minLength = this.prefixLength + this.minVariadicLength + this.postfixLength;
|
|
minLengthNode = this.minLength === 0 ?
|
|
null
|
|
// cast is safe here as the only time this would not be a
|
|
// MinLengthNode would be when minLength is 0
|
|
: this.$.node("minLength", this.minLength);
|
|
maxLength = this.variadic ? null : this.tuple.length;
|
|
maxLengthNode = this.maxLength === null ? null : this.$.node("maxLength", this.maxLength);
|
|
impliedSiblings = this.minLengthNode ?
|
|
this.maxLengthNode ?
|
|
[this.minLengthNode, this.maxLengthNode]
|
|
: [this.minLengthNode]
|
|
: this.maxLengthNode ? [this.maxLengthNode]
|
|
: [];
|
|
defaultValueMorphs = getDefaultableMorphs(this);
|
|
defaultValueMorphsReference = this.defaultValueMorphs.length ?
|
|
registeredReference(this.defaultValueMorphs)
|
|
: undefined;
|
|
elementAtIndex(data, index) {
|
|
if (index < this.prevariadic.length)
|
|
return this.tuple[index];
|
|
const firstPostfixIndex = data.length - this.postfixLength;
|
|
if (index >= firstPostfixIndex)
|
|
return { kind: "postfix", node: this.postfix[index - firstPostfixIndex] };
|
|
return {
|
|
kind: "variadic",
|
|
node: this.variadic ??
|
|
throwInternalError(`Unexpected attempt to access index ${index} on ${this}`)
|
|
};
|
|
}
|
|
// minLength/maxLength should be checked by Intersection before either traversal
|
|
traverseAllows = (data, ctx) => {
|
|
for (let i = 0; i < data.length; i++) {
|
|
if (!this.elementAtIndex(data, i).node.traverseAllows(data[i], ctx))
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
traverseApply = (data, ctx) => {
|
|
let i = 0;
|
|
for (; i < data.length; i++) {
|
|
traverseKey(i, () => this.elementAtIndex(data, i).node.traverseApply(data[i], ctx), ctx);
|
|
}
|
|
};
|
|
get element() {
|
|
return this.cacheGetter("element", this.$.node("union", this.children));
|
|
}
|
|
// minLength/maxLength compilation should be handled by Intersection
|
|
compile(js) {
|
|
if (this.prefix) {
|
|
for (const [i, node] of this.prefix.entries())
|
|
js.traverseKey(`${i}`, `data[${i}]`, node);
|
|
}
|
|
for (const [i, node] of this.defaultablesAndOptionals.entries()) {
|
|
const dataIndex = `${i + this.prefixLength}`;
|
|
js.if(`${dataIndex} >= data.length`, () => js.traversalKind === "Allows" ? js.return(true) : js.return());
|
|
js.traverseKey(dataIndex, `data[${dataIndex}]`, node);
|
|
}
|
|
if (this.variadic) {
|
|
if (this.postfix) {
|
|
js.const("firstPostfixIndex", `data.length${this.postfix ? `- ${this.postfix.length}` : ""}`);
|
|
}
|
|
js.for(`i < ${this.postfix ? "firstPostfixIndex" : "data.length"}`, () => js.traverseKey("i", "data[i]", this.variadic), this.prevariadic.length);
|
|
if (this.postfix) {
|
|
for (const [i, node] of this.postfix.entries()) {
|
|
const keyExpression = `firstPostfixIndex + ${i}`;
|
|
js.traverseKey(keyExpression, `data[${keyExpression}]`, node);
|
|
}
|
|
}
|
|
}
|
|
if (js.traversalKind === "Allows")
|
|
js.return(true);
|
|
}
|
|
_transform(mapper, ctx) {
|
|
ctx.path.push($ark.intrinsic.nonNegativeIntegerString.internal);
|
|
const result = super._transform(mapper, ctx);
|
|
ctx.path.pop();
|
|
return result;
|
|
}
|
|
// this depends on tuple so needs to come after it
|
|
expression = this.description;
|
|
reduceJsonSchema(schema, ctx) {
|
|
const isDraft07 = ctx.target === "draft-07";
|
|
if (this.prevariadic.length) {
|
|
const prefixSchemas = this.prevariadic.map(el => {
|
|
const valueSchema = el.node.toJsonSchemaRecurse(ctx);
|
|
if (el.kind === "defaultables") {
|
|
const value = typeof el.default === "function" ? el.default() : el.default;
|
|
valueSchema.default =
|
|
$ark.intrinsic.jsonData.allows(value) ?
|
|
value
|
|
: ctx.fallback.defaultValue({
|
|
code: "defaultValue",
|
|
base: valueSchema,
|
|
value
|
|
});
|
|
}
|
|
return valueSchema;
|
|
});
|
|
// draft-07 uses items as array, draft-2020-12 uses prefixItems
|
|
if (isDraft07)
|
|
schema.items = prefixSchemas;
|
|
else
|
|
schema.prefixItems = prefixSchemas;
|
|
}
|
|
// by default JSON schema prefixElements are optional
|
|
// add minLength here if there are any required prefix elements
|
|
if (this.minLength)
|
|
schema.minItems = this.minLength;
|
|
if (this.variadic) {
|
|
const variadicItemSchema = this.variadic.toJsonSchemaRecurse(ctx);
|
|
// draft-07 uses additionalItems when items is an array (tuple),
|
|
// draft-2020-12 uses items
|
|
if (isDraft07 && this.prevariadic.length)
|
|
schema.additionalItems = variadicItemSchema;
|
|
else
|
|
schema.items = variadicItemSchema;
|
|
// maxLength constraint will be enforced by items: false
|
|
// for non-variadic arrays
|
|
if (this.maxLength)
|
|
schema.maxItems = this.maxLength;
|
|
// postfix can only be present if variadic is present so nesting this is fine
|
|
if (this.postfix) {
|
|
const elements = this.postfix.map(el => el.toJsonSchemaRecurse(ctx));
|
|
schema = ctx.fallback.arrayPostfix({
|
|
code: "arrayPostfix",
|
|
base: schema,
|
|
elements
|
|
});
|
|
}
|
|
}
|
|
else {
|
|
// For fixed-length tuples without variadic elements
|
|
// draft-07 uses additionalItems: false, draft-2020-12 uses items: false
|
|
if (isDraft07)
|
|
schema.additionalItems = false;
|
|
else
|
|
schema.items = false;
|
|
// delete maxItems constraint that will have been added by the
|
|
// base intersection node to enforce fixed length
|
|
delete schema.maxItems;
|
|
}
|
|
return schema;
|
|
}
|
|
}
|
|
const defaultableMorphsCache = {};
|
|
const getDefaultableMorphs = (node) => {
|
|
if (!node.defaultables)
|
|
return [];
|
|
const morphs = [];
|
|
let cacheKey = "[";
|
|
const lastDefaultableIndex = node.prefixLength + node.defaultablesLength - 1;
|
|
for (let i = node.prefixLength; i <= lastDefaultableIndex; i++) {
|
|
const [elementNode, defaultValue] = node.defaultables[i - node.prefixLength];
|
|
morphs.push(computeDefaultValueMorph(i, elementNode, defaultValue));
|
|
cacheKey += `${i}: ${elementNode.id} = ${defaultValueSerializer(defaultValue)}, `;
|
|
}
|
|
cacheKey += "]";
|
|
return (defaultableMorphsCache[cacheKey] ??= morphs);
|
|
};
|
|
export const Sequence = {
|
|
implementation,
|
|
Node: SequenceNode
|
|
};
|
|
const sequenceInnerToTuple = (inner) => {
|
|
const tuple = [];
|
|
if (inner.prefix)
|
|
for (const node of inner.prefix)
|
|
tuple.push({ kind: "prefix", node });
|
|
if (inner.defaultables) {
|
|
for (const [node, defaultValue] of inner.defaultables)
|
|
tuple.push({ kind: "defaultables", node, default: defaultValue });
|
|
}
|
|
if (inner.optionals)
|
|
for (const node of inner.optionals)
|
|
tuple.push({ kind: "optionals", node });
|
|
if (inner.variadic)
|
|
tuple.push({ kind: "variadic", node: inner.variadic });
|
|
if (inner.postfix)
|
|
for (const node of inner.postfix)
|
|
tuple.push({ kind: "postfix", node });
|
|
return tuple;
|
|
};
|
|
const sequenceTupleToInner = (tuple) => tuple.reduce((result, element) => {
|
|
if (element.kind === "variadic")
|
|
result.variadic = element.node;
|
|
else if (element.kind === "defaultables") {
|
|
result.defaultables = append(result.defaultables, [
|
|
[element.node, element.default]
|
|
]);
|
|
}
|
|
else
|
|
result[element.kind] = append(result[element.kind], element.node);
|
|
return result;
|
|
}, {});
|
|
export const postfixAfterOptionalOrDefaultableMessage = "A postfix required element cannot follow an optional or defaultable element";
|
|
export const postfixWithoutVariadicMessage = "A postfix element requires a variadic element";
|
|
const _intersectSequences = (s) => {
|
|
const [lHead, ...lTail] = s.l;
|
|
const [rHead, ...rTail] = s.r;
|
|
if (!lHead || !rHead)
|
|
return s;
|
|
const lHasPostfix = lTail[lTail.length - 1]?.kind === "postfix";
|
|
const rHasPostfix = rTail[rTail.length - 1]?.kind === "postfix";
|
|
const kind = lHead.kind === "prefix" || rHead.kind === "prefix" ? "prefix"
|
|
: lHead.kind === "postfix" || rHead.kind === "postfix" ? "postfix"
|
|
: lHead.kind === "variadic" && rHead.kind === "variadic" ? "variadic"
|
|
// if either operand has postfix elements, the full-length
|
|
// intersection can't include optional elements (though they may
|
|
// exist in some of the fixed length variants)
|
|
: lHasPostfix || rHasPostfix ? "prefix"
|
|
: lHead.kind === "defaultables" || rHead.kind === "defaultables" ?
|
|
"defaultables"
|
|
: "optionals";
|
|
if (lHead.kind === "prefix" && rHead.kind === "variadic" && rHasPostfix) {
|
|
const postfixBranchResult = _intersectSequences({
|
|
...s,
|
|
fixedVariants: [],
|
|
r: rTail.map(element => ({ ...element, kind: "prefix" }))
|
|
});
|
|
if (postfixBranchResult.disjoint.length === 0)
|
|
s.fixedVariants.push(postfixBranchResult);
|
|
}
|
|
else if (rHead.kind === "prefix" &&
|
|
lHead.kind === "variadic" &&
|
|
lHasPostfix) {
|
|
const postfixBranchResult = _intersectSequences({
|
|
...s,
|
|
fixedVariants: [],
|
|
l: lTail.map(element => ({ ...element, kind: "prefix" }))
|
|
});
|
|
if (postfixBranchResult.disjoint.length === 0)
|
|
s.fixedVariants.push(postfixBranchResult);
|
|
}
|
|
const result = intersectOrPipeNodes(lHead.node, rHead.node, s.ctx);
|
|
if (result instanceof Disjoint) {
|
|
if (kind === "prefix" || kind === "postfix") {
|
|
s.disjoint.push(...result.withPrefixKey(
|
|
// ideally we could handle disjoint paths more precisely here,
|
|
// but not trivial to serialize postfix elements as keys
|
|
kind === "prefix" ? s.result.length : `-${lTail.length + 1}`,
|
|
// both operands must be required for the disjoint to be considered required
|
|
elementIsRequired(lHead) && elementIsRequired(rHead) ?
|
|
"required"
|
|
: "optional"));
|
|
s.result = [...s.result, { kind, node: $ark.intrinsic.never.internal }];
|
|
}
|
|
else if (kind === "optionals" || kind === "defaultables") {
|
|
// if the element result is optional and unsatisfiable, the
|
|
// intersection can still be satisfied as long as the tuple
|
|
// ends before the disjoint element would occur
|
|
return s;
|
|
}
|
|
else {
|
|
// if the element is variadic and unsatisfiable, the intersection
|
|
// can be satisfied with a fixed length variant including zero
|
|
// variadic elements
|
|
return _intersectSequences({
|
|
...s,
|
|
fixedVariants: [],
|
|
// if there were any optional elements, there will be no postfix elements
|
|
// so this mapping will never occur (which would be illegal otherwise)
|
|
l: lTail.map(element => ({ ...element, kind: "prefix" })),
|
|
r: lTail.map(element => ({ ...element, kind: "prefix" }))
|
|
});
|
|
}
|
|
}
|
|
else if (kind === "defaultables") {
|
|
if (lHead.kind === "defaultables" &&
|
|
rHead.kind === "defaultables" &&
|
|
lHead.default !== rHead.default) {
|
|
throwParseError(writeDefaultIntersectionMessage(lHead.default, rHead.default));
|
|
}
|
|
s.result = [
|
|
...s.result,
|
|
{
|
|
kind,
|
|
node: result,
|
|
default: lHead.kind === "defaultables" ? lHead.default
|
|
: rHead.kind === "defaultables" ? rHead.default
|
|
: throwInternalError(`Unexpected defaultable intersection from ${lHead.kind} and ${rHead.kind} elements.`)
|
|
}
|
|
];
|
|
}
|
|
else
|
|
s.result = [...s.result, { kind, node: result }];
|
|
const lRemaining = s.l.length;
|
|
const rRemaining = s.r.length;
|
|
if (lHead.kind !== "variadic" ||
|
|
(lRemaining >= rRemaining &&
|
|
(rHead.kind === "variadic" || rRemaining === 1)))
|
|
s.l = lTail;
|
|
if (rHead.kind !== "variadic" ||
|
|
(rRemaining >= lRemaining &&
|
|
(lHead.kind === "variadic" || lRemaining === 1)))
|
|
s.r = rTail;
|
|
return _intersectSequences(s);
|
|
};
|
|
const elementIsRequired = (el) => el.kind === "prefix" || el.kind === "postfix";
|