@chipmobilesdk/rn-notification 0.4.0 → 0.4.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +174 -166
  2. package/README.md +414 -410
  3. package/package.json +58 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +134 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +67 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +706 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +143 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +278 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +73 -73
  64. package/src/state/mmkv-store.ts +75 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +266 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +40 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
@@ -1,80 +1,80 @@
1
- /**
2
- * Delivery moment resolution.
3
- *
4
- * Two rules, and they are two sides of one decision rather than a threshold:
5
- * a moment more than the tolerance in the past is a caller error and is
6
- * refused; a moment a few seconds in the past is a processing delay and is
7
- * delivered promptly. Both outcomes are reported, so a caller never has to
8
- * guess which happened.
9
- *
10
- * Sixty seconds is long enough to absorb reconciling a large desired set, cold
11
- * start work, and clock skew, and short enough that a genuinely stale schedule
12
- * is still caught rather than fired at the user minutes late with no
13
- * explanation.
14
- */
15
-
16
- import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
17
- import type { DeliveryMoment } from '../contract/notification';
18
-
19
- export const PAST_TOLERANCE_MS = 60_000;
20
-
21
- export type MomentResolution =
22
- | { readonly kind: 'future'; readonly at: number }
23
- | { readonly kind: 'immediate'; readonly at: number; readonly lateBy: number };
24
-
25
- /**
26
- * Classifies a resolved instant against now.
27
- *
28
- * Takes an already-anchored instant: anchoring decides *which* instant a moment
29
- * means, and this decides whether that instant is usable. Keeping them separate
30
- * is what lets each be tested without the other.
31
- */
32
- export function resolveMoment(resolvedAt: number, now: number): MomentResolution {
33
- if (!Number.isFinite(resolvedAt)) {
34
- throw new NotificationError(
35
- DIAGNOSTIC_CODES.MOMENT_IN_PAST,
36
- 'A delivery moment must be a finite epoch-millisecond timestamp.',
37
- { received: String(resolvedAt) },
38
- );
39
- }
40
-
41
- if (resolvedAt >= now) {
42
- return { kind: 'future', at: resolvedAt };
43
- }
44
-
45
- const lateBy = now - resolvedAt;
46
-
47
- if (lateBy <= PAST_TOLERANCE_MS) {
48
- return { kind: 'immediate', at: now, lateBy };
49
- }
50
-
51
- throw new NotificationError(
52
- DIAGNOSTIC_CODES.MOMENT_IN_PAST,
53
- `Delivery moment is ${Math.round(lateBy / 1000)}s in the past, beyond the ${PAST_TOLERANCE_MS / 1000}s tolerance. ` +
54
- 'It was not scheduled, because handing it to the platform would fire it immediately without explanation.',
55
- { lateByMs: lateBy, toleranceMs: PAST_TOLERANCE_MS },
56
- );
57
- }
58
-
59
- /** Validates the shape of a caller-supplied moment before anchoring resolves it. */
60
- export function validateMoment(moment: DeliveryMoment): void {
61
- if (typeof moment.at !== 'number' || !Number.isFinite(moment.at)) {
62
- throw new NotificationError(
63
- DIAGNOSTIC_CODES.MOMENT_IN_PAST,
64
- 'A delivery moment must carry a finite epoch-millisecond `at` value.',
65
- { received: typeof moment.at },
66
- );
67
- }
68
-
69
- if (
70
- moment.anchoring !== undefined &&
71
- moment.anchoring !== 'wallClock' &&
72
- moment.anchoring !== 'absolute'
73
- ) {
74
- throw new NotificationError(
75
- DIAGNOSTIC_CODES.MOMENT_IN_PAST,
76
- 'Anchoring must be either "wallClock" or "absolute".',
77
- { received: String(moment.anchoring) },
78
- );
79
- }
80
- }
1
+ /**
2
+ * Delivery moment resolution.
3
+ *
4
+ * Two rules, and they are two sides of one decision rather than a threshold:
5
+ * a moment more than the tolerance in the past is a caller error and is
6
+ * refused; a moment a few seconds in the past is a processing delay and is
7
+ * delivered promptly. Both outcomes are reported, so a caller never has to
8
+ * guess which happened.
9
+ *
10
+ * Sixty seconds is long enough to absorb reconciling a large desired set, cold
11
+ * start work, and clock skew, and short enough that a genuinely stale schedule
12
+ * is still caught rather than fired at the user minutes late with no
13
+ * explanation.
14
+ */
15
+
16
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
17
+ import type { DeliveryMoment } from '../contract/notification';
18
+
19
+ export const PAST_TOLERANCE_MS = 60_000;
20
+
21
+ export type MomentResolution =
22
+ | { readonly kind: 'future'; readonly at: number }
23
+ | { readonly kind: 'immediate'; readonly at: number; readonly lateBy: number };
24
+
25
+ /**
26
+ * Classifies a resolved instant against now.
27
+ *
28
+ * Takes an already-anchored instant: anchoring decides *which* instant a moment
29
+ * means, and this decides whether that instant is usable. Keeping them separate
30
+ * is what lets each be tested without the other.
31
+ */
32
+ export function resolveMoment(resolvedAt: number, now: number): MomentResolution {
33
+ if (!Number.isFinite(resolvedAt)) {
34
+ throw new NotificationError(
35
+ DIAGNOSTIC_CODES.MOMENT_IN_PAST,
36
+ 'A delivery moment must be a finite epoch-millisecond timestamp.',
37
+ { received: String(resolvedAt) },
38
+ );
39
+ }
40
+
41
+ if (resolvedAt >= now) {
42
+ return { kind: 'future', at: resolvedAt };
43
+ }
44
+
45
+ const lateBy = now - resolvedAt;
46
+
47
+ if (lateBy <= PAST_TOLERANCE_MS) {
48
+ return { kind: 'immediate', at: now, lateBy };
49
+ }
50
+
51
+ throw new NotificationError(
52
+ DIAGNOSTIC_CODES.MOMENT_IN_PAST,
53
+ `Delivery moment is ${Math.round(lateBy / 1000)}s in the past, beyond the ${PAST_TOLERANCE_MS / 1000}s tolerance. ` +
54
+ 'It was not scheduled, because handing it to the platform would fire it immediately without explanation.',
55
+ { lateByMs: lateBy, toleranceMs: PAST_TOLERANCE_MS },
56
+ );
57
+ }
58
+
59
+ /** Validates the shape of a caller-supplied moment before anchoring resolves it. */
60
+ export function validateMoment(moment: DeliveryMoment): void {
61
+ if (typeof moment.at !== 'number' || !Number.isFinite(moment.at)) {
62
+ throw new NotificationError(
63
+ DIAGNOSTIC_CODES.MOMENT_IN_PAST,
64
+ 'A delivery moment must carry a finite epoch-millisecond `at` value.',
65
+ { received: typeof moment.at },
66
+ );
67
+ }
68
+
69
+ if (
70
+ moment.anchoring !== undefined &&
71
+ moment.anchoring !== 'wallClock' &&
72
+ moment.anchoring !== 'absolute'
73
+ ) {
74
+ throw new NotificationError(
75
+ DIAGNOSTIC_CODES.MOMENT_IN_PAST,
76
+ 'Anchoring must be either "wallClock" or "absolute".',
77
+ { received: String(moment.anchoring) },
78
+ );
79
+ }
80
+ }
@@ -1,111 +1,111 @@
1
- /**
2
- * Content validation.
3
- *
4
- * The package passes content through **unchanged** and never truncates it.
5
- * Display truncation is the platform's decision and differs between them; a
6
- * library that pre-truncated would be guessing at a rule it does not own, and
7
- * would corrupt content that would have displayed fine.
8
- *
9
- * What it does refuse is content over the platform's hard payload limit, where
10
- * the alternative is not a shortened notification but a failed or undefined
11
- * delivery. That is a real error and is reported as one, before any platform
12
- * call.
13
- */
14
-
15
- import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
16
- import type { NotificationContent } from '../contract/notification';
17
-
18
- /**
19
- * Hard payload ceilings.
20
- *
21
- * These are *not* display limits. A title longer than a phone shows is fine and
22
- * is passed through; the README documents where each platform visually
23
- * truncates so a caller can decide for itself.
24
- *
25
- * The total is deliberately lower than the sum of the parts, so it actually
26
- * binds — a per-field check that can never be exceeded in aggregate would be
27
- * dead code. It sits under the 4 KB payload budget both platforms enforce,
28
- * leaving headroom for the routing payload that shares it.
29
- */
30
- export const CONTENT_LIMITS = {
31
- maxTitleBytes: 512,
32
- maxBodyBytes: 3072,
33
- maxSubtitleBytes: 512,
34
- maxTotalBytes: 3584,
35
- } as const;
36
-
37
- /** UTF-8 byte length — the unit the platforms actually bound. */
38
- export function byteLength(value: string): number {
39
- let bytes = 0;
40
- for (const char of value) {
41
- const code = char.codePointAt(0) ?? 0;
42
- if (code <= 0x7f) bytes += 1;
43
- else if (code <= 0x7ff) bytes += 2;
44
- else if (code <= 0xffff) bytes += 3;
45
- else bytes += 4;
46
- }
47
- return bytes;
48
- }
49
-
50
- function requireString(value: unknown, field: string): string {
51
- if (typeof value !== 'string') {
52
- throw new NotificationError(
53
- DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
54
- `Notification content field "${field}" must be a string of resolved display text. The package performs no locale resolution.`,
55
- { field, received: typeof value },
56
- );
57
- }
58
- return value;
59
- }
60
-
61
- /**
62
- * Validates content and returns it **byte-identical**.
63
- *
64
- * The return value is the same object reference where possible, so nothing
65
- * downstream can mistake this for a transformation step.
66
- */
67
- export function validateContent(content: NotificationContent): NotificationContent {
68
- const title = requireString(content.title, 'title');
69
- const body = requireString(content.body, 'body');
70
- const subtitle =
71
- content.subtitle === undefined ? undefined : requireString(content.subtitle, 'subtitle');
72
-
73
- if (title.length === 0 && body.length === 0) {
74
- throw new NotificationError(
75
- DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
76
- 'A notification must carry a title or a body; both were empty.',
77
- { field: 'title+body' },
78
- );
79
- }
80
-
81
- const checks: [string, string, number][] = [
82
- ['title', title, CONTENT_LIMITS.maxTitleBytes],
83
- ['body', body, CONTENT_LIMITS.maxBodyBytes],
84
- ];
85
- if (subtitle !== undefined) {
86
- checks.push(['subtitle', subtitle, CONTENT_LIMITS.maxSubtitleBytes]);
87
- }
88
-
89
- for (const [field, value, limit] of checks) {
90
- const size = byteLength(value);
91
- if (size > limit) {
92
- throw new NotificationError(
93
- DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
94
- `Notification "${field}" is ${size} bytes, over the ${limit}-byte platform limit. ` +
95
- 'It was refused rather than truncated, because the package does not decide where a caller\'s content should be cut.',
96
- { field, bytes: size, limit },
97
- );
98
- }
99
- }
100
-
101
- const total = byteLength(title) + byteLength(body) + (subtitle ? byteLength(subtitle) : 0);
102
- if (total > CONTENT_LIMITS.maxTotalBytes) {
103
- throw new NotificationError(
104
- DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
105
- `Notification content totals ${total} bytes, over the ${CONTENT_LIMITS.maxTotalBytes}-byte payload limit.`,
106
- { field: 'total', bytes: total, limit: CONTENT_LIMITS.maxTotalBytes },
107
- );
108
- }
109
-
110
- return content;
111
- }
1
+ /**
2
+ * Content validation.
3
+ *
4
+ * The package passes content through **unchanged** and never truncates it.
5
+ * Display truncation is the platform's decision and differs between them; a
6
+ * library that pre-truncated would be guessing at a rule it does not own, and
7
+ * would corrupt content that would have displayed fine.
8
+ *
9
+ * What it does refuse is content over the platform's hard payload limit, where
10
+ * the alternative is not a shortened notification but a failed or undefined
11
+ * delivery. That is a real error and is reported as one, before any platform
12
+ * call.
13
+ */
14
+
15
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
16
+ import type { NotificationContent } from '../contract/notification';
17
+
18
+ /**
19
+ * Hard payload ceilings.
20
+ *
21
+ * These are *not* display limits. A title longer than a phone shows is fine and
22
+ * is passed through; the README documents where each platform visually
23
+ * truncates so a caller can decide for itself.
24
+ *
25
+ * The total is deliberately lower than the sum of the parts, so it actually
26
+ * binds — a per-field check that can never be exceeded in aggregate would be
27
+ * dead code. It sits under the 4 KB payload budget both platforms enforce,
28
+ * leaving headroom for the routing payload that shares it.
29
+ */
30
+ export const CONTENT_LIMITS = {
31
+ maxTitleBytes: 512,
32
+ maxBodyBytes: 3072,
33
+ maxSubtitleBytes: 512,
34
+ maxTotalBytes: 3584,
35
+ } as const;
36
+
37
+ /** UTF-8 byte length — the unit the platforms actually bound. */
38
+ export function byteLength(value: string): number {
39
+ let bytes = 0;
40
+ for (const char of value) {
41
+ const code = char.codePointAt(0) ?? 0;
42
+ if (code <= 0x7f) bytes += 1;
43
+ else if (code <= 0x7ff) bytes += 2;
44
+ else if (code <= 0xffff) bytes += 3;
45
+ else bytes += 4;
46
+ }
47
+ return bytes;
48
+ }
49
+
50
+ function requireString(value: unknown, field: string): string {
51
+ if (typeof value !== 'string') {
52
+ throw new NotificationError(
53
+ DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
54
+ `Notification content field "${field}" must be a string of resolved display text. The package performs no locale resolution.`,
55
+ { field, received: typeof value },
56
+ );
57
+ }
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * Validates content and returns it **byte-identical**.
63
+ *
64
+ * The return value is the same object reference where possible, so nothing
65
+ * downstream can mistake this for a transformation step.
66
+ */
67
+ export function validateContent(content: NotificationContent): NotificationContent {
68
+ const title = requireString(content.title, 'title');
69
+ const body = requireString(content.body, 'body');
70
+ const subtitle =
71
+ content.subtitle === undefined ? undefined : requireString(content.subtitle, 'subtitle');
72
+
73
+ if (title.length === 0 && body.length === 0) {
74
+ throw new NotificationError(
75
+ DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
76
+ 'A notification must carry a title or a body; both were empty.',
77
+ { field: 'title+body' },
78
+ );
79
+ }
80
+
81
+ const checks: [string, string, number][] = [
82
+ ['title', title, CONTENT_LIMITS.maxTitleBytes],
83
+ ['body', body, CONTENT_LIMITS.maxBodyBytes],
84
+ ];
85
+ if (subtitle !== undefined) {
86
+ checks.push(['subtitle', subtitle, CONTENT_LIMITS.maxSubtitleBytes]);
87
+ }
88
+
89
+ for (const [field, value, limit] of checks) {
90
+ const size = byteLength(value);
91
+ if (size > limit) {
92
+ throw new NotificationError(
93
+ DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
94
+ `Notification "${field}" is ${size} bytes, over the ${limit}-byte platform limit. ` +
95
+ "It was refused rather than truncated, because the package does not decide where a caller's content should be cut.",
96
+ { field, bytes: size, limit },
97
+ );
98
+ }
99
+ }
100
+
101
+ const total = byteLength(title) + byteLength(body) + (subtitle ? byteLength(subtitle) : 0);
102
+ if (total > CONTENT_LIMITS.maxTotalBytes) {
103
+ throw new NotificationError(
104
+ DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
105
+ `Notification content totals ${total} bytes, over the ${CONTENT_LIMITS.maxTotalBytes}-byte payload limit.`,
106
+ { field: 'total', bytes: total, limit: CONTENT_LIMITS.maxTotalBytes },
107
+ );
108
+ }
109
+
110
+ return content;
111
+ }
@@ -1,126 +1,126 @@
1
- /**
2
- * Identifier validation.
3
- *
4
- * Identity is the caller's contract: only the caller can derive an identifier
5
- * reproducibly from its own data, so the package never generates one.
6
- *
7
- * The charset is composite-friendly because real derived keys are hierarchical
8
- * — `task:1234:reminder:2`. A charset that forced encoding of natural keys
9
- * would work against caller-supplied identity being readable and reproducible,
10
- * which is most of its value.
11
- *
12
- * Nothing is normalized, trimmed, case-folded, or hashed. Two identifiers
13
- * differing in any character are different identifiers, and the value visible
14
- * at the platform boundary is exactly what the caller supplied — which is what
15
- * lets a notification be traced back to the data that produced it.
16
- */
17
-
18
- import { DIAGNOSTIC_CODES, NotificationError, type DiagnosticCode } from '../contract/errors';
19
- import { REPEAT_STOP_PREFIX } from '../repeat/marker';
20
-
21
- export const MAX_IDENTIFIER_LENGTH = 128;
22
-
23
- /** `A–Z a–z 0–9 . _ - : /` — printable, unambiguous, and safe at every boundary. */
24
- const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/-]+$/;
25
-
26
- /** Reports the first offending character so a failure is actionable without echoing the value. */
27
- function firstInvalidCharacter(value: string): { char: string; index: number } | undefined {
28
- for (let index = 0; index < value.length; index += 1) {
29
- const char = value[index];
30
- if (!/[A-Za-z0-9._:/-]/.test(char)) {
31
- return { char, index };
32
- }
33
- }
34
- return undefined;
35
- }
36
-
37
- export type IdentifierKind = 'id' | 'ownerTag' | 'groupTag' | 'seriesId' | 'actionId';
38
-
39
- const EMPTY_CODE: Record<IdentifierKind, DiagnosticCode> = {
40
- id: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
41
- ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
42
- groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
43
- seriesId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
44
- actionId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
45
- };
46
-
47
- const LENGTH_CODE: Record<IdentifierKind, DiagnosticCode> = {
48
- id: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
49
- ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
50
- groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
51
- seriesId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
52
- actionId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
53
- };
54
-
55
- const CHARSET_CODE: Record<IdentifierKind, DiagnosticCode> = {
56
- id: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
57
- ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
58
- groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
59
- seriesId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
60
- actionId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
61
- };
62
-
63
- /**
64
- * Throws a typed error naming the violated rule.
65
- *
66
- * The offending character and its position are reported; the value itself never
67
- * is, because an identifier can carry user data a caller derived it from.
68
- */
69
- export function validateIdentifier(value: unknown, kind: IdentifierKind = 'id'): string {
70
- if (typeof value !== 'string' || value.length === 0) {
71
- throw new NotificationError(
72
- EMPTY_CODE[kind],
73
- `A ${kind} must be a non-empty string supplied by the caller. The package never generates one.`,
74
- { kind, received: typeof value },
75
- );
76
- }
77
-
78
- if (value.length > MAX_IDENTIFIER_LENGTH) {
79
- throw new NotificationError(
80
- LENGTH_CODE[kind],
81
- `A ${kind} must be at most ${MAX_IDENTIFIER_LENGTH} characters.`,
82
- { kind, length: value.length, max: MAX_IDENTIFIER_LENGTH },
83
- );
84
- }
85
-
86
- if (value.startsWith(REPEAT_STOP_PREFIX)) {
87
- // Not a style rule. Repeat expiry cancels markers by identifier, so a
88
- // consumer identifier that looks like one would be deleted out from under
89
- // a real notification.
90
- throw new NotificationError(
91
- CHARSET_CODE[kind],
92
- `A ${kind} may not begin with "${REPEAT_STOP_PREFIX}" — that prefix is reserved for the package's own repeat-expiry markers, which are cancelled automatically.`,
93
- { kind, reservedPrefix: REPEAT_STOP_PREFIX },
94
- );
95
- }
96
-
97
- if (!IDENTIFIER_PATTERN.test(value)) {
98
- const offender = firstInvalidCharacter(value);
99
- throw new NotificationError(
100
- CHARSET_CODE[kind],
101
- `A ${kind} may contain only A-Z, a-z, 0-9, and the separators . _ - : / — whitespace and control characters are not permitted.`,
102
- { kind, index: offender?.index, charCode: offender?.char.charCodeAt(0) },
103
- );
104
- }
105
-
106
- return value;
107
- }
108
-
109
- /** Non-throwing form, for validating a batch without unwinding on the first failure. */
110
- export function isValidIdentifier(value: unknown): value is string {
111
- return (
112
- typeof value === 'string' &&
113
- value.length > 0 &&
114
- value.length <= MAX_IDENTIFIER_LENGTH &&
115
- IDENTIFIER_PATTERN.test(value)
116
- );
117
- }
118
-
119
- /** Validates an optional tag, returning undefined when absent rather than failing. */
120
- export function validateOptionalTag(
121
- value: string | undefined,
122
- kind: Extract<IdentifierKind, 'ownerTag' | 'groupTag'>,
123
- ): string | undefined {
124
- if (value === undefined) return undefined;
125
- return validateIdentifier(value, kind);
126
- }
1
+ /**
2
+ * Identifier validation.
3
+ *
4
+ * Identity is the caller's contract: only the caller can derive an identifier
5
+ * reproducibly from its own data, so the package never generates one.
6
+ *
7
+ * The charset is composite-friendly because real derived keys are hierarchical
8
+ * — `task:1234:reminder:2`. A charset that forced encoding of natural keys
9
+ * would work against caller-supplied identity being readable and reproducible,
10
+ * which is most of its value.
11
+ *
12
+ * Nothing is normalized, trimmed, case-folded, or hashed. Two identifiers
13
+ * differing in any character are different identifiers, and the value visible
14
+ * at the platform boundary is exactly what the caller supplied — which is what
15
+ * lets a notification be traced back to the data that produced it.
16
+ */
17
+
18
+ import { DIAGNOSTIC_CODES, NotificationError, type DiagnosticCode } from '../contract/errors';
19
+ import { REPEAT_STOP_PREFIX } from '../repeat/marker';
20
+
21
+ export const MAX_IDENTIFIER_LENGTH = 128;
22
+
23
+ /** `A–Z a–z 0–9 . _ - : /` — printable, unambiguous, and safe at every boundary. */
24
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/-]+$/;
25
+
26
+ /** Reports the first offending character so a failure is actionable without echoing the value. */
27
+ function firstInvalidCharacter(value: string): { char: string; index: number } | undefined {
28
+ for (let index = 0; index < value.length; index += 1) {
29
+ const char = value[index];
30
+ if (!/[A-Za-z0-9._:/-]/.test(char)) {
31
+ return { char, index };
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ export type IdentifierKind = 'id' | 'ownerTag' | 'groupTag' | 'seriesId' | 'actionId';
38
+
39
+ const EMPTY_CODE: Record<IdentifierKind, DiagnosticCode> = {
40
+ id: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
41
+ ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
42
+ groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
43
+ seriesId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
44
+ actionId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
45
+ };
46
+
47
+ const LENGTH_CODE: Record<IdentifierKind, DiagnosticCode> = {
48
+ id: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
49
+ ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
50
+ groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
51
+ seriesId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
52
+ actionId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
53
+ };
54
+
55
+ const CHARSET_CODE: Record<IdentifierKind, DiagnosticCode> = {
56
+ id: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
57
+ ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
58
+ groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
59
+ seriesId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
60
+ actionId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
61
+ };
62
+
63
+ /**
64
+ * Throws a typed error naming the violated rule.
65
+ *
66
+ * The offending character and its position are reported; the value itself never
67
+ * is, because an identifier can carry user data a caller derived it from.
68
+ */
69
+ export function validateIdentifier(value: unknown, kind: IdentifierKind = 'id'): string {
70
+ if (typeof value !== 'string' || value.length === 0) {
71
+ throw new NotificationError(
72
+ EMPTY_CODE[kind],
73
+ `A ${kind} must be a non-empty string supplied by the caller. The package never generates one.`,
74
+ { kind, received: typeof value },
75
+ );
76
+ }
77
+
78
+ if (value.length > MAX_IDENTIFIER_LENGTH) {
79
+ throw new NotificationError(
80
+ LENGTH_CODE[kind],
81
+ `A ${kind} must be at most ${MAX_IDENTIFIER_LENGTH} characters.`,
82
+ { kind, length: value.length, max: MAX_IDENTIFIER_LENGTH },
83
+ );
84
+ }
85
+
86
+ if (value.startsWith(REPEAT_STOP_PREFIX)) {
87
+ // Not a style rule. Repeat expiry cancels markers by identifier, so a
88
+ // consumer identifier that looks like one would be deleted out from under
89
+ // a real notification.
90
+ throw new NotificationError(
91
+ CHARSET_CODE[kind],
92
+ `A ${kind} may not begin with "${REPEAT_STOP_PREFIX}" — that prefix is reserved for the package's own repeat-expiry markers, which are cancelled automatically.`,
93
+ { kind, reservedPrefix: REPEAT_STOP_PREFIX },
94
+ );
95
+ }
96
+
97
+ if (!IDENTIFIER_PATTERN.test(value)) {
98
+ const offender = firstInvalidCharacter(value);
99
+ throw new NotificationError(
100
+ CHARSET_CODE[kind],
101
+ `A ${kind} may contain only A-Z, a-z, 0-9, and the separators . _ - : / — whitespace and control characters are not permitted.`,
102
+ { kind, index: offender?.index, charCode: offender?.char.charCodeAt(0) },
103
+ );
104
+ }
105
+
106
+ return value;
107
+ }
108
+
109
+ /** Non-throwing form, for validating a batch without unwinding on the first failure. */
110
+ export function isValidIdentifier(value: unknown): value is string {
111
+ return (
112
+ typeof value === 'string' &&
113
+ value.length > 0 &&
114
+ value.length <= MAX_IDENTIFIER_LENGTH &&
115
+ IDENTIFIER_PATTERN.test(value)
116
+ );
117
+ }
118
+
119
+ /** Validates an optional tag, returning undefined when absent rather than failing. */
120
+ export function validateOptionalTag(
121
+ value: string | undefined,
122
+ kind: Extract<IdentifierKind, 'ownerTag' | 'groupTag'>,
123
+ ): string | undefined {
124
+ if (value === undefined) return undefined;
125
+ return validateIdentifier(value, kind);
126
+ }