@baukit/notifications-core 0.7.2 → 0.7.3
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.
- package/package.json +3 -2
- package/src/errors.ts +25 -0
- package/src/index.ts +55 -0
- package/src/memory.ts +148 -0
- package/src/notifications-core.test.ts +161 -0
- package/src/occurrences.ts +154 -0
- package/src/ownership.ts +72 -0
- package/src/plan-vectors.test.ts +28 -0
- package/src/plan-vectors.ts +198 -0
- package/src/replacement-plan.ts +57 -0
- package/src/scheduler.ts +287 -0
- package/src/validation.ts +90 -0
- package/src/vitest.ts +366 -0
- package/src/zoned-vectors.test.ts +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/notifications-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Framework-free local notification planning: zoned occurrence resolution, horizon filtering, deterministic keep, cancel, and schedule sets, and owner-scoped replacement over a platform port.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Patrick Koss",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"files": [
|
|
41
41
|
"dist",
|
|
42
|
+
"src",
|
|
42
43
|
"README.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
@@ -56,6 +57,6 @@
|
|
|
56
57
|
"vitest": "5.0.3"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
|
-
"@baukit/localization-core": "^0.7.
|
|
60
|
+
"@baukit/localization-core": "^0.7.3"
|
|
60
61
|
}
|
|
61
62
|
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type NotificationPlanErrorCode =
|
|
2
|
+
| 'invalid_civil_date'
|
|
3
|
+
| 'invalid_civil_time'
|
|
4
|
+
| 'invalid_time_zone'
|
|
5
|
+
| 'invalid_horizon'
|
|
6
|
+
| 'invalid_clock'
|
|
7
|
+
| 'invalid_namespace'
|
|
8
|
+
| 'invalid_logical_id'
|
|
9
|
+
| 'invalid_content_digest'
|
|
10
|
+
| 'invalid_instant'
|
|
11
|
+
| 'invalid_pending_limit'
|
|
12
|
+
| 'duplicate_logical_id'
|
|
13
|
+
| 'reserved_data_key';
|
|
14
|
+
|
|
15
|
+
export class NotificationPlanError extends Error {
|
|
16
|
+
override readonly name = 'NotificationPlanError';
|
|
17
|
+
readonly code: NotificationPlanErrorCode;
|
|
18
|
+
readonly logicalId: string | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(code: NotificationPlanErrorCode, logicalId?: string) {
|
|
21
|
+
super(logicalId === undefined ? code : `${code}: ${logicalId}`);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.logicalId = logicalId;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export { NotificationPlanError, type NotificationPlanErrorCode } from './errors.js';
|
|
2
|
+
export {
|
|
3
|
+
InMemoryNotificationPlatform,
|
|
4
|
+
NotificationPlatformFaultState,
|
|
5
|
+
type NotificationPlatformFaults,
|
|
6
|
+
type StoredNotification,
|
|
7
|
+
} from './memory.js';
|
|
8
|
+
export {
|
|
9
|
+
resolveNotificationOccurrences,
|
|
10
|
+
type NotificationClock,
|
|
11
|
+
type NotificationOccurrence,
|
|
12
|
+
type NotificationOccurrenceInput,
|
|
13
|
+
type NotificationOccurrenceResolution,
|
|
14
|
+
type ResolvedNotification,
|
|
15
|
+
type SkippedOccurrence,
|
|
16
|
+
type SkippedOccurrenceReason,
|
|
17
|
+
} from './occurrences.js';
|
|
18
|
+
export {
|
|
19
|
+
decodeOwnedNotificationMarker,
|
|
20
|
+
encodeOwnedNotificationMarker,
|
|
21
|
+
isOwnedBy,
|
|
22
|
+
OWNED_NOTIFICATION_DATA_KEY,
|
|
23
|
+
OWNED_NOTIFICATION_MARKER_VERSION,
|
|
24
|
+
ownedNotificationIdentifier,
|
|
25
|
+
type OwnedNotificationMarker,
|
|
26
|
+
} from './ownership.js';
|
|
27
|
+
export {
|
|
28
|
+
planNotificationReplacement,
|
|
29
|
+
type NotificationReplacementOptions,
|
|
30
|
+
type NotificationReplacementPlan,
|
|
31
|
+
} from './replacement-plan.js';
|
|
32
|
+
export {
|
|
33
|
+
createOwnedNotificationScheduler,
|
|
34
|
+
type NotificationOwner,
|
|
35
|
+
type NotificationPermission,
|
|
36
|
+
type NotificationPlatform,
|
|
37
|
+
type OwnedNotification,
|
|
38
|
+
type OwnedNotificationFailure,
|
|
39
|
+
type OwnedNotificationFailureCode,
|
|
40
|
+
type OwnedNotificationReplacementOutcome,
|
|
41
|
+
type OwnedNotificationReplacementStatus,
|
|
42
|
+
type OwnedNotificationScheduler,
|
|
43
|
+
type OwnedNotificationSchedulerOptions,
|
|
44
|
+
type OwnedNotificationScheduleRequest,
|
|
45
|
+
type PendingNotification,
|
|
46
|
+
} from './scheduler.js';
|
|
47
|
+
export {
|
|
48
|
+
isValidContentDigest,
|
|
49
|
+
isValidLogicalId,
|
|
50
|
+
isValidNamespace,
|
|
51
|
+
MAX_CONTENT_DIGEST_LENGTH,
|
|
52
|
+
MAX_LOGICAL_ID_LENGTH,
|
|
53
|
+
MAX_NAMESPACE_LENGTH,
|
|
54
|
+
type PlannedNotification,
|
|
55
|
+
} from './validation.js';
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NotificationPermission,
|
|
3
|
+
NotificationPlatform,
|
|
4
|
+
OwnedNotificationScheduleRequest,
|
|
5
|
+
PendingNotification,
|
|
6
|
+
} from './scheduler.js';
|
|
7
|
+
|
|
8
|
+
export interface NotificationPlatformFaults {
|
|
9
|
+
setPermission(permission: NotificationPermission): void;
|
|
10
|
+
failNextList(): void;
|
|
11
|
+
failNextPermission(): void;
|
|
12
|
+
failCancel(identifier: string): void;
|
|
13
|
+
failSchedule(identifier: string): void;
|
|
14
|
+
holdNextList(): () => void;
|
|
15
|
+
afterNextList(action: () => void): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StoredNotification<TContent> extends PendingNotification {
|
|
19
|
+
readonly content: TContent | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class NotificationPlatformFaultState implements NotificationPlatformFaults {
|
|
23
|
+
permission: NotificationPermission = 'granted';
|
|
24
|
+
private listFailures = 0;
|
|
25
|
+
private permissionFailures = 0;
|
|
26
|
+
private readonly cancelFailures = new Set<string>();
|
|
27
|
+
private readonly scheduleFailures = new Set<string>();
|
|
28
|
+
private readonly listHolds: Promise<void>[] = [];
|
|
29
|
+
private readonly listActions: (() => void)[] = [];
|
|
30
|
+
|
|
31
|
+
setPermission(permission: NotificationPermission): void {
|
|
32
|
+
this.permission = permission;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
failNextList(): void {
|
|
36
|
+
this.listFailures += 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
failNextPermission(): void {
|
|
40
|
+
this.permissionFailures += 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
failCancel(identifier: string): void {
|
|
44
|
+
this.cancelFailures.add(identifier);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
failSchedule(identifier: string): void {
|
|
48
|
+
this.scheduleFailures.add(identifier);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
holdNextList(): () => void {
|
|
52
|
+
let release = (): void => undefined;
|
|
53
|
+
this.listHolds.push(
|
|
54
|
+
new Promise<void>((resolve) => {
|
|
55
|
+
release = resolve;
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return release;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterNextList(action: () => void): void {
|
|
62
|
+
this.listActions.push(action);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async beforeList(): Promise<void> {
|
|
66
|
+
await this.listHolds.shift();
|
|
67
|
+
if (this.listFailures > 0) {
|
|
68
|
+
this.listFailures -= 1;
|
|
69
|
+
throw new Error('list failed');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
afterList(): void {
|
|
74
|
+
this.listActions.shift()?.();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforePermission(): void {
|
|
78
|
+
if (this.permissionFailures > 0) {
|
|
79
|
+
this.permissionFailures -= 1;
|
|
80
|
+
throw new Error('permission check failed');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeCancel(identifier: string): void {
|
|
85
|
+
if (this.cancelFailures.delete(identifier)) {
|
|
86
|
+
throw new Error('cancel failed');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeSchedule(identifier: string): void {
|
|
91
|
+
if (this.scheduleFailures.delete(identifier)) {
|
|
92
|
+
throw new Error('schedule failed');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class InMemoryNotificationPlatform<TContent> implements NotificationPlatform<TContent> {
|
|
98
|
+
readonly faults = new NotificationPlatformFaultState();
|
|
99
|
+
private readonly requests = new Map<string, StoredNotification<TContent>>();
|
|
100
|
+
|
|
101
|
+
addUnrelated(identifier: string): void {
|
|
102
|
+
this.requests.set(identifier, { identifier, marker: null, content: null });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pending(): readonly StoredNotification<TContent>[] {
|
|
106
|
+
return [...this.requests.values()].sort((left, right) =>
|
|
107
|
+
left.identifier < right.identifier ? -1 : 1,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async list(): Promise<readonly PendingNotification[]> {
|
|
112
|
+
await this.faults.beforeList();
|
|
113
|
+
const snapshot = this.pending().map(({ identifier, marker }) => ({ identifier, marker }));
|
|
114
|
+
this.faults.afterList();
|
|
115
|
+
return snapshot;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
cancel(identifier: string): Promise<void> {
|
|
119
|
+
return settle(() => {
|
|
120
|
+
this.faults.beforeCancel(identifier);
|
|
121
|
+
this.requests.delete(identifier);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
permission(): Promise<NotificationPermission> {
|
|
126
|
+
return settle(() => {
|
|
127
|
+
this.faults.beforePermission();
|
|
128
|
+
return this.faults.permission;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
schedule(request: OwnedNotificationScheduleRequest<TContent>): Promise<void> {
|
|
133
|
+
return settle(() => {
|
|
134
|
+
this.faults.beforeSchedule(request.identifier);
|
|
135
|
+
this.requests.set(request.identifier, {
|
|
136
|
+
identifier: request.identifier,
|
|
137
|
+
marker: request.marker,
|
|
138
|
+
content: request.content,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function settle<T>(action: () => T): Promise<T> {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
resolve(action());
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { NotificationPlanError } from './errors.js';
|
|
4
|
+
import { InMemoryNotificationPlatform } from './memory.js';
|
|
5
|
+
import { resolveNotificationOccurrences } from './occurrences.js';
|
|
6
|
+
import {
|
|
7
|
+
decodeOwnedNotificationMarker,
|
|
8
|
+
encodeOwnedNotificationMarker,
|
|
9
|
+
isOwnedBy,
|
|
10
|
+
ownedNotificationIdentifier,
|
|
11
|
+
type OwnedNotificationMarker,
|
|
12
|
+
} from './ownership.js';
|
|
13
|
+
import { planNotificationReplacement } from './replacement-plan.js';
|
|
14
|
+
import { createOwnedNotificationScheduler } from './scheduler.js';
|
|
15
|
+
import { describeOwnedNotificationSchedulerContract } from './vitest.js';
|
|
16
|
+
|
|
17
|
+
const NOW = Date.parse('2026-06-15T06:00:00Z');
|
|
18
|
+
const MARKER: OwnedNotificationMarker = {
|
|
19
|
+
namespace: 'reminders',
|
|
20
|
+
logicalId: 'plan:2026-06-15',
|
|
21
|
+
epochMilliseconds: NOW,
|
|
22
|
+
contentDigest: 'sha256:abc',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const baseInput = {
|
|
26
|
+
timeZone: 'Europe/Berlin',
|
|
27
|
+
gap: 'reject',
|
|
28
|
+
fold: 'earlier',
|
|
29
|
+
clock: () => NOW,
|
|
30
|
+
horizonDays: 2,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
function markerJson(overrides: Record<string, unknown>): string {
|
|
34
|
+
return JSON.stringify({ version: 1, ...MARKER, ...overrides });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveNotificationOccurrences', () => {
|
|
38
|
+
it('carries product fields through to the desired set', () => {
|
|
39
|
+
const resolution = resolveNotificationOccurrences({
|
|
40
|
+
...baseInput,
|
|
41
|
+
occurrences: [
|
|
42
|
+
{
|
|
43
|
+
logicalId: 'a',
|
|
44
|
+
civilDate: '2026-06-15',
|
|
45
|
+
civilTime: '09:00',
|
|
46
|
+
contentDigest: 'v1',
|
|
47
|
+
title: 'kept',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
expect(resolution.desired).toEqual([
|
|
52
|
+
{
|
|
53
|
+
logicalId: 'a',
|
|
54
|
+
civilDate: '2026-06-15',
|
|
55
|
+
civilTime: '09:00',
|
|
56
|
+
contentDigest: 'v1',
|
|
57
|
+
title: 'kept',
|
|
58
|
+
epochMilliseconds: Date.parse('2026-06-15T07:00:00Z'),
|
|
59
|
+
transition: 'none',
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects an unknown policy even without occurrences', () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
resolveNotificationOccurrences({
|
|
67
|
+
...baseInput,
|
|
68
|
+
gap: 'skip' as 'reject',
|
|
69
|
+
occurrences: [],
|
|
70
|
+
}),
|
|
71
|
+
).toThrow(RangeError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns the same result for repeated calls with the same clock', () => {
|
|
75
|
+
const input = {
|
|
76
|
+
...baseInput,
|
|
77
|
+
occurrences: [
|
|
78
|
+
{ logicalId: 'b', civilDate: '2026-06-16', civilTime: '09:00', contentDigest: 'v1' },
|
|
79
|
+
{ logicalId: 'a', civilDate: '2026-06-15', civilTime: '09:00', contentDigest: 'v1' },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
expect(resolveNotificationOccurrences(input)).toEqual(resolveNotificationOccurrences(input));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('planNotificationReplacement', () => {
|
|
87
|
+
it('keeps an entry whose text changed under the same digest', () => {
|
|
88
|
+
const current = [{ logicalId: 'a', epochMilliseconds: NOW + 1, contentDigest: 'v1' }];
|
|
89
|
+
const desired = [
|
|
90
|
+
{ logicalId: 'a', epochMilliseconds: NOW + 1, contentDigest: 'v1', text: 'new' },
|
|
91
|
+
];
|
|
92
|
+
expect(planNotificationReplacement(current, desired)).toEqual({
|
|
93
|
+
keep: current,
|
|
94
|
+
cancel: [],
|
|
95
|
+
schedule: [],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects an instant that is not a safe integer', () => {
|
|
100
|
+
const desired = [{ logicalId: 'a', epochMilliseconds: 1.5, contentDigest: 'v1' }];
|
|
101
|
+
expect(() => planNotificationReplacement([], desired)).toThrow(
|
|
102
|
+
expect.objectContaining({ code: 'invalid_instant', logicalId: 'a' }),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('owned notification markers', () => {
|
|
108
|
+
it('round-trips a marker', () => {
|
|
109
|
+
expect(decodeOwnedNotificationMarker(encodeOwnedNotificationMarker(MARKER))).toEqual(MARKER);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it.each([
|
|
113
|
+
['an object', { ...MARKER }],
|
|
114
|
+
['malformed JSON', '{'],
|
|
115
|
+
['JSON null', 'null'],
|
|
116
|
+
['another version', markerJson({ version: 2 })],
|
|
117
|
+
['an invalid namespace', markerJson({ namespace: 'Upper' })],
|
|
118
|
+
['an empty logical ID', markerJson({ logicalId: '' })],
|
|
119
|
+
['a string instant', markerJson({ epochMilliseconds: '1' })],
|
|
120
|
+
['a missing digest', markerJson({ contentDigest: undefined })],
|
|
121
|
+
])('ignores %s', (_label, value) => {
|
|
122
|
+
expect(decodeOwnedNotificationMarker(value)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('owns a request only when the identifier matches the marker', () => {
|
|
126
|
+
const identifier = ownedNotificationIdentifier(MARKER.namespace, MARKER.logicalId);
|
|
127
|
+
expect(identifier).toBe('baukit:reminders:plan:2026-06-15');
|
|
128
|
+
expect(isOwnedBy('reminders', identifier, MARKER)).toBe(true);
|
|
129
|
+
expect(isOwnedBy('reminders', 'forged', MARKER)).toBe(false);
|
|
130
|
+
expect(isOwnedBy('reminders-extra', identifier, MARKER)).toBe(false);
|
|
131
|
+
expect(isOwnedBy('reminders', identifier, null)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects an invalid namespace in identifiers', () => {
|
|
135
|
+
expect(() => ownedNotificationIdentifier('a:b', 'x')).toThrow(NotificationPlanError);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('createOwnedNotificationScheduler', () => {
|
|
140
|
+
it.each([0, -1, 1.5, Number.NaN])('rejects a pending limit of %s', (pendingLimit) => {
|
|
141
|
+
const platform = new InMemoryNotificationPlatform<string>();
|
|
142
|
+
expect(() => createOwnedNotificationScheduler(platform, { pendingLimit })).toThrow(
|
|
143
|
+
expect.objectContaining({ code: 'invalid_pending_limit' }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('InMemoryNotificationPlatform', () => {
|
|
149
|
+
describeOwnedNotificationSchedulerContract<string>((options) => {
|
|
150
|
+
const platform = new InMemoryNotificationPlatform<string>();
|
|
151
|
+
return {
|
|
152
|
+
scheduler: createOwnedNotificationScheduler(platform, options),
|
|
153
|
+
faults: platform.faults,
|
|
154
|
+
content: (_logicalId, text) => text,
|
|
155
|
+
addUnrelated: (identifier) => {
|
|
156
|
+
platform.addUnrelated(identifier);
|
|
157
|
+
},
|
|
158
|
+
pending: () => platform.pending(),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addCivilDays,
|
|
3
|
+
civilDateForInstant,
|
|
4
|
+
compareCivilDates,
|
|
5
|
+
INVALID_TIME_ZONE_CODE,
|
|
6
|
+
NONEXISTENT_LOCAL_TIME_CODE,
|
|
7
|
+
resolveZonedLocalTime,
|
|
8
|
+
type FoldPolicy,
|
|
9
|
+
type GapPolicy,
|
|
10
|
+
type LocalTimeTransition,
|
|
11
|
+
type ZonedLocalTimeResult,
|
|
12
|
+
} from '@baukit/localization-core';
|
|
13
|
+
|
|
14
|
+
import { NotificationPlanError } from './errors.js';
|
|
15
|
+
import { assertIdentity, compareByInstant, compareCodeUnits } from './validation.js';
|
|
16
|
+
|
|
17
|
+
export interface NotificationOccurrence {
|
|
18
|
+
readonly logicalId: string;
|
|
19
|
+
readonly civilDate: string;
|
|
20
|
+
readonly civilTime: string;
|
|
21
|
+
readonly contentDigest: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NotificationClock = () => number;
|
|
25
|
+
|
|
26
|
+
export interface NotificationOccurrenceInput<T extends NotificationOccurrence> {
|
|
27
|
+
readonly occurrences: readonly T[];
|
|
28
|
+
readonly timeZone: string;
|
|
29
|
+
readonly gap: GapPolicy;
|
|
30
|
+
readonly fold: FoldPolicy;
|
|
31
|
+
readonly clock: NotificationClock;
|
|
32
|
+
readonly horizonDays: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ResolvedNotification<T extends NotificationOccurrence = NotificationOccurrence> = T & {
|
|
36
|
+
readonly epochMilliseconds: number;
|
|
37
|
+
readonly transition: LocalTimeTransition;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SkippedOccurrenceReason = 'past' | 'outside_horizon' | 'nonexistent_local_time';
|
|
41
|
+
|
|
42
|
+
export interface SkippedOccurrence {
|
|
43
|
+
readonly logicalId: string;
|
|
44
|
+
readonly reason: SkippedOccurrenceReason;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface NotificationOccurrenceResolution<T extends NotificationOccurrence> {
|
|
48
|
+
readonly now: number;
|
|
49
|
+
readonly firstCivilDate: string;
|
|
50
|
+
readonly lastCivilDate: string;
|
|
51
|
+
readonly desired: readonly ResolvedNotification<T>[];
|
|
52
|
+
readonly skipped: readonly SkippedOccurrence[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface HorizonWindow {
|
|
56
|
+
readonly now: number;
|
|
57
|
+
readonly firstCivilDate: string;
|
|
58
|
+
readonly lastCivilDate: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TIME_ZONE_PROBE_DATE = '2000-01-01';
|
|
62
|
+
const TIME_ZONE_PROBE_TIME = '12:00';
|
|
63
|
+
|
|
64
|
+
export function resolveNotificationOccurrences<T extends NotificationOccurrence>(
|
|
65
|
+
input: NotificationOccurrenceInput<T>,
|
|
66
|
+
): NotificationOccurrenceResolution<T> {
|
|
67
|
+
const window = horizonWindow(input);
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const desired: ResolvedNotification<T>[] = [];
|
|
70
|
+
const skipped: SkippedOccurrence[] = [];
|
|
71
|
+
|
|
72
|
+
for (const occurrence of input.occurrences) {
|
|
73
|
+
assertIdentity(occurrence, seen);
|
|
74
|
+
const result = resolveZonedLocalTime({
|
|
75
|
+
civilDate: occurrence.civilDate,
|
|
76
|
+
civilTime: occurrence.civilTime,
|
|
77
|
+
timeZone: input.timeZone,
|
|
78
|
+
gap: input.gap,
|
|
79
|
+
fold: input.fold,
|
|
80
|
+
});
|
|
81
|
+
const reason = skipReason(occurrence, result, window);
|
|
82
|
+
if (reason !== null) {
|
|
83
|
+
skipped.push({ logicalId: occurrence.logicalId, reason });
|
|
84
|
+
} else if (result.ok) {
|
|
85
|
+
desired.push({
|
|
86
|
+
...occurrence,
|
|
87
|
+
epochMilliseconds: result.epochMilliseconds,
|
|
88
|
+
transition: result.transition,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...window,
|
|
95
|
+
desired: desired.sort(compareByInstant),
|
|
96
|
+
skipped: skipped.sort((left, right) => compareCodeUnits(left.logicalId, right.logicalId)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function horizonWindow(input: NotificationOccurrenceInput<NotificationOccurrence>): HorizonWindow {
|
|
101
|
+
assertTimeZoneAndPolicies(input);
|
|
102
|
+
if (!Number.isSafeInteger(input.horizonDays) || input.horizonDays < 1) {
|
|
103
|
+
throw new NotificationPlanError('invalid_horizon');
|
|
104
|
+
}
|
|
105
|
+
const now = input.clock();
|
|
106
|
+
if (!Number.isFinite(now) || Number.isNaN(new Date(now).getTime())) {
|
|
107
|
+
throw new NotificationPlanError('invalid_clock');
|
|
108
|
+
}
|
|
109
|
+
const firstCivilDate = civilDateForInstant(now, input.timeZone);
|
|
110
|
+
try {
|
|
111
|
+
return {
|
|
112
|
+
now,
|
|
113
|
+
firstCivilDate,
|
|
114
|
+
lastCivilDate: addCivilDays(firstCivilDate, input.horizonDays - 1),
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
throw new NotificationPlanError('invalid_horizon');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertTimeZoneAndPolicies(
|
|
122
|
+
input: NotificationOccurrenceInput<NotificationOccurrence>,
|
|
123
|
+
): void {
|
|
124
|
+
const probe = resolveZonedLocalTime({
|
|
125
|
+
civilDate: TIME_ZONE_PROBE_DATE,
|
|
126
|
+
civilTime: TIME_ZONE_PROBE_TIME,
|
|
127
|
+
timeZone: input.timeZone,
|
|
128
|
+
gap: input.gap,
|
|
129
|
+
fold: input.fold,
|
|
130
|
+
});
|
|
131
|
+
if (!probe.ok && probe.code === INVALID_TIME_ZONE_CODE) {
|
|
132
|
+
throw new NotificationPlanError('invalid_time_zone');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function skipReason(
|
|
137
|
+
occurrence: NotificationOccurrence,
|
|
138
|
+
result: ZonedLocalTimeResult,
|
|
139
|
+
window: HorizonWindow,
|
|
140
|
+
): SkippedOccurrenceReason | null {
|
|
141
|
+
if (!result.ok && result.code !== NONEXISTENT_LOCAL_TIME_CODE) {
|
|
142
|
+
throw new NotificationPlanError(result.code, occurrence.logicalId);
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
compareCivilDates(occurrence.civilDate, window.firstCivilDate) < 0 ||
|
|
146
|
+
compareCivilDates(occurrence.civilDate, window.lastCivilDate) > 0
|
|
147
|
+
) {
|
|
148
|
+
return 'outside_horizon';
|
|
149
|
+
}
|
|
150
|
+
if (!result.ok) {
|
|
151
|
+
return 'nonexistent_local_time';
|
|
152
|
+
}
|
|
153
|
+
return result.epochMilliseconds <= window.now ? 'past' : null;
|
|
154
|
+
}
|
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertNamespace,
|
|
3
|
+
isValidContentDigest,
|
|
4
|
+
isValidInstant,
|
|
5
|
+
isValidLogicalId,
|
|
6
|
+
isValidNamespace,
|
|
7
|
+
type PlannedNotification,
|
|
8
|
+
} from './validation.js';
|
|
9
|
+
|
|
10
|
+
export const OWNED_NOTIFICATION_DATA_KEY = 'baukitNotification';
|
|
11
|
+
export const OWNED_NOTIFICATION_MARKER_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
const IDENTIFIER_PREFIX = 'baukit';
|
|
14
|
+
const IDENTIFIER_SEPARATOR = ':';
|
|
15
|
+
|
|
16
|
+
export interface OwnedNotificationMarker extends PlannedNotification {
|
|
17
|
+
readonly namespace: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ownedNotificationIdentifier(namespace: string, logicalId: string): string {
|
|
21
|
+
assertNamespace(namespace);
|
|
22
|
+
return [IDENTIFIER_PREFIX, namespace, logicalId].join(IDENTIFIER_SEPARATOR);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encodeOwnedNotificationMarker(marker: OwnedNotificationMarker): string {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
version: OWNED_NOTIFICATION_MARKER_VERSION,
|
|
28
|
+
namespace: marker.namespace,
|
|
29
|
+
logicalId: marker.logicalId,
|
|
30
|
+
epochMilliseconds: marker.epochMilliseconds,
|
|
31
|
+
contentDigest: marker.contentDigest,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodeOwnedNotificationMarker(value: unknown): OwnedNotificationMarker | null {
|
|
36
|
+
if (typeof value !== 'string') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(value);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const record = parsed as Record<string, unknown>;
|
|
49
|
+
const { namespace, logicalId, epochMilliseconds, contentDigest } = record;
|
|
50
|
+
if (
|
|
51
|
+
record['version'] !== OWNED_NOTIFICATION_MARKER_VERSION ||
|
|
52
|
+
!isValidNamespace(namespace) ||
|
|
53
|
+
!isValidLogicalId(logicalId) ||
|
|
54
|
+
!isValidInstant(epochMilliseconds) ||
|
|
55
|
+
!isValidContentDigest(contentDigest)
|
|
56
|
+
) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { namespace, logicalId, epochMilliseconds, contentDigest };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isOwnedBy(
|
|
63
|
+
namespace: string,
|
|
64
|
+
identifier: string,
|
|
65
|
+
marker: OwnedNotificationMarker | null,
|
|
66
|
+
): marker is OwnedNotificationMarker {
|
|
67
|
+
return (
|
|
68
|
+
marker !== null &&
|
|
69
|
+
marker.namespace === namespace &&
|
|
70
|
+
identifier === ownedNotificationIdentifier(namespace, marker.logicalId)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
notificationPlanVectorChecks,
|
|
7
|
+
type NotificationPlanVectorFixture,
|
|
8
|
+
} from './plan-vectors.js';
|
|
9
|
+
|
|
10
|
+
const fixtureUrl = new URL(
|
|
11
|
+
'../../../../fixtures/notifications/plan-vectors-v1.json',
|
|
12
|
+
import.meta.url,
|
|
13
|
+
);
|
|
14
|
+
const readUtf8File = readFileSync as unknown as (path: URL, encoding: 'utf8') => string;
|
|
15
|
+
const fixture = JSON.parse(readUtf8File(fixtureUrl, 'utf8')) as NotificationPlanVectorFixture;
|
|
16
|
+
|
|
17
|
+
describe('notification plan vectors', () => {
|
|
18
|
+
it('reads version 1 of the shared vectors', () => {
|
|
19
|
+
expect(fixture.version).toBe(1);
|
|
20
|
+
const errorCases = fixture.cases.filter((entry) => 'error' in entry.expected);
|
|
21
|
+
expect(errorCases.length).toBeGreaterThan(0);
|
|
22
|
+
expect(errorCases.length).toBeLessThan(fixture.cases.length);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each(notificationPlanVectorChecks(fixture))('$label', ({ actual, expected }) => {
|
|
26
|
+
expect(actual()).toEqual(expected);
|
|
27
|
+
});
|
|
28
|
+
});
|