@baukit/notifications-core 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import type { FoldPolicy, GapPolicy, LocalTimeTransition } from '@baukit/localization-core';
2
+
3
+ import { NotificationPlanError } from './errors.js';
4
+ import {
5
+ resolveNotificationOccurrences,
6
+ type NotificationOccurrence,
7
+ type SkippedOccurrence,
8
+ } from './occurrences.js';
9
+ import { planNotificationReplacement } from './replacement-plan.js';
10
+ import type { PlannedNotification } from './validation.js';
11
+
12
+ export interface NotificationPlanVectorCurrent {
13
+ readonly logicalId: string;
14
+ readonly instant: string;
15
+ readonly contentDigest: string;
16
+ }
17
+
18
+ export interface NotificationPlanVectorInput {
19
+ readonly timeZone: string;
20
+ readonly gap: GapPolicy;
21
+ readonly fold: FoldPolicy;
22
+ readonly now: string;
23
+ readonly horizonDays: number;
24
+ readonly replaceAll?: boolean;
25
+ readonly occurrences: readonly NotificationOccurrence[];
26
+ readonly current: readonly NotificationPlanVectorCurrent[];
27
+ }
28
+
29
+ export interface NotificationPlanVectorResult {
30
+ readonly firstCivilDate: string;
31
+ readonly lastCivilDate: string;
32
+ readonly schedule: readonly {
33
+ readonly logicalId: string;
34
+ readonly instant: string;
35
+ readonly transition: LocalTimeTransition;
36
+ }[];
37
+ readonly keep: readonly string[];
38
+ readonly cancel: readonly string[];
39
+ readonly skipped: readonly SkippedOccurrence[];
40
+ }
41
+
42
+ export interface NotificationPlanVectorError {
43
+ readonly error: { readonly code: string; readonly logicalId?: string };
44
+ }
45
+
46
+ export interface NotificationPlanVectorCase {
47
+ readonly name: string;
48
+ readonly input: NotificationPlanVectorInput;
49
+ readonly expected: NotificationPlanVectorResult | NotificationPlanVectorError;
50
+ }
51
+
52
+ export interface NotificationPlanVectorFixture {
53
+ readonly version: number;
54
+ readonly cases: readonly NotificationPlanVectorCase[];
55
+ }
56
+
57
+ /** One vector: `actual()` must deep-equal `expected` (`undefined` fields count as absent). */
58
+ export interface NotificationPlanVectorCheck {
59
+ readonly label: string;
60
+ readonly expected: unknown;
61
+ readonly actual: () => unknown;
62
+ }
63
+
64
+ interface Convergence {
65
+ readonly schedule: readonly unknown[];
66
+ readonly cancel: readonly unknown[];
67
+ readonly notKept: number;
68
+ }
69
+
70
+ /**
71
+ * Expands `fixtures/notifications/plan-vectors-v1.json` into checks. Each success case yields
72
+ * three: the expected plan, convergence once the plan is applied, and independence from input
73
+ * order. Each error case yields one check of the thrown `NotificationPlanError` code and ID.
74
+ */
75
+ export function notificationPlanVectorChecks(
76
+ fixture: NotificationPlanVectorFixture,
77
+ ): NotificationPlanVectorCheck[] {
78
+ return fixture.cases.flatMap((entry) =>
79
+ isErrorCase(entry) ? [errorCheck(entry)] : successChecks(entry),
80
+ );
81
+ }
82
+
83
+ function isErrorCase(
84
+ entry: NotificationPlanVectorCase,
85
+ ): entry is NotificationPlanVectorCase & { expected: NotificationPlanVectorError } {
86
+ return 'error' in entry.expected;
87
+ }
88
+
89
+ function successChecks(entry: NotificationPlanVectorCase): NotificationPlanVectorCheck[] {
90
+ const { input } = entry;
91
+ return [
92
+ {
93
+ label: entry.name,
94
+ expected: entry.expected,
95
+ actual: () => summary(input, toCurrent(input.current)),
96
+ },
97
+ {
98
+ label: `${entry.name} converges after the plan is applied`,
99
+ expected: { schedule: [], cancel: [], notKept: 0 },
100
+ actual: () => convergence(input),
101
+ },
102
+ {
103
+ label: `${entry.name} does not depend on input order`,
104
+ expected: entry.expected,
105
+ actual: () =>
106
+ summary(
107
+ { ...input, occurrences: [...input.occurrences].reverse() },
108
+ toCurrent(input.current).reverse(),
109
+ ),
110
+ },
111
+ ];
112
+ }
113
+
114
+ function errorCheck(
115
+ entry: NotificationPlanVectorCase & { expected: NotificationPlanVectorError },
116
+ ): NotificationPlanVectorCheck {
117
+ const { code, logicalId } = entry.expected.error;
118
+ return {
119
+ label: entry.name,
120
+ expected: { error: { code, logicalId } },
121
+ actual: () => thrownPlanError(() => run(entry.input, toCurrent(entry.input.current))),
122
+ };
123
+ }
124
+
125
+ function toCurrent(entries: readonly NotificationPlanVectorCurrent[]): PlannedNotification[] {
126
+ return entries.map(({ logicalId, instant, contentDigest }) => ({
127
+ logicalId,
128
+ epochMilliseconds: Date.parse(instant),
129
+ contentDigest,
130
+ }));
131
+ }
132
+
133
+ function run(input: NotificationPlanVectorInput, current: readonly PlannedNotification[]) {
134
+ const resolution = resolveNotificationOccurrences({
135
+ occurrences: input.occurrences,
136
+ timeZone: input.timeZone,
137
+ gap: input.gap,
138
+ fold: input.fold,
139
+ clock: () => Date.parse(input.now),
140
+ horizonDays: input.horizonDays,
141
+ });
142
+ const plan = planNotificationReplacement(current, resolution.desired, {
143
+ replaceAll: input.replaceAll ?? false,
144
+ });
145
+ return { resolution, plan };
146
+ }
147
+
148
+ function summary(
149
+ input: NotificationPlanVectorInput,
150
+ current: readonly PlannedNotification[],
151
+ ): NotificationPlanVectorResult {
152
+ const { resolution, plan } = run(input, current);
153
+ return {
154
+ firstCivilDate: resolution.firstCivilDate,
155
+ lastCivilDate: resolution.lastCivilDate,
156
+ schedule: plan.schedule.map((entry) => ({
157
+ logicalId: entry.logicalId,
158
+ instant: new Date(entry.epochMilliseconds).toISOString().replace('.000Z', 'Z'),
159
+ transition: entry.transition,
160
+ })),
161
+ keep: plan.keep.map((entry) => entry.logicalId),
162
+ cancel: plan.cancel.map((entry) => entry.logicalId),
163
+ skipped: resolution.skipped,
164
+ };
165
+ }
166
+
167
+ function appliedPlan(input: NotificationPlanVectorInput): PlannedNotification[] {
168
+ const { plan } = run(input, toCurrent(input.current));
169
+ return [...plan.keep, ...plan.schedule].map(
170
+ ({ logicalId, epochMilliseconds, contentDigest }) => ({
171
+ logicalId,
172
+ epochMilliseconds,
173
+ contentDigest,
174
+ }),
175
+ );
176
+ }
177
+
178
+ function convergence(input: NotificationPlanVectorInput): Convergence {
179
+ const applied = appliedPlan(input);
180
+ const settled = summary({ ...input, replaceAll: false }, [...applied].reverse());
181
+ return {
182
+ schedule: settled.schedule,
183
+ cancel: settled.cancel,
184
+ notKept: applied.length - settled.keep.length,
185
+ };
186
+ }
187
+
188
+ function thrownPlanError(action: () => unknown): unknown {
189
+ try {
190
+ action();
191
+ } catch (error) {
192
+ if (error instanceof NotificationPlanError) {
193
+ return { error: { code: error.code, logicalId: error.logicalId } };
194
+ }
195
+ return { unexpected: String(error) };
196
+ }
197
+ return { unexpected: 'no error thrown' };
198
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ assertPlannedNotifications,
3
+ compareByInstant,
4
+ type PlannedNotification,
5
+ } from './validation.js';
6
+
7
+ export interface NotificationReplacementOptions {
8
+ readonly replaceAll?: boolean;
9
+ }
10
+
11
+ export interface NotificationReplacementPlan<
12
+ TCurrent extends PlannedNotification,
13
+ TDesired extends PlannedNotification,
14
+ > {
15
+ readonly keep: readonly TCurrent[];
16
+ readonly cancel: readonly TCurrent[];
17
+ readonly schedule: readonly TDesired[];
18
+ }
19
+
20
+ export function planNotificationReplacement<
21
+ TCurrent extends PlannedNotification,
22
+ TDesired extends PlannedNotification,
23
+ >(
24
+ current: readonly TCurrent[],
25
+ desired: readonly TDesired[],
26
+ options: NotificationReplacementOptions = {},
27
+ ): NotificationReplacementPlan<TCurrent, TDesired> {
28
+ assertPlannedNotifications(current);
29
+ assertPlannedNotifications(desired);
30
+
31
+ const desiredById = new Map(desired.map((entry) => [entry.logicalId, entry]));
32
+ const keep: TCurrent[] = [];
33
+ const cancel: TCurrent[] = [];
34
+ const kept = new Set<string>();
35
+
36
+ for (const entry of current) {
37
+ const wanted = desiredById.get(entry.logicalId);
38
+ if (options.replaceAll !== true && wanted !== undefined && samePlacement(entry, wanted)) {
39
+ keep.push(entry);
40
+ kept.add(entry.logicalId);
41
+ } else {
42
+ cancel.push(entry);
43
+ }
44
+ }
45
+
46
+ return {
47
+ keep: keep.sort(compareByInstant),
48
+ cancel: cancel.sort(compareByInstant),
49
+ schedule: desired.filter((entry) => !kept.has(entry.logicalId)).sort(compareByInstant),
50
+ };
51
+ }
52
+
53
+ function samePlacement(left: PlannedNotification, right: PlannedNotification): boolean {
54
+ return (
55
+ left.epochMilliseconds === right.epochMilliseconds && left.contentDigest === right.contentDigest
56
+ );
57
+ }
@@ -0,0 +1,287 @@
1
+ import { NotificationPlanError } from './errors.js';
2
+ import {
3
+ isOwnedBy,
4
+ ownedNotificationIdentifier,
5
+ type OwnedNotificationMarker,
6
+ } from './ownership.js';
7
+ import {
8
+ planNotificationReplacement,
9
+ type NotificationReplacementOptions,
10
+ } from './replacement-plan.js';
11
+ import {
12
+ assertNamespace,
13
+ assertPlannedNotifications,
14
+ type PlannedNotification,
15
+ } from './validation.js';
16
+
17
+ export type NotificationPermission = 'granted' | 'denied' | 'undetermined';
18
+
19
+ export interface PendingNotification {
20
+ readonly identifier: string;
21
+ readonly marker: OwnedNotificationMarker | null;
22
+ }
23
+
24
+ export interface OwnedNotificationScheduleRequest<TContent> {
25
+ readonly identifier: string;
26
+ readonly marker: OwnedNotificationMarker;
27
+ readonly content: TContent;
28
+ }
29
+
30
+ export interface NotificationPlatform<TContent> {
31
+ list(): Promise<readonly PendingNotification[]>;
32
+ cancel(identifier: string): Promise<void>;
33
+ permission(): Promise<NotificationPermission>;
34
+ schedule(request: OwnedNotificationScheduleRequest<TContent>): Promise<void>;
35
+ }
36
+
37
+ export interface NotificationOwner {
38
+ readonly namespace: string;
39
+ }
40
+
41
+ export interface OwnedNotification<TContent> extends PlannedNotification {
42
+ readonly content: TContent;
43
+ }
44
+
45
+ export type OwnedNotificationFailureCode =
46
+ | 'list_failed'
47
+ | 'cancel_failed'
48
+ | 'permission_denied'
49
+ | 'permission_failed'
50
+ | 'schedule_failed'
51
+ | 'schedule_limit';
52
+
53
+ export interface OwnedNotificationFailure {
54
+ readonly code: OwnedNotificationFailureCode;
55
+ readonly logicalId?: string;
56
+ }
57
+
58
+ export type OwnedNotificationReplacementStatus = 'complete' | 'incomplete' | 'superseded';
59
+
60
+ export interface OwnedNotificationReplacementOutcome {
61
+ readonly status: OwnedNotificationReplacementStatus;
62
+ readonly kept: readonly string[];
63
+ readonly cancelled: readonly string[];
64
+ readonly scheduled: readonly string[];
65
+ readonly failures: readonly OwnedNotificationFailure[];
66
+ }
67
+
68
+ export interface OwnedNotificationScheduler<TContent> {
69
+ replaceOwned(
70
+ owner: NotificationOwner,
71
+ desired: readonly OwnedNotification<TContent>[],
72
+ options?: NotificationReplacementOptions,
73
+ ): Promise<OwnedNotificationReplacementOutcome>;
74
+ }
75
+
76
+ export interface OwnedNotificationSchedulerOptions {
77
+ readonly pendingLimit?: number;
78
+ }
79
+
80
+ type Replacement = () => Promise<OwnedNotificationReplacementOutcome>;
81
+
82
+ interface QueuedReplacement {
83
+ readonly run: Replacement;
84
+ readonly resolve: (outcome: OwnedNotificationReplacementOutcome) => void;
85
+ readonly reject: (error: unknown) => void;
86
+ }
87
+
88
+ interface OwnerQueue {
89
+ waiting: QueuedReplacement | null;
90
+ }
91
+
92
+ interface ScheduleResult {
93
+ readonly scheduled: readonly string[];
94
+ readonly failures: readonly OwnedNotificationFailure[];
95
+ }
96
+
97
+ const SUPERSEDED: OwnedNotificationReplacementOutcome = Object.freeze({
98
+ status: 'superseded',
99
+ kept: [],
100
+ cancelled: [],
101
+ scheduled: [],
102
+ failures: [],
103
+ });
104
+
105
+ export function createOwnedNotificationScheduler<TContent>(
106
+ platform: NotificationPlatform<TContent>,
107
+ options: OwnedNotificationSchedulerOptions = {},
108
+ ): OwnedNotificationScheduler<TContent> {
109
+ const pendingLimit = options.pendingLimit ?? Number.POSITIVE_INFINITY;
110
+ if (pendingLimit !== Number.POSITIVE_INFINITY && !isPositiveInteger(pendingLimit)) {
111
+ throw new NotificationPlanError('invalid_pending_limit');
112
+ }
113
+ const queues = new Map<string, OwnerQueue>();
114
+
115
+ return {
116
+ async replaceOwned(owner, desired, replaceOptions = {}) {
117
+ assertNamespace(owner.namespace);
118
+ assertPlannedNotifications(desired);
119
+ const namespace = owner.namespace;
120
+ const snapshot = [...desired];
121
+ return enqueue(queues, namespace, () =>
122
+ replaceNow(platform, namespace, snapshot, replaceOptions, pendingLimit),
123
+ );
124
+ },
125
+ };
126
+ }
127
+
128
+ function enqueue(
129
+ queues: Map<string, OwnerQueue>,
130
+ namespace: string,
131
+ run: Replacement,
132
+ ): Promise<OwnedNotificationReplacementOutcome> {
133
+ return new Promise((resolve, reject) => {
134
+ const replacement = { run, resolve, reject };
135
+ const queue = queues.get(namespace);
136
+ if (queue === undefined) {
137
+ const created: OwnerQueue = { waiting: null };
138
+ queues.set(namespace, created);
139
+ start(queues, namespace, created, replacement);
140
+ return;
141
+ }
142
+ queue.waiting?.resolve(SUPERSEDED);
143
+ queue.waiting = replacement;
144
+ });
145
+ }
146
+
147
+ function start(
148
+ queues: Map<string, OwnerQueue>,
149
+ namespace: string,
150
+ queue: OwnerQueue,
151
+ replacement: QueuedReplacement,
152
+ ): void {
153
+ void replacement
154
+ .run()
155
+ .then(replacement.resolve, replacement.reject)
156
+ .finally(() => {
157
+ const next = queue.waiting;
158
+ queue.waiting = null;
159
+ if (next === null) {
160
+ queues.delete(namespace);
161
+ return;
162
+ }
163
+ start(queues, namespace, queue, next);
164
+ });
165
+ }
166
+
167
+ async function replaceNow<TContent>(
168
+ platform: NotificationPlatform<TContent>,
169
+ namespace: string,
170
+ desired: readonly OwnedNotification<TContent>[],
171
+ options: NotificationReplacementOptions,
172
+ pendingLimit: number,
173
+ ): Promise<OwnedNotificationReplacementOutcome> {
174
+ let pending: readonly PendingNotification[];
175
+ try {
176
+ pending = await platform.list();
177
+ } catch {
178
+ return outcome([], [], [], [{ code: 'list_failed' }]);
179
+ }
180
+
181
+ const current = pending.flatMap(({ identifier, marker }) =>
182
+ isOwnedBy(namespace, identifier, marker) ? [marker] : [],
183
+ );
184
+ const plan = planNotificationReplacement(current, desired, options);
185
+
186
+ const cancelFailures: OwnedNotificationFailure[] = [];
187
+ const cancelled: string[] = [];
188
+ for (const entry of plan.cancel) {
189
+ try {
190
+ await platform.cancel(ownedNotificationIdentifier(namespace, entry.logicalId));
191
+ cancelled.push(entry.logicalId);
192
+ } catch {
193
+ cancelFailures.push({ code: 'cancel_failed', logicalId: entry.logicalId });
194
+ }
195
+ }
196
+
197
+ const blocked = new Set(cancelFailures.map((failure) => failure.logicalId));
198
+ const toSchedule = plan.schedule.filter((entry) => !blocked.has(entry.logicalId));
199
+ const capacity = pendingLimit - (pending.length - cancelled.length);
200
+ const scheduling = await scheduleAll(platform, namespace, toSchedule, capacity);
201
+
202
+ return outcome(
203
+ plan.keep.map((entry) => entry.logicalId),
204
+ cancelled,
205
+ scheduling.scheduled,
206
+ [...cancelFailures, ...scheduling.failures],
207
+ );
208
+ }
209
+
210
+ async function scheduleAll<TContent>(
211
+ platform: NotificationPlatform<TContent>,
212
+ namespace: string,
213
+ entries: readonly OwnedNotification<TContent>[],
214
+ capacity: number,
215
+ ): Promise<ScheduleResult> {
216
+ if (entries.length === 0) {
217
+ return { scheduled: [], failures: [] };
218
+ }
219
+ const blockedCode = await permissionFailure(platform);
220
+ if (blockedCode !== null) {
221
+ return {
222
+ scheduled: [],
223
+ failures: entries.map((entry) => ({ code: blockedCode, logicalId: entry.logicalId })),
224
+ };
225
+ }
226
+
227
+ const scheduled: string[] = [];
228
+ const failures: OwnedNotificationFailure[] = [];
229
+ for (const entry of entries) {
230
+ if (scheduled.length >= capacity) {
231
+ failures.push({ code: 'schedule_limit', logicalId: entry.logicalId });
232
+ continue;
233
+ }
234
+ try {
235
+ await platform.schedule(scheduleRequest(namespace, entry));
236
+ scheduled.push(entry.logicalId);
237
+ } catch {
238
+ failures.push({ code: 'schedule_failed', logicalId: entry.logicalId });
239
+ }
240
+ }
241
+ return { scheduled, failures };
242
+ }
243
+
244
+ async function permissionFailure<TContent>(
245
+ platform: NotificationPlatform<TContent>,
246
+ ): Promise<OwnedNotificationFailureCode | null> {
247
+ try {
248
+ return (await platform.permission()) === 'granted' ? null : 'permission_denied';
249
+ } catch {
250
+ return 'permission_failed';
251
+ }
252
+ }
253
+
254
+ function scheduleRequest<TContent>(
255
+ namespace: string,
256
+ entry: OwnedNotification<TContent>,
257
+ ): OwnedNotificationScheduleRequest<TContent> {
258
+ return {
259
+ identifier: ownedNotificationIdentifier(namespace, entry.logicalId),
260
+ marker: {
261
+ namespace,
262
+ logicalId: entry.logicalId,
263
+ epochMilliseconds: entry.epochMilliseconds,
264
+ contentDigest: entry.contentDigest,
265
+ },
266
+ content: entry.content,
267
+ };
268
+ }
269
+
270
+ function outcome(
271
+ kept: readonly string[],
272
+ cancelled: readonly string[],
273
+ scheduled: readonly string[],
274
+ failures: readonly OwnedNotificationFailure[],
275
+ ): OwnedNotificationReplacementOutcome {
276
+ return {
277
+ status: failures.length === 0 ? 'complete' : 'incomplete',
278
+ kept,
279
+ cancelled,
280
+ scheduled,
281
+ failures,
282
+ };
283
+ }
284
+
285
+ function isPositiveInteger(value: number): boolean {
286
+ return Number.isSafeInteger(value) && value > 0;
287
+ }
@@ -0,0 +1,90 @@
1
+ import { NotificationPlanError } from './errors.js';
2
+
3
+ export const MAX_NAMESPACE_LENGTH = 64;
4
+ export const MAX_LOGICAL_ID_LENGTH = 128;
5
+ export const MAX_CONTENT_DIGEST_LENGTH = 128;
6
+
7
+ const NAMESPACE_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
8
+ const VISIBLE_ASCII_PATTERN = /^[!-~]+$/;
9
+
10
+ export interface PlannedNotification {
11
+ readonly logicalId: string;
12
+ readonly epochMilliseconds: number;
13
+ readonly contentDigest: string;
14
+ }
15
+
16
+ export function isValidNamespace(namespace: unknown): namespace is string {
17
+ return (
18
+ typeof namespace === 'string' &&
19
+ namespace.length <= MAX_NAMESPACE_LENGTH &&
20
+ NAMESPACE_PATTERN.test(namespace)
21
+ );
22
+ }
23
+
24
+ export function isValidLogicalId(logicalId: unknown): logicalId is string {
25
+ return isVisibleAscii(logicalId, MAX_LOGICAL_ID_LENGTH);
26
+ }
27
+
28
+ export function isValidContentDigest(contentDigest: unknown): contentDigest is string {
29
+ return isVisibleAscii(contentDigest, MAX_CONTENT_DIGEST_LENGTH);
30
+ }
31
+
32
+ export function isValidInstant(epochMilliseconds: unknown): epochMilliseconds is number {
33
+ return (
34
+ typeof epochMilliseconds === 'number' &&
35
+ Number.isSafeInteger(epochMilliseconds) &&
36
+ !Number.isNaN(new Date(epochMilliseconds).getTime())
37
+ );
38
+ }
39
+
40
+ export function assertNamespace(namespace: unknown): asserts namespace is string {
41
+ if (!isValidNamespace(namespace)) {
42
+ throw new NotificationPlanError('invalid_namespace');
43
+ }
44
+ }
45
+
46
+ export function assertIdentity(
47
+ entry: { readonly logicalId: unknown; readonly contentDigest: unknown },
48
+ seen: Set<string>,
49
+ ): void {
50
+ if (!isValidLogicalId(entry.logicalId)) {
51
+ throw new NotificationPlanError('invalid_logical_id');
52
+ }
53
+ if (!isValidContentDigest(entry.contentDigest)) {
54
+ throw new NotificationPlanError('invalid_content_digest', entry.logicalId);
55
+ }
56
+ if (seen.has(entry.logicalId)) {
57
+ throw new NotificationPlanError('duplicate_logical_id', entry.logicalId);
58
+ }
59
+ seen.add(entry.logicalId);
60
+ }
61
+
62
+ export function assertPlannedNotifications(entries: readonly PlannedNotification[]): void {
63
+ const seen = new Set<string>();
64
+ for (const entry of entries) {
65
+ assertIdentity(entry, seen);
66
+ if (!isValidInstant(entry.epochMilliseconds)) {
67
+ throw new NotificationPlanError('invalid_instant', entry.logicalId);
68
+ }
69
+ }
70
+ }
71
+
72
+ export function compareByInstant(left: PlannedNotification, right: PlannedNotification): number {
73
+ return (
74
+ left.epochMilliseconds - right.epochMilliseconds ||
75
+ compareCodeUnits(left.logicalId, right.logicalId)
76
+ );
77
+ }
78
+
79
+ export function compareCodeUnits(left: string, right: string): number {
80
+ if (left === right) {
81
+ return 0;
82
+ }
83
+ return left < right ? -1 : 1;
84
+ }
85
+
86
+ function isVisibleAscii(value: unknown, maxLength: number): value is string {
87
+ return (
88
+ typeof value === 'string' && value.length <= maxLength && VISIBLE_ASCII_PATTERN.test(value)
89
+ );
90
+ }