Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 150 additions & 31 deletions packages/client/src/clients/guide/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Knock from "../../knock";

import {
DEFAULT_GROUP_KEY,
SelectionResult,
// SelectionResult,
byKey,
checkStateIfThrottled,
findDefaultGroup,
Expand Down Expand Up @@ -46,6 +46,9 @@ import {
SelectFilterParams,
SelectGuideOpts,
SelectGuidesOpts,
SelectQueryLimit,
SelectionResult,
// SelectQueryParams,
StepMessageState,
StoreState,
TargetParams,
Expand Down Expand Up @@ -150,7 +153,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => {
}
};

const select = (state: StoreState, filters: SelectFilterParams = {}) => {
type SelectQueryMetadata = {
limit: SelectQueryLimit;
opts: SelectGuideOpts;
};

const select = (
state: StoreState,
filters: SelectFilterParams,
metadata: SelectQueryMetadata,
) => {
// A map of selected guides as values, with its order index as keys.
const result = new SelectionResult();

Expand All @@ -175,7 +187,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
result.set(index, guide);
}

result.metadata = { guideGroup: defaultGroup };
result.metadata = { guideGroup: defaultGroup, filters, ...metadata };

return result;
};

Expand Down Expand Up @@ -617,14 +630,35 @@ export class KnockGuideClient {
`[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
);

const selectedGuide = this.selectGuide(state, filters, opts);
// 1. First, call selectGuide() using the same filters to ensure we have a
// group stage open and respect throttling. This isn't the real query, but
// rather it's a shortcut ahead of handling the actual query result below.
const selectedGuide = this.selectGuide(state, filters, {
...opts,
// Don't record this result, not the actual query result we need.
recordSelectQuery: false,
});

// 2. Now make the actual select query with the provided filters and opts,
// and record the result (as needed). By default, we only record the result
// while in debugging.
const { recordSelectQuery = !!state.debug?.debugging } = opts;
const metadata: SelectQueryMetadata = {
limit: "all",
opts: { ...opts, recordSelectQuery },
};
const result = select(state, filters, metadata);
this.maybeRecordSelectResult(result);

// 3. Stop if there is not at least one guide to return.
if (!selectedGuide) {
return [];
}

// There should be at least one guide to return here now.
const guides = [...select(state, filters).values()];
const guides = [...result.values()];

// 4. If throttled, filter out any throttled guides.
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
const unthrottledGuides = guides.filter(
(g) => g.bypass_global_group_limit,
Expand Down Expand Up @@ -657,32 +691,6 @@ export class KnockGuideClient {
return undefined;
}

const result = select(state, filters);

if (result.size === 0) {
this.knock.log("[Guide] Selection found zero result");
return undefined;
}

const [index, guide] = [...result][0]!;
this.knock.log(
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
);

// If a guide ignores the group limit, then return immediately to render
// always.
if (guide.bypass_global_group_limit) {
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
return guide;
}

// Check if inside the throttle window (i.e. throttled) and if so stop and
// return undefined unless explicitly given the option to include throttled.
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
return undefined;
}

// Starting here to the end of this method represents the core logic of how
// "group stage" works. It provides a mechanism for 1) figuring out which
// guide components are about to render on a page, 2) determining which
Expand Down Expand Up @@ -716,6 +724,42 @@ export class KnockGuideClient {
this.stage = this.openGroupStage(); // Assign here to make tsc happy
}

// Must come AFTER we ensure a group stage exists above, so we can record
// select queries. By default, we only record the result while in debugging.
const { recordSelectQuery = !!state.debug?.debugging } = opts;
const metadata: SelectQueryMetadata = {
limit: "one",
opts: { ...opts, recordSelectQuery },
};
const result = select(state, filters, metadata);
this.maybeRecordSelectResult(result);

if (result.size === 0) {
this.knock.log("[Guide] Selection found zero result");
return undefined;
}

const [index, guide] = [...result][0]!;
this.knock.log(
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
);

// If a guide ignores the group limit, then return immediately to render
// always.
if (guide.bypass_global_group_limit) {
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
return guide;
}

// Check if inside the throttle window (i.e. throttled) and if so stop and
// return undefined unless explicitly given the option to include throttled.
const throttled = !opts.includeThrottled && checkStateIfThrottled(state);

// if (!opts.includeThrottled && checkStateIfThrottled(state)) {
// this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
// return undefined;
// }

switch (this.stage.status) {
case "open": {
this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
Expand All @@ -727,6 +771,11 @@ export class KnockGuideClient {
this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
this.stage.ordered[index] = guide.key;

if (throttled) {
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
return undefined;
}

const ret = this.stage.resolved === guide.key ? guide : undefined;
this.knock.log(
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
Expand All @@ -735,6 +784,11 @@ export class KnockGuideClient {
}

case "closed": {
if (throttled) {
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
return undefined;
}

const ret = this.stage.resolved === guide.key ? guide : undefined;
this.knock.log(
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
Expand All @@ -744,6 +798,70 @@ export class KnockGuideClient {
}
}

private maybeRecordSelectResult(result: SelectionResult) {
if (!result.metadata) return;

const { opts, filters, limit } = result.metadata;
if (!opts.recordSelectQuery) return;
if (!filters.key && !filters.type) return;
if (!this.stage || this.stage.status === "closed") return;

// Deep merge to accumulate the results.
const queriedByKey = this.stage.results.key || {};
if (filters.key) {
queriedByKey[filters.key] = {
...(queriedByKey[filters.key] || {}),
...{ [limit]: result },
};
}
const queriedByType = this.stage.results.type || {};
if (filters.type) {
queriedByType[filters.type] = {
...(queriedByType[filters.type] || {}),
...{ [limit]: result },
};
}

this.stage = {
...this.stage,
results: { key: queriedByKey, type: queriedByType },
};
}

// private maybeRecordSelectQuery(
// params: SelectQueryParams,
// opts: SelectGuideOpts,
// ) {
// if (!opts.recordSelectQuery) return;
// if (!this.stage || this.stage.status === "closed") return;
// if (!params.key && !params.type) return;
//
// // Deep merge into the query logs:
// const queriesByKey = this.stage.queries.key || {};
// if (params.key) {
// queriesByKey[params.key] = {
// ...(queriesByKey[params.key] || {}),
// ...{ [params.limit]: opts },
// };
// }
// const queriesByType = this.stage.queries.type || {};
// if (params.type) {
// queriesByType[params.type] = {
// ...(queriesByType[params.type] || {}),
// ...{ [params.limit]: opts },
// };
// }
//
// this.stage = {
// ...this.stage,
// queries: { key: queriesByKey, type: queriesByType },
// };
// }

getStage() {
return this.stage;
}

private openGroupStage() {
this.knock.log("[Guide] Opening a new group stage");

Expand All @@ -759,6 +877,7 @@ export class KnockGuideClient {
this.stage = {
status: "open",
ordered: [],
results: {},
timeoutId,
};

Expand Down
30 changes: 18 additions & 12 deletions packages/client/src/clients/guide/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,14 @@ import {
GuideActivationUrlRuleData,
GuideData,
GuideGroupData,
KnockGuide,
// KnockGuide,
KnockGuideActivationUrlPattern,
SelectFilterParams,
// SelectGuideOpts,
// SelectQueryLimit,
StoreState,
} from "./types";

// Extends the map class to allow having metadata on it, which is used to record
// the guide group context for the selection result (though currently only a
// default global group is supported).
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
metadata: { guideGroup: GuideGroupData } | undefined;

constructor() {
super();
}
}

export const formatGroupStage = (stage: GroupStage) => {
return `status=${stage.status}, resolved=${stage.resolved}`;
};
Expand Down Expand Up @@ -233,3 +224,18 @@ export const predicateUrlPatterns = (
}
}, predicateDefault);
};

// export type SelectorIdParams = {
// filters: SelectFilterParams;
// limit: "one" | "all";
// };
//
// export const formatSelectorId = ({ filters, limit }: SelectorIdParams) => {
//
// // change to query params format
// // key="asdf"&type
//
//
// // Keep this ordering: limit, key, and type.
// return JSON.stringify({ limit, key: filters.key, type: filters.type });
// };
3 changes: 3 additions & 0 deletions packages/client/src/clients/guide/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
DEBUG_QUERY_PARAMS,
checkActivatable,
} from "./client";
export { checkStateIfThrottled } from "./helpers";
export type {
KnockGuide,
KnockGuideStep,
Expand All @@ -12,4 +13,6 @@ export type {
SelectGuideOpts as KnockSelectGuideOpts,
SelectGuidesOpts as KnockSelectGuidesOpts,
StoreState as KnockGuideClientStoreState,
GroupStage as KnockGuideClientGroupStage,
SelectionResult as KnockGuideSelectionResult,
} from "./types";
39 changes: 39 additions & 0 deletions packages/client/src/clients/guide/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import { GenericData } from "@knocklabs/types";

type SelectionResultMetadata = {
guideGroup: GuideGroupData;
// Additional info about the underlying select query behind the result.
filters: SelectFilterParams;
limit: SelectQueryLimit;
opts: SelectGuideOpts;
};

// Extends the map class to allow having metadata on it, which is used to record
// the guide group context for the selection result (though currently only a
// default global group is supported).
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
metadata: SelectionResultMetadata | undefined;

constructor() {
super();
}
}

//
// Fetch guides API
//
Expand Down Expand Up @@ -231,6 +250,8 @@ export type SelectFilterParams = {

export type SelectGuideOpts = {
includeThrottled?: boolean;
// XXX: record result
recordSelectQuery?: boolean;
};

export type SelectGuidesOpts = SelectGuideOpts;
Expand All @@ -247,9 +268,27 @@ export type ConstructorOpts = {
throttleCheckInterval?: number;
};

// i.e. useGuide vs useGuides
export type SelectQueryLimit = "one" | "all";

// export type SelectQueryParams = SelectFilterParams & {
// limit: "one" | "all";
// };

type SelectionResultByLimit = {
one?: SelectionResult;
all?: SelectionResult;
};

type RecordedSelectionResults = {
key?: Record<KnockGuide["key"], SelectionResultByLimit>;
type?: Record<KnockGuide["type"], SelectionResultByLimit>;
};

export type GroupStage = {
status: "open" | "closed" | "patch";
ordered: Array<KnockGuide["key"]>;
resolved?: KnockGuide["key"];
timeoutId: ReturnType<typeof setTimeout> | null;
results: RecordedSelectionResults;
};
Loading
Loading