@aglyn/tenant-data-admin 1.0.0-beta.229 → 1.0.0-beta.231
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 +7 -7
- package/src/lib/server/duplicate-resource.js +4 -1
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/erase.js +14 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-released-names.d.ts +22 -0
- package/src/lib/server/host-released-names.js +31 -0
- package/src/lib/server/host-released-names.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +1 -1
- package/src/lib/server/host-visitor-records.js +1 -1
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/mobile-push-switch.d.ts +46 -0
- package/src/lib/server/mobile-push-switch.js +45 -0
- package/src/lib/server/mobile-push-switch.js.map +1 -0
- package/src/lib/server/native-push.d.ts +17 -0
- package/src/lib/server/native-push.js +35 -0
- package/src/lib/server/native-push.js.map +1 -0
- package/src/lib/server/notifications.js +23 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/personal-data-export.js +108 -0
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/plugin-event-outbox.d.ts +138 -0
- package/src/lib/server/plugin-event-outbox.js +229 -0
- package/src/lib/server/plugin-event-outbox.js.map +1 -0
- package/src/lib/server/push-apns.d.ts +73 -0
- package/src/lib/server/push-apns.js +225 -0
- package/src/lib/server/push-apns.js.map +1 -0
- package/src/lib/server/push-delivery.d.ts +78 -0
- package/src/lib/server/push-delivery.js +176 -0
- package/src/lib/server/push-delivery.js.map +1 -0
- package/src/lib/server/push-fcm.d.ts +29 -0
- package/src/lib/server/push-fcm.js +83 -0
- package/src/lib/server/push-fcm.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +6 -0
- package/src/lib/server/serve-media-cdn.js +20 -3
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-marketing-sync.d.ts +146 -0
- package/src/lib/server/site-marketing-sync.js +315 -0
- package/src/lib/server/site-marketing-sync.js.map +1 -0
- package/src/lib/server/upsert-contact.js +6 -5
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginDomainEvent } from '@aglyn/aglyn/plugin-manager/plugin-domain-events';
|
|
18
|
+
/**
|
|
19
|
+
* The outbox behind plugin events (AGL-3611): raising an event is a write
|
|
20
|
+
* here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in
|
|
21
|
+
* core for the contract a raiser and a subscriber see.
|
|
22
|
+
*
|
|
23
|
+
* WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —
|
|
24
|
+
* must reach accounting, shipping and the merchant's webhooks even when one of
|
|
25
|
+
* them is down, and even when the process that made the write dies a moment
|
|
26
|
+
* later. Calling the subscribers in the request loses the event on either.
|
|
27
|
+
* Writing it here, in the raiser's own transaction where it has one, makes
|
|
28
|
+
* the event exist exactly when the fact does.
|
|
29
|
+
*
|
|
30
|
+
* AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers
|
|
31
|
+
* took the event (`delivered`), and a retry calls only the rest. A
|
|
32
|
+
* subscriber can still see an event twice — its handler succeeded and the
|
|
33
|
+
* write recording that did not — so the envelope's `id` is the key it
|
|
34
|
+
* dedupes on.
|
|
35
|
+
*
|
|
36
|
+
* ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in
|
|
37
|
+
* a transaction before it delivers, so two overlapping beats do not both
|
|
38
|
+
* deliver it. A pass that dies holding the lease is retried once the lease
|
|
39
|
+
* lapses.
|
|
40
|
+
*
|
|
41
|
+
* A delivered event is deleted: the collection holds what is still owed, so
|
|
42
|
+
* the drain's query reads nothing on an idle platform. One that runs out of
|
|
43
|
+
* attempts stays, `failed`, with each subscriber's last error.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PLUGIN_EVENT_OUTBOX_COLLECTION = "pluginEventOutbox";
|
|
46
|
+
/** Attempts per event before it is left as a dead letter. */
|
|
47
|
+
export declare const PLUGIN_EVENT_MAX_ATTEMPTS = 8;
|
|
48
|
+
/** Wait before attempt n+1, by attempts made; the last step repeats. */
|
|
49
|
+
export declare const PLUGIN_EVENT_BACKOFF_MS: readonly number[];
|
|
50
|
+
/** How long a drain pass holds an event while it delivers. */
|
|
51
|
+
export declare const PLUGIN_EVENT_LEASE_MS = 60000;
|
|
52
|
+
/** Events one pass reads. */
|
|
53
|
+
export declare const PLUGIN_EVENT_SCAN_LIMIT = 100;
|
|
54
|
+
export type PluginEventOutboxStatus = 'pending' | 'failed';
|
|
55
|
+
/** `pluginEventOutbox/{id}`. Written and read only by the Admin SDK. */
|
|
56
|
+
export interface PluginEventOutboxRecord {
|
|
57
|
+
status: PluginEventOutboxStatus;
|
|
58
|
+
event: string;
|
|
59
|
+
/** The plugin that raised it. */
|
|
60
|
+
pluginId: string;
|
|
61
|
+
hostId: string;
|
|
62
|
+
orgId: string | null;
|
|
63
|
+
occurredAtMs: number;
|
|
64
|
+
payload: Record<string, unknown>;
|
|
65
|
+
attempts: number;
|
|
66
|
+
nextAttemptAtMs: number;
|
|
67
|
+
createdAtMs: number;
|
|
68
|
+
/** Subscribers that took it, by plugin id, when. */
|
|
69
|
+
delivered?: Record<string, number>;
|
|
70
|
+
/** Attempts per subscriber, for the envelope's `attempt`. */
|
|
71
|
+
subscriberAttempts?: Record<string, number>;
|
|
72
|
+
/** Each failing subscriber's last error. */
|
|
73
|
+
lastErrors?: Record<string, string>;
|
|
74
|
+
leaseUntilMs?: number;
|
|
75
|
+
failedAtMs?: number;
|
|
76
|
+
}
|
|
77
|
+
export declare function pluginEventBackoffMs(attempts: number): number;
|
|
78
|
+
/**
|
|
79
|
+
* The event's document id: the same raise twice is the same document. `key`
|
|
80
|
+
* names the occurrence (a fulfillment id, a refund id); without one every
|
|
81
|
+
* raise is new.
|
|
82
|
+
*/
|
|
83
|
+
export declare function pluginEventId(hostId: string, event: string, key: string): string;
|
|
84
|
+
/** A transaction or a batch: anything that can `set` a document. */
|
|
85
|
+
export interface PluginEventWriter {
|
|
86
|
+
set(ref: FirebaseFirestore.DocumentReference, data: FirebaseFirestore.DocumentData): unknown;
|
|
87
|
+
}
|
|
88
|
+
export interface RaisePluginEventRequest<Payload> {
|
|
89
|
+
event: PluginDomainEvent<Payload> | string;
|
|
90
|
+
/** The raising plugin's id. */
|
|
91
|
+
pluginId: string;
|
|
92
|
+
hostId: string;
|
|
93
|
+
orgId?: string | null;
|
|
94
|
+
payload: Payload;
|
|
95
|
+
/** The occurrence: the same key raises the same event once. */
|
|
96
|
+
key: string;
|
|
97
|
+
occurredAtMs?: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Raises an event INSIDE the caller's transaction or batch, so it is written
|
|
101
|
+
* exactly when the fact is. The write is a `set` (a transaction cannot read
|
|
102
|
+
* after it writes), so the caller's own "already done" guard is what keeps a
|
|
103
|
+
* retry from raising it twice — and the id is keyed, so if it does, the
|
|
104
|
+
* event is the same document. Returns the id.
|
|
105
|
+
*/
|
|
106
|
+
export declare function stagePluginEvent<Payload>(writer: PluginEventWriter, firestore: FirebaseFirestore.Firestore, request: RaisePluginEventRequest<Payload>, now?: number): string;
|
|
107
|
+
/**
|
|
108
|
+
* Raises an event after the fact is already written, for a raiser with no
|
|
109
|
+
* transaction to ride. `create`, so the same key raised twice — a webhook
|
|
110
|
+
* redelivered — is one event: the second answers `exists`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function raisePluginEvent<Payload>(firestore: FirebaseFirestore.Firestore, request: RaisePluginEventRequest<Payload>, now?: number): Promise<{
|
|
113
|
+
id: string;
|
|
114
|
+
outcome: 'queued' | 'exists' | 'failed';
|
|
115
|
+
}>;
|
|
116
|
+
export interface PluginEventDrainResult {
|
|
117
|
+
scanned: number;
|
|
118
|
+
delivered: number;
|
|
119
|
+
retried: number;
|
|
120
|
+
deadLettered: number;
|
|
121
|
+
skippedLocked: number;
|
|
122
|
+
skippedLeased: number;
|
|
123
|
+
}
|
|
124
|
+
type AttemptOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'leased';
|
|
125
|
+
/**
|
|
126
|
+
* Delivers one event: lease, hand it to the subscribers still owed it, and
|
|
127
|
+
* record what happened. Exported for a raiser that wants an event out now
|
|
128
|
+
* rather than on the next beat; the drain is the backstop either way.
|
|
129
|
+
*/
|
|
130
|
+
export declare function attemptPluginEvent(firestore: FirebaseFirestore.Firestore, id: string, now?: number): Promise<AttemptOutcome>;
|
|
131
|
+
/**
|
|
132
|
+
* One beat of the drain: the events owed now, oldest first. A locked site's
|
|
133
|
+
* events wait, untouched, for the lock to lift.
|
|
134
|
+
*/
|
|
135
|
+
export declare function drainPluginEvents(gate: {
|
|
136
|
+
isLocked(hostId: string): Promise<boolean>;
|
|
137
|
+
}, firestore: FirebaseFirestore.Firestore, now?: number): Promise<PluginEventDrainResult>;
|
|
138
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { deliverPluginDomainEvent } from "@aglyn/aglyn/plugin-manager/plugin-domain-events";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
/**
|
|
20
|
+
* The outbox behind plugin events (AGL-3611): raising an event is a write
|
|
21
|
+
* here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in
|
|
22
|
+
* core for the contract a raiser and a subscriber see.
|
|
23
|
+
*
|
|
24
|
+
* WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —
|
|
25
|
+
* must reach accounting, shipping and the merchant's webhooks even when one of
|
|
26
|
+
* them is down, and even when the process that made the write dies a moment
|
|
27
|
+
* later. Calling the subscribers in the request loses the event on either.
|
|
28
|
+
* Writing it here, in the raiser's own transaction where it has one, makes
|
|
29
|
+
* the event exist exactly when the fact does.
|
|
30
|
+
*
|
|
31
|
+
* AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers
|
|
32
|
+
* took the event (`delivered`), and a retry calls only the rest. A
|
|
33
|
+
* subscriber can still see an event twice — its handler succeeded and the
|
|
34
|
+
* write recording that did not — so the envelope's `id` is the key it
|
|
35
|
+
* dedupes on.
|
|
36
|
+
*
|
|
37
|
+
* ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in
|
|
38
|
+
* a transaction before it delivers, so two overlapping beats do not both
|
|
39
|
+
* deliver it. A pass that dies holding the lease is retried once the lease
|
|
40
|
+
* lapses.
|
|
41
|
+
*
|
|
42
|
+
* A delivered event is deleted: the collection holds what is still owed, so
|
|
43
|
+
* the drain's query reads nothing on an idle platform. One that runs out of
|
|
44
|
+
* attempts stays, `failed`, with each subscriber's last error.
|
|
45
|
+
*/ export const PLUGIN_EVENT_OUTBOX_COLLECTION = 'pluginEventOutbox';
|
|
46
|
+
/** Attempts per event before it is left as a dead letter. */ export const PLUGIN_EVENT_MAX_ATTEMPTS = 8;
|
|
47
|
+
/** Wait before attempt n+1, by attempts made; the last step repeats. */ export const PLUGIN_EVENT_BACKOFF_MS = [
|
|
48
|
+
30000,
|
|
49
|
+
120000,
|
|
50
|
+
600000,
|
|
51
|
+
1800000,
|
|
52
|
+
3600000,
|
|
53
|
+
21600000,
|
|
54
|
+
43200000
|
|
55
|
+
];
|
|
56
|
+
/** How long a drain pass holds an event while it delivers. */ export const PLUGIN_EVENT_LEASE_MS = 60000;
|
|
57
|
+
/** Events one pass reads. */ export const PLUGIN_EVENT_SCAN_LIMIT = 100;
|
|
58
|
+
export function pluginEventBackoffMs(attempts) {
|
|
59
|
+
const last = PLUGIN_EVENT_BACKOFF_MS.length - 1;
|
|
60
|
+
return PLUGIN_EVENT_BACKOFF_MS[Math.max(0, Math.min(attempts - 1, last))];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The event's document id: the same raise twice is the same document. `key`
|
|
64
|
+
* names the occurrence (a fulfillment id, a refund id); without one every
|
|
65
|
+
* raise is new.
|
|
66
|
+
*/ export function pluginEventId(hostId, event, key) {
|
|
67
|
+
return createHash('sha256').update(`${hostId}\u0000${event}\u0000${key}`).digest('hex').slice(0, 40);
|
|
68
|
+
}
|
|
69
|
+
/** Firestore refuses `undefined`; the payload is data, so it is plain JSON. */ function plainPayload(payload) {
|
|
70
|
+
const plain = JSON.parse(JSON.stringify(payload != null ? payload : {}));
|
|
71
|
+
return plain && typeof plain === 'object' && !Array.isArray(plain) ? plain : {
|
|
72
|
+
value: plain
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function recordFor(request, now) {
|
|
76
|
+
var _request_orgId, _request_occurredAtMs;
|
|
77
|
+
return {
|
|
78
|
+
status: 'pending',
|
|
79
|
+
event: typeof request.event === 'string' ? request.event : request.event.id,
|
|
80
|
+
pluginId: request.pluginId,
|
|
81
|
+
hostId: request.hostId,
|
|
82
|
+
orgId: (_request_orgId = request.orgId) != null ? _request_orgId : null,
|
|
83
|
+
occurredAtMs: (_request_occurredAtMs = request.occurredAtMs) != null ? _request_occurredAtMs : now,
|
|
84
|
+
payload: plainPayload(request.payload),
|
|
85
|
+
attempts: 0,
|
|
86
|
+
nextAttemptAtMs: now,
|
|
87
|
+
createdAtMs: now
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Raises an event INSIDE the caller's transaction or batch, so it is written
|
|
92
|
+
* exactly when the fact is. The write is a `set` (a transaction cannot read
|
|
93
|
+
* after it writes), so the caller's own "already done" guard is what keeps a
|
|
94
|
+
* retry from raising it twice — and the id is keyed, so if it does, the
|
|
95
|
+
* event is the same document. Returns the id.
|
|
96
|
+
*/ export function stagePluginEvent(writer, firestore, request, now = Date.now()) {
|
|
97
|
+
const record = recordFor(request, now);
|
|
98
|
+
const id = pluginEventId(request.hostId, record.event, request.key);
|
|
99
|
+
writer.set(firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id), record);
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
const GRPC_ALREADY_EXISTS = 6;
|
|
103
|
+
/**
|
|
104
|
+
* Raises an event after the fact is already written, for a raiser with no
|
|
105
|
+
* transaction to ride. `create`, so the same key raised twice — a webhook
|
|
106
|
+
* redelivered — is one event: the second answers `exists`.
|
|
107
|
+
*/ export async function raisePluginEvent(firestore, request, now = Date.now()) {
|
|
108
|
+
const record = recordFor(request, now);
|
|
109
|
+
const id = pluginEventId(request.hostId, record.event, request.key);
|
|
110
|
+
try {
|
|
111
|
+
await firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id).create(record);
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
outcome: 'queued'
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if ((error == null ? void 0 : error.code) === GRPC_ALREADY_EXISTS) return {
|
|
118
|
+
id,
|
|
119
|
+
outcome: 'exists'
|
|
120
|
+
};
|
|
121
|
+
console.error('[plugin-events] not queued', record.event, request.hostId, error);
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
outcome: 'failed'
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Delivers one event: lease, hand it to the subscribers still owed it, and
|
|
130
|
+
* record what happened. Exported for a raiser that wants an event out now
|
|
131
|
+
* rather than on the next beat; the drain is the backstop either way.
|
|
132
|
+
*/ export async function attemptPluginEvent(firestore, id, now = Date.now()) {
|
|
133
|
+
var _leased_delivered, _leased_subscriberAttempts, _leased_orgId, _leased_attempts, _leased_lastErrors;
|
|
134
|
+
const ref = firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id);
|
|
135
|
+
const leased = await firestore.runTransaction(async (transaction)=>{
|
|
136
|
+
var _data_leaseUntilMs, _data_nextAttemptAtMs;
|
|
137
|
+
const snapshot = await transaction.get(ref);
|
|
138
|
+
if (!snapshot.exists) return null;
|
|
139
|
+
const data = snapshot.data();
|
|
140
|
+
if (data.status !== 'pending') return null;
|
|
141
|
+
if (Number((_data_leaseUntilMs = data.leaseUntilMs) != null ? _data_leaseUntilMs : 0) > now) return null;
|
|
142
|
+
if (Number((_data_nextAttemptAtMs = data.nextAttemptAtMs) != null ? _data_nextAttemptAtMs : 0) > now) return null;
|
|
143
|
+
transaction.update(ref, {
|
|
144
|
+
leaseUntilMs: now + PLUGIN_EVENT_LEASE_MS
|
|
145
|
+
});
|
|
146
|
+
return data;
|
|
147
|
+
});
|
|
148
|
+
if (!leased) return 'leased';
|
|
149
|
+
const delivered = _extends({}, (_leased_delivered = leased.delivered) != null ? _leased_delivered : {});
|
|
150
|
+
const subscriberAttempts = _extends({}, (_leased_subscriberAttempts = leased.subscriberAttempts) != null ? _leased_subscriberAttempts : {});
|
|
151
|
+
const result = await deliverPluginDomainEvent({
|
|
152
|
+
id,
|
|
153
|
+
event: leased.event,
|
|
154
|
+
hostId: leased.hostId,
|
|
155
|
+
orgId: (_leased_orgId = leased.orgId) != null ? _leased_orgId : null,
|
|
156
|
+
occurredAtMs: leased.occurredAtMs,
|
|
157
|
+
payload: leased.payload
|
|
158
|
+
}, {
|
|
159
|
+
skip: new Set(Object.keys(delivered)),
|
|
160
|
+
attempts: subscriberAttempts
|
|
161
|
+
});
|
|
162
|
+
for (const pluginId of result.delivered){
|
|
163
|
+
var _subscriberAttempts_pluginId;
|
|
164
|
+
delivered[pluginId] = now;
|
|
165
|
+
subscriberAttempts[pluginId] = ((_subscriberAttempts_pluginId = subscriberAttempts[pluginId]) != null ? _subscriberAttempts_pluginId : 0) + 1;
|
|
166
|
+
}
|
|
167
|
+
for (const { pluginId } of result.failed){
|
|
168
|
+
var _subscriberAttempts_pluginId1;
|
|
169
|
+
subscriberAttempts[pluginId] = ((_subscriberAttempts_pluginId1 = subscriberAttempts[pluginId]) != null ? _subscriberAttempts_pluginId1 : 0) + 1;
|
|
170
|
+
}
|
|
171
|
+
if (result.failed.length === 0) {
|
|
172
|
+
await ref.delete();
|
|
173
|
+
return 'delivered';
|
|
174
|
+
}
|
|
175
|
+
const attempts = Number((_leased_attempts = leased.attempts) != null ? _leased_attempts : 0) + 1;
|
|
176
|
+
const exhausted = attempts >= PLUGIN_EVENT_MAX_ATTEMPTS;
|
|
177
|
+
const lastErrors = _extends({}, (_leased_lastErrors = leased.lastErrors) != null ? _leased_lastErrors : {});
|
|
178
|
+
for (const { pluginId, error } of result.failed)lastErrors[pluginId] = error;
|
|
179
|
+
await ref.update(_extends({
|
|
180
|
+
status: exhausted ? 'failed' : 'pending',
|
|
181
|
+
attempts,
|
|
182
|
+
delivered,
|
|
183
|
+
subscriberAttempts,
|
|
184
|
+
lastErrors,
|
|
185
|
+
leaseUntilMs: 0
|
|
186
|
+
}, exhausted ? {
|
|
187
|
+
failedAtMs: now
|
|
188
|
+
} : {
|
|
189
|
+
nextAttemptAtMs: now + pluginEventBackoffMs(attempts)
|
|
190
|
+
}));
|
|
191
|
+
if (exhausted) {
|
|
192
|
+
console.error(`[plugin-events] ${leased.event} ${id} on ${leased.hostId} gave up after ${attempts} attempts:`, lastErrors);
|
|
193
|
+
return 'dead-lettered';
|
|
194
|
+
}
|
|
195
|
+
return 'retried';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One beat of the drain: the events owed now, oldest first. A locked site's
|
|
199
|
+
* events wait, untouched, for the lock to lift.
|
|
200
|
+
*/ export async function drainPluginEvents(gate, firestore, now = Date.now()) {
|
|
201
|
+
const due = await firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).where('status', '==', 'pending').where('nextAttemptAtMs', '<=', now).orderBy('nextAttemptAtMs').limit(PLUGIN_EVENT_SCAN_LIMIT).get();
|
|
202
|
+
const result = {
|
|
203
|
+
scanned: due.size,
|
|
204
|
+
delivered: 0,
|
|
205
|
+
retried: 0,
|
|
206
|
+
deadLettered: 0,
|
|
207
|
+
skippedLocked: 0,
|
|
208
|
+
skippedLeased: 0
|
|
209
|
+
};
|
|
210
|
+
for (const snapshot of due.docs){
|
|
211
|
+
var _snapshot_get;
|
|
212
|
+
const hostId = String((_snapshot_get = snapshot.get('hostId')) != null ? _snapshot_get : '');
|
|
213
|
+
if (await gate.isLocked(hostId)) {
|
|
214
|
+
result.skippedLocked += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const outcome = await attemptPluginEvent(firestore, snapshot.id, now).catch((error)=>{
|
|
218
|
+
console.error('[plugin-events] attempt failed', snapshot.id, error);
|
|
219
|
+
return null;
|
|
220
|
+
});
|
|
221
|
+
if (outcome === 'delivered') result.delivered += 1;
|
|
222
|
+
else if (outcome === 'retried') result.retried += 1;
|
|
223
|
+
else if (outcome === 'dead-lettered') result.deadLettered += 1;
|
|
224
|
+
else if (outcome === 'leased') result.skippedLeased += 1;
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
//# sourceMappingURL=plugin-event-outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/plugin-event-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n deliverPluginDomainEvent,\n type PluginDomainEvent,\n} from '@aglyn/aglyn/plugin-manager/plugin-domain-events'\nimport { createHash } from 'crypto'\n\n/**\n * The outbox behind plugin events (AGL-3611): raising an event is a write\n * here, and a scheduled drain delivers it. See `plugin-domain-events.ts` in\n * core for the contract a raiser and a subscriber see.\n *\n * WHY AN OUTBOX. A fact a plugin reports — an order paid, a return refunded —\n * must reach accounting, shipping and the merchant's webhooks even when one of\n * them is down, and even when the process that made the write dies a moment\n * later. Calling the subscribers in the request loses the event on either.\n * Writing it here, in the raiser's own transaction where it has one, makes\n * the event exist exactly when the fact does.\n *\n * AT LEAST ONCE, PER SUBSCRIBER. The document records which subscribers\n * took the event (`delivered`), and a retry calls only the rest. A\n * subscriber can still see an event twice — its handler succeeded and the\n * write recording that did not — so the envelope's `id` is the key it\n * dedupes on.\n *\n * ONE DRAIN AT A TIME PER EVENT. A pass leases the document for a minute in\n * a transaction before it delivers, so two overlapping beats do not both\n * deliver it. A pass that dies holding the lease is retried once the lease\n * lapses.\n *\n * A delivered event is deleted: the collection holds what is still owed, so\n * the drain's query reads nothing on an idle platform. One that runs out of\n * attempts stays, `failed`, with each subscriber's last error.\n */\n\nexport const PLUGIN_EVENT_OUTBOX_COLLECTION = 'pluginEventOutbox'\n\n/** Attempts per event before it is left as a dead letter. */\nexport const PLUGIN_EVENT_MAX_ATTEMPTS = 8\n\n/** Wait before attempt n+1, by attempts made; the last step repeats. */\nexport const PLUGIN_EVENT_BACKOFF_MS: readonly number[] = [\n 30_000,\n 120_000,\n 600_000,\n 1_800_000,\n 3_600_000,\n 21_600_000,\n 43_200_000,\n]\n\n/** How long a drain pass holds an event while it delivers. */\nexport const PLUGIN_EVENT_LEASE_MS = 60_000\n\n/** Events one pass reads. */\nexport const PLUGIN_EVENT_SCAN_LIMIT = 100\n\nexport type PluginEventOutboxStatus = 'pending' | 'failed'\n\n/** `pluginEventOutbox/{id}`. Written and read only by the Admin SDK. */\nexport interface PluginEventOutboxRecord {\n status: PluginEventOutboxStatus\n event: string\n /** The plugin that raised it. */\n pluginId: string\n hostId: string\n orgId: string | null\n occurredAtMs: number\n payload: Record<string, unknown>\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n /** Subscribers that took it, by plugin id, when. */\n delivered?: Record<string, number>\n /** Attempts per subscriber, for the envelope's `attempt`. */\n subscriberAttempts?: Record<string, number>\n /** Each failing subscriber's last error. */\n lastErrors?: Record<string, string>\n leaseUntilMs?: number\n failedAtMs?: number\n}\n\nexport function pluginEventBackoffMs(attempts: number): number {\n const last = PLUGIN_EVENT_BACKOFF_MS.length - 1\n return PLUGIN_EVENT_BACKOFF_MS[Math.max(0, Math.min(attempts - 1, last))]\n}\n\n/**\n * The event's document id: the same raise twice is the same document. `key`\n * names the occurrence (a fulfillment id, a refund id); without one every\n * raise is new.\n */\nexport function pluginEventId(hostId: string, event: string, key: string): string {\n return createHash('sha256').update(`${hostId}\\u0000${event}\\u0000${key}`).digest('hex').slice(0, 40)\n}\n\n/** A transaction or a batch: anything that can `set` a document. */\nexport interface PluginEventWriter {\n set(ref: FirebaseFirestore.DocumentReference, data: FirebaseFirestore.DocumentData): unknown\n}\n\nexport interface RaisePluginEventRequest<Payload> {\n event: PluginDomainEvent<Payload> | string\n /** The raising plugin's id. */\n pluginId: string\n hostId: string\n orgId?: string | null\n payload: Payload\n /** The occurrence: the same key raises the same event once. */\n key: string\n occurredAtMs?: number\n}\n\n/** Firestore refuses `undefined`; the payload is data, so it is plain JSON. */\nfunction plainPayload(payload: unknown): Record<string, unknown> {\n const plain = JSON.parse(JSON.stringify(payload ?? {}))\n return plain && typeof plain === 'object' && !Array.isArray(plain) ? plain : { value: plain }\n}\n\nfunction recordFor<Payload>(request: RaisePluginEventRequest<Payload>, now: number): PluginEventOutboxRecord {\n return {\n status: 'pending',\n event: typeof request.event === 'string' ? request.event : request.event.id,\n pluginId: request.pluginId,\n hostId: request.hostId,\n orgId: request.orgId ?? null,\n occurredAtMs: request.occurredAtMs ?? now,\n payload: plainPayload(request.payload),\n attempts: 0,\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n}\n\n/**\n * Raises an event INSIDE the caller's transaction or batch, so it is written\n * exactly when the fact is. The write is a `set` (a transaction cannot read\n * after it writes), so the caller's own \"already done\" guard is what keeps a\n * retry from raising it twice — and the id is keyed, so if it does, the\n * event is the same document. Returns the id.\n */\nexport function stagePluginEvent<Payload>(\n writer: PluginEventWriter,\n firestore: FirebaseFirestore.Firestore,\n request: RaisePluginEventRequest<Payload>,\n now = Date.now(),\n): string {\n const record = recordFor(request, now)\n const id = pluginEventId(request.hostId, record.event, request.key)\n writer.set(\n firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id),\n record as unknown as FirebaseFirestore.DocumentData,\n )\n return id\n}\n\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * Raises an event after the fact is already written, for a raiser with no\n * transaction to ride. `create`, so the same key raised twice — a webhook\n * redelivered — is one event: the second answers `exists`.\n */\nexport async function raisePluginEvent<Payload>(\n firestore: FirebaseFirestore.Firestore,\n request: RaisePluginEventRequest<Payload>,\n now = Date.now(),\n): Promise<{ id: string; outcome: 'queued' | 'exists' | 'failed' }> {\n const record = recordFor(request, now)\n const id = pluginEventId(request.hostId, record.event, request.key)\n try {\n await firestore\n .collection(PLUGIN_EVENT_OUTBOX_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return { id, outcome: 'queued' }\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) return { id, outcome: 'exists' }\n console.error('[plugin-events] not queued', record.event, request.hostId, error)\n return { id, outcome: 'failed' }\n }\n}\n\nexport interface PluginEventDrainResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n skippedLocked: number\n skippedLeased: number\n}\n\ntype AttemptOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'leased'\n\n/**\n * Delivers one event: lease, hand it to the subscribers still owed it, and\n * record what happened. Exported for a raiser that wants an event out now\n * rather than on the next beat; the drain is the backstop either way.\n */\nexport async function attemptPluginEvent(\n firestore: FirebaseFirestore.Firestore,\n id: string,\n now = Date.now(),\n): Promise<AttemptOutcome> {\n const ref = firestore.collection(PLUGIN_EVENT_OUTBOX_COLLECTION).doc(id)\n const leased = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists) return null\n const data = snapshot.data() as PluginEventOutboxRecord\n if (data.status !== 'pending') return null\n if (Number(data.leaseUntilMs ?? 0) > now) return null\n if (Number(data.nextAttemptAtMs ?? 0) > now) return null\n transaction.update(ref, { leaseUntilMs: now + PLUGIN_EVENT_LEASE_MS })\n return data\n })\n if (!leased) return 'leased'\n\n const delivered = { ...(leased.delivered ?? {}) }\n const subscriberAttempts = { ...(leased.subscriberAttempts ?? {}) }\n const result = await deliverPluginDomainEvent(\n {\n id,\n event: leased.event,\n hostId: leased.hostId,\n orgId: leased.orgId ?? null,\n occurredAtMs: leased.occurredAtMs,\n payload: leased.payload,\n },\n { skip: new Set(Object.keys(delivered)), attempts: subscriberAttempts },\n )\n for (const pluginId of result.delivered) {\n delivered[pluginId] = now\n subscriberAttempts[pluginId] = (subscriberAttempts[pluginId] ?? 0) + 1\n }\n for (const { pluginId } of result.failed) {\n subscriberAttempts[pluginId] = (subscriberAttempts[pluginId] ?? 0) + 1\n }\n\n if (result.failed.length === 0) {\n await ref.delete()\n return 'delivered'\n }\n const attempts = Number(leased.attempts ?? 0) + 1\n const exhausted = attempts >= PLUGIN_EVENT_MAX_ATTEMPTS\n const lastErrors = { ...(leased.lastErrors ?? {}) }\n for (const { pluginId, error } of result.failed) lastErrors[pluginId] = error\n await ref.update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n delivered,\n subscriberAttempts,\n lastErrors,\n leaseUntilMs: 0,\n ...(exhausted ? { failedAtMs: now } : { nextAttemptAtMs: now + pluginEventBackoffMs(attempts) }),\n })\n if (exhausted) {\n console.error(\n `[plugin-events] ${leased.event} ${id} on ${leased.hostId} gave up after ${attempts} attempts:`,\n lastErrors,\n )\n return 'dead-lettered'\n }\n return 'retried'\n}\n\n/**\n * One beat of the drain: the events owed now, oldest first. A locked site's\n * events wait, untouched, for the lock to lift.\n */\nexport async function drainPluginEvents(\n gate: { isLocked(hostId: string): Promise<boolean> },\n firestore: FirebaseFirestore.Firestore,\n now = Date.now(),\n): Promise<PluginEventDrainResult> {\n const due = await firestore\n .collection(PLUGIN_EVENT_OUTBOX_COLLECTION)\n .where('status', '==', 'pending')\n .where('nextAttemptAtMs', '<=', now)\n .orderBy('nextAttemptAtMs')\n .limit(PLUGIN_EVENT_SCAN_LIMIT)\n .get()\n const result: PluginEventDrainResult = {\n scanned: due.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n skippedLocked: 0,\n skippedLeased: 0,\n }\n for (const snapshot of due.docs) {\n const hostId = String(snapshot.get('hostId') ?? '')\n if (await gate.isLocked(hostId)) {\n result.skippedLocked += 1\n continue\n }\n const outcome = await attemptPluginEvent(firestore, snapshot.id, now).catch((error) => {\n console.error('[plugin-events] attempt failed', snapshot.id, error)\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'leased') result.skippedLeased += 1\n }\n return result\n}\n"],"names":["deliverPluginDomainEvent","createHash","PLUGIN_EVENT_OUTBOX_COLLECTION","PLUGIN_EVENT_MAX_ATTEMPTS","PLUGIN_EVENT_BACKOFF_MS","PLUGIN_EVENT_LEASE_MS","PLUGIN_EVENT_SCAN_LIMIT","pluginEventBackoffMs","attempts","last","length","Math","max","min","pluginEventId","hostId","event","key","update","digest","slice","plainPayload","payload","plain","JSON","parse","stringify","Array","isArray","value","recordFor","request","now","status","id","pluginId","orgId","occurredAtMs","nextAttemptAtMs","createdAtMs","stagePluginEvent","writer","firestore","Date","record","set","collection","doc","GRPC_ALREADY_EXISTS","raisePluginEvent","create","outcome","error","code","console","attemptPluginEvent","leased","ref","runTransaction","transaction","data","snapshot","get","exists","Number","leaseUntilMs","delivered","subscriberAttempts","result","skip","Set","Object","keys","failed","delete","exhausted","lastErrors","failedAtMs","drainPluginEvents","gate","due","where","orderBy","limit","scanned","size","retried","deadLettered","skippedLocked","skippedLeased","docs","String","isLocked","catch"],"mappings":"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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to
|
|
19
|
+
* `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development
|
|
20
|
+
* build's token), authenticated with a provider token, an ES256 JWT signed
|
|
21
|
+
* with the team's APNs key.
|
|
22
|
+
*
|
|
23
|
+
* APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded
|
|
24
|
+
* APNS_KEY_ID the key's 10-character id
|
|
25
|
+
* APNS_TEAM_ID the Apple Developer team id
|
|
26
|
+
*
|
|
27
|
+
* Without all three the transport is off: a device on APNs is skipped and
|
|
28
|
+
* that is logged once per process. A provider token is reused for up to 50
|
|
29
|
+
* minutes (Apple refuses one older than an hour, and one refreshed more often
|
|
30
|
+
* than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the
|
|
31
|
+
* token is gone, and the row is pruned.
|
|
32
|
+
*
|
|
33
|
+
* Server-only, imported by `push-delivery.ts` only when a device is on APNs.
|
|
34
|
+
*/
|
|
35
|
+
import { type KeyObject } from 'node:crypto';
|
|
36
|
+
import type { PushMessage, PushOutcome, PushTransport } from './push-delivery';
|
|
37
|
+
export declare const APNS_HOSTS: {
|
|
38
|
+
readonly production: "https://api.push.apple.com";
|
|
39
|
+
readonly sandbox: "https://api.sandbox.push.apple.com";
|
|
40
|
+
};
|
|
41
|
+
/** A provider token is reused for at most this long. */
|
|
42
|
+
export declare const APNS_TOKEN_TTL_MS: number;
|
|
43
|
+
export interface ApnsCredentials {
|
|
44
|
+
key: KeyObject;
|
|
45
|
+
keyId: string;
|
|
46
|
+
teamId: string;
|
|
47
|
+
}
|
|
48
|
+
export interface ApnsResponse {
|
|
49
|
+
status: number;
|
|
50
|
+
body: string;
|
|
51
|
+
}
|
|
52
|
+
/** One HTTP/2 POST to an APNs origin; injectable for tests. */
|
|
53
|
+
export type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>;
|
|
54
|
+
/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */
|
|
55
|
+
export declare function parseApnsKey(raw: string): KeyObject;
|
|
56
|
+
export declare function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null;
|
|
57
|
+
/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */
|
|
58
|
+
export declare function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string;
|
|
59
|
+
/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */
|
|
60
|
+
export declare function apnsPayload(message: PushMessage): string;
|
|
61
|
+
export declare function apnsOutcome(response: ApnsResponse): PushOutcome;
|
|
62
|
+
export interface ApnsTransportOptions {
|
|
63
|
+
credentials: ApnsCredentials;
|
|
64
|
+
/** Injected in tests; the default opens HTTP/2 sessions per send. */
|
|
65
|
+
request?: ApnsRequest;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
}
|
|
68
|
+
export declare function createApnsTransport(options: ApnsTransportOptions): PushTransport;
|
|
69
|
+
/**
|
|
70
|
+
* The deployment's APNs transport from `APNS_*`, or null — logged once —
|
|
71
|
+
* when they are not set.
|
|
72
|
+
*/
|
|
73
|
+
export declare function apnsTransport(env?: Record<string, string | undefined>): PushTransport | null;
|