mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[PM-11418] generator policy constraints (#11014)
* add constraint support to UserStateSubject * add dynamic constraints * implement password policy constraints * replace policy evaluator with constraints in credential generation service * add cascade between minNumber and minSpecial Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
This commit is contained in:
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Constraints, StateConstraints } from "../types";
|
||||
|
||||
// The constraints type shares the properties of the state,
|
||||
// but never has any members
|
||||
const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
|
||||
get() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
/** A constraint that does nothing. */
|
||||
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
|
||||
/** Instantiate the identity constraint */
|
||||
constructor() {}
|
||||
|
||||
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
||||
|
||||
adjust(state: State) {
|
||||
return state;
|
||||
}
|
||||
fix(state: State) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StateConstraints } from "../types";
|
||||
|
||||
import { isDynamic } from "./state-constraints-dependency";
|
||||
|
||||
type TestType = { foo: string };
|
||||
|
||||
describe("isDynamic", () => {
|
||||
it("returns `true` when the constraint fits the `DynamicStateConstraints` type.", () => {
|
||||
const constraint: any = {
|
||||
calibrate(state: TestType): StateConstraints<TestType> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const result = isDynamic(constraint);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns `false` when the constraint fails to fit the `DynamicStateConstraints` type.", () => {
|
||||
const constraint: any = {};
|
||||
|
||||
const result = isDynamic(constraint);
|
||||
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DynamicStateConstraints, StateConstraints } from "../types";
|
||||
|
||||
/** A pattern for types that depend upon a dynamic set of constraints.
|
||||
*
|
||||
* Consumers of this dependency should track the last-received state and
|
||||
* apply it when application state is received or emitted. If `constraints$`
|
||||
* emits an unrecoverable error, the consumer should continue using the
|
||||
* last-emitted constraints. If `constraints$` completes, the consumer should
|
||||
* continue using the last-emitted constraints.
|
||||
*/
|
||||
export type StateConstraintsDependency<State> = {
|
||||
/** A stream that emits constraints when subscribed and when the
|
||||
* constraints change. The stream should not emit `null` or
|
||||
* `undefined`.
|
||||
*/
|
||||
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
|
||||
};
|
||||
|
||||
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
||||
* Otherwise, returns false.
|
||||
* @param constraints the constraint to evaluate.
|
||||
* */
|
||||
export function isDynamic<State>(
|
||||
constraints: StateConstraints<State> | DynamicStateConstraints<State>,
|
||||
): constraints is DynamicStateConstraints<State> {
|
||||
return constraints && "calibrate" in constraints;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
|
||||
import { StateConstraintsDependency } from "./state-constraints-dependency";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> &
|
||||
Partial<StateConstraintsDependency<State>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns the value to store in state
|
||||
*/
|
||||
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
|
||||
/**
|
||||
* Compute whether the state should update. If this is not set, values
|
||||
* provided to `next` always update the state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns `true` if the value should be stored, otherwise `false`.
|
||||
*/
|
||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||
}
|
||||
>;
|
||||
@@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { awaitAsync, FakeSingleUserState } from "../../../spec";
|
||||
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
|
||||
import { StateConstraints } from "../types";
|
||||
|
||||
import { UserStateSubject } from "./user-state-subject";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
type TestType = { foo: string };
|
||||
|
||||
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||
return Object.freeze({
|
||||
constraints: { foo: { maxLength } },
|
||||
adjust: function (state: TestType): TestType {
|
||||
return {
|
||||
foo: state.foo.slice(0, this.constraints.foo.maxLength),
|
||||
};
|
||||
},
|
||||
fix: function (state: TestType): TestType {
|
||||
return {
|
||||
foo: `finalized|${state.foo.slice(0, this.constraints.foo.maxLength)}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DynamicFooMaxLength = Object.freeze({
|
||||
expected: fooMaxLength(0),
|
||||
calibrate(state: TestType) {
|
||||
return this.expected;
|
||||
},
|
||||
});
|
||||
|
||||
describe("UserStateSubject", () => {
|
||||
describe("dependencies", () => {
|
||||
it("ignores repeated when$ emissions", async () => {
|
||||
@@ -54,6 +78,19 @@ describe("UserStateSubject", () => {
|
||||
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
const [initResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(initResult).toEqual({ foo: "ini" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("next", () => {
|
||||
@@ -246,6 +283,116 @@ describe("UserStateSubject", () => {
|
||||
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies constraints$ on init", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(result).toEqual({ foo: "in" });
|
||||
});
|
||||
|
||||
it("applies dynamic constraints", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual).toEqual({ foo: "" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(1));
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "i" });
|
||||
});
|
||||
|
||||
it("applies constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
const [, result] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(result).toEqual({ foo: "ne" });
|
||||
});
|
||||
|
||||
it("applies latest constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.next(fooMaxLength(3));
|
||||
subject.next({ foo: "next" });
|
||||
const [, , result] = await tracker.pauseUntilReceived(3);
|
||||
|
||||
expect(result).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
constraints$.next(fooMaxLength(3));
|
||||
// `init` is also waiting and is processed before `next`
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("uses the last-emitted value from constraints$ when constraints$ errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
|
||||
it("uses the last-emitted value from constraints$ when constraints$ completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject);
|
||||
|
||||
constraints$.complete();
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult).toEqual({ foo: "nex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
@@ -474,4 +621,150 @@ describe("UserStateSubject", () => {
|
||||
expect(subject.userId).toEqual(SomeUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withConstraints$", () => {
|
||||
it("emits the next value with an empty constraint", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual.state).toEqual(expected);
|
||||
expect(actual.constraints).toEqual({});
|
||||
});
|
||||
|
||||
it("ceases emissions once the subject completes", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
subject.complete();
|
||||
subject.next({ foo: "ignored" });
|
||||
const [result] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(result.state).toEqual(initialState);
|
||||
expect(tracker.emissions.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("emits constraints$ on constraints$ emission", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(1);
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
constraints$.next(expected);
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "i" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits dynamic constraints", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected: TestType = { foo: "next" };
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next(expected);
|
||||
const actual = await emission;
|
||||
|
||||
expect(actual.state).toEqual({ foo: "" });
|
||||
expect(actual.constraints).toEqual(DynamicFooMaxLength.expected.constraints);
|
||||
});
|
||||
|
||||
it("emits constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(2);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const emission = tracker.expectEmission();
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "ne" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the latest constraints$ on next", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
constraints$.next(expected);
|
||||
|
||||
const emission = tracker.expectEmission();
|
||||
subject.next({ foo: "next" });
|
||||
const result = await emission;
|
||||
|
||||
expect(result.state).toEqual({ foo: "nex" });
|
||||
expect(result.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("waits for constraints$", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
const expected = fooMaxLength(3);
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
constraints$.next(expected);
|
||||
// `init` is also waiting and is processed before `next`
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the last-emitted value from constraints$ when constraints$ errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.error({ some: "error" });
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
|
||||
it("emits the last-emitted value from constraints$ when constraints$ completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const expected = fooMaxLength(3);
|
||||
const constraints$ = new BehaviorSubject(expected);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||
|
||||
constraints$.complete();
|
||||
subject.next({ foo: "next" });
|
||||
const [, nextResult] = await tracker.pauseUntilReceived(1);
|
||||
|
||||
expect(nextResult.state).toEqual({ foo: "nex" });
|
||||
expect(nextResult.constraints).toEqual(expected.constraints);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,37 +17,20 @@ import {
|
||||
startWith,
|
||||
Observable,
|
||||
Subscription,
|
||||
last,
|
||||
concat,
|
||||
combineLatestWith,
|
||||
catchError,
|
||||
EMPTY,
|
||||
} from "rxjs";
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
import { WithConstraints } from "../types";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns the value to store in state
|
||||
*/
|
||||
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
|
||||
/**
|
||||
* Compute whether the state should update. If this is not set, values
|
||||
* provided to `next` always update the state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns `true` if the value should be stored, otherwise `false`.
|
||||
*/
|
||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||
}
|
||||
>;
|
||||
import { IdentityConstraint } from "./identity-state-constraint";
|
||||
import { isDynamic } from "./state-constraints-dependency";
|
||||
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
||||
|
||||
/**
|
||||
* Adapt a state provider to an rxjs subject.
|
||||
@@ -61,7 +44,7 @@ export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
* @template State the state stored by the subject
|
||||
* @template Dependencies use-specific dependencies provided by the user.
|
||||
*/
|
||||
export class UserStateSubject<State, Dependencies = null>
|
||||
export class UserStateSubject<State extends object, Dependencies = null>
|
||||
extends Observable<State>
|
||||
implements SubjectLike<State>
|
||||
{
|
||||
@@ -99,6 +82,35 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const constraints$ = (
|
||||
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
|
||||
).pipe(
|
||||
// FIXME: this should probably log that an error occurred
|
||||
catchError(() => EMPTY),
|
||||
);
|
||||
|
||||
// normalize input in case this `UserStateSubject` is not the only
|
||||
// observer of the backing store
|
||||
const input$ = combineLatest([this.input, constraints$]).pipe(
|
||||
map(([input, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
|
||||
const state = calibration.adjust(input);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
|
||||
// when the output subscription completes, its last-emitted value
|
||||
// loops around to the input for finalization
|
||||
const finalize$ = this.pipe(
|
||||
last(),
|
||||
combineLatestWith(constraints$),
|
||||
map(([output, constraints]) => {
|
||||
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
|
||||
const state = calibration.fix(output);
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
const updates$ = concat(input$, finalize$);
|
||||
|
||||
// observe completion
|
||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
||||
@@ -106,9 +118,24 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
||||
|
||||
// wire subscriptions
|
||||
this.outputSubscription = this.state.state$.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
|
||||
// wire output before input so that output normalizes the current state
|
||||
// before any `next` value is processed
|
||||
this.outputSubscription = this.state.state$
|
||||
.pipe(
|
||||
combineLatestWith(constraints$),
|
||||
map(([rawState, constraints]) => {
|
||||
const calibration = isDynamic(constraints)
|
||||
? constraints.calibrate(rawState)
|
||||
: constraints;
|
||||
const state = calibration.adjust(rawState);
|
||||
return {
|
||||
constraints: calibration.constraints,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
|
||||
.pipe(
|
||||
filter(([_, when]) => when),
|
||||
map(([state]) => state),
|
||||
@@ -144,14 +171,19 @@ export class UserStateSubject<State, Dependencies = null>
|
||||
* @returns the subscription
|
||||
*/
|
||||
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
|
||||
return this.output.subscribe(observer);
|
||||
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
|
||||
}
|
||||
|
||||
// using subjects to ensure the right semantics are followed;
|
||||
// if greater efficiency becomes desirable, consider implementing
|
||||
// `SubjectLike` directly
|
||||
private input = new Subject<State>();
|
||||
private readonly output = new ReplaySubject<State>(1);
|
||||
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
||||
|
||||
/** A stream containing settings and their last-applied constraints. */
|
||||
get withConstraints$() {
|
||||
return this.output.asObservable();
|
||||
}
|
||||
|
||||
private inputSubscription: Unsubscribable;
|
||||
private outputSubscription: Unsubscribable;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Simplify } from "type-fest";
|
||||
|
||||
/** Constraints that are shared by all primitive field types */
|
||||
type PrimitiveConstraint = {
|
||||
/** presence indicates the field is required */
|
||||
required?: true;
|
||||
/** `true` indicates the field is required; otherwise the field is optional */
|
||||
required?: boolean;
|
||||
|
||||
/** `true` indicates the field is immutable; otherwise the field is mutable */
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
/** Constraints that are shared by string fields */
|
||||
@@ -23,29 +26,108 @@ type NumberConstraints = {
|
||||
/** maximum number value. When absent, min value is unbounded. */
|
||||
max?: number;
|
||||
|
||||
/** presence indicates the field only accepts integer values */
|
||||
integer?: true;
|
||||
|
||||
/** requires the number be a multiple of the step value */
|
||||
/** requires the number be a multiple of the step value;
|
||||
* this field must be a positive number. +0 and Infinity are
|
||||
* prohibited. When absent, any number is accepted.
|
||||
* @remarks set this to `1` to require integer values.
|
||||
*/
|
||||
step?: number;
|
||||
};
|
||||
|
||||
/** Constraints that are shared by boolean fields */
|
||||
type BooleanConstraint = {
|
||||
/** When present, the boolean field must have the set value.
|
||||
* When absent or undefined, the boolean field's value is unconstrained.
|
||||
*/
|
||||
requiredValue?: boolean;
|
||||
};
|
||||
|
||||
/** Utility type that transforms a type T into its supported validators.
|
||||
*/
|
||||
export type Constraint<T> = PrimitiveConstraint &
|
||||
(T extends string
|
||||
? StringConstraints
|
||||
: T extends number
|
||||
? NumberConstraints
|
||||
: T extends boolean
|
||||
? BooleanConstraint
|
||||
: never);
|
||||
|
||||
/** Utility type that transforms keys of T into their supported
|
||||
* validators.
|
||||
*/
|
||||
export type Constraints<T> = {
|
||||
[Key in keyof T]: Simplify<
|
||||
PrimitiveConstraint &
|
||||
(T[Key] extends string
|
||||
? StringConstraints
|
||||
: T[Key] extends number
|
||||
? NumberConstraints
|
||||
: never)
|
||||
>;
|
||||
[Key in keyof T]?: Simplify<Constraint<T[Key]>>;
|
||||
};
|
||||
|
||||
/** Utility type that tracks whether a set of constraints was
|
||||
* produced by an active policy.
|
||||
*/
|
||||
export type PolicyConstraints<T> = {
|
||||
/** When true, the constraints were derived from an active policy. */
|
||||
policyInEffect?: boolean;
|
||||
} & Constraints<T>;
|
||||
|
||||
/** utility type for methods that evaluate constraints generically. */
|
||||
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
|
||||
export type AnyConstraint = PrimitiveConstraint &
|
||||
StringConstraints &
|
||||
NumberConstraints &
|
||||
BooleanConstraint;
|
||||
|
||||
/** Extends state message with constraints that apply to the message. */
|
||||
export type WithConstraints<State> = {
|
||||
/** the state */
|
||||
readonly state: State;
|
||||
|
||||
/** the constraints enforced upon the type. */
|
||||
readonly constraints: Constraints<State>;
|
||||
};
|
||||
|
||||
/** Creates constraints that are applied automatically to application
|
||||
* state.
|
||||
* This type is mutually exclusive with `StateConstraints`.
|
||||
*/
|
||||
export type DynamicStateConstraints<State> = {
|
||||
/** Creates constraints with data derived from the input state
|
||||
* @param state the state from which the constraints are initialized.
|
||||
* @remarks this is useful for calculating constraints that
|
||||
* depend upon values from the input state. You should not send these
|
||||
* constraints to the UI, because that would prevent the UI from
|
||||
* offering less restrictive constraints.
|
||||
*/
|
||||
calibrate: (state: State) => StateConstraints<State>;
|
||||
};
|
||||
|
||||
/** Constraints that are applied automatically to application state.
|
||||
* This type is mutually exclusive with `DynamicStateConstraints`.
|
||||
* @remarks this type automatically corrects incoming our outgoing
|
||||
* data. If you would like to prevent invalid data from being
|
||||
* applied, use an rxjs filter and evaluate `Constraints<State>`
|
||||
* instead.
|
||||
*/
|
||||
export type StateConstraints<State> = {
|
||||
/** Well-known constraints of `State` */
|
||||
readonly constraints: Readonly<Constraints<State>>;
|
||||
|
||||
/** Enforces constraints that always hold for the emitted state.
|
||||
* @remarks This is useful for enforcing "override" constraints,
|
||||
* such as when a policy requires a value fall within a specific
|
||||
* range.
|
||||
* @param state the state pending emission from the subject.
|
||||
* @return the value emitted by the subject
|
||||
*/
|
||||
adjust: (state: State) => State;
|
||||
|
||||
/** Enforces constraints that holds when the subject completes.
|
||||
* @remarks This is useful for enforcing "default" constraints,
|
||||
* such as when a policy requires some state is true when data is
|
||||
* first subscribed, but the state may vary thereafter.
|
||||
* @param state the state of the subject immediately before
|
||||
* completion.
|
||||
* @return the value stored to state upon completion.
|
||||
*/
|
||||
fix: (state: State) => State;
|
||||
};
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a generator is invoked.
|
||||
|
||||
Reference in New Issue
Block a user