155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
/**
|
|
* §19. Offline Command and Background Sync contract.
|
|
*
|
|
* The product capability is `NOT_SELECTED` (§19.1). An operation only becomes
|
|
* queueable when the external package contribution provides `KEYED` retry
|
|
* semantics plus a non-null recovery descriptor (§19.3); the frontend never
|
|
* defines server idempotency or an inspect protocol of its own.
|
|
*/
|
|
|
|
export const OFFLINE_COMMAND_BOUNDS = Object.freeze({
|
|
operations: 64,
|
|
defaultRequestBytes: 262_144,
|
|
hardRequestBytes: 1_048_576,
|
|
records: 1_000,
|
|
datasetBytes: 50 * 1024 * 1024,
|
|
senderLeaseMs: 30_000,
|
|
leaseRenewMs: 10_000,
|
|
batchCount: 10,
|
|
batchWindowMs: 30_000,
|
|
parallelSend: 1,
|
|
retryBaseMs: 1_000,
|
|
retryMaxMs: 300_000,
|
|
attemptCap: 10,
|
|
ordinaryRetentionMs: 7 * 24 * 60 * 60 * 1_000,
|
|
conflictRetentionMs: 30 * 24 * 60 * 60 * 1_000,
|
|
ackedSummaryRetentionMs: 24 * 60 * 60 * 1_000,
|
|
syncReregisterMinimumMs: 60_000,
|
|
});
|
|
|
|
/** §19.18. Background Sync is a wake-up hint only; it never sends a command. */
|
|
export const OFFLINE_SYNC_TAG = "ca-outbox-v1" as const;
|
|
|
|
export interface InstalledOfflineOperation {
|
|
readonly operationId: string;
|
|
readonly contractPackageId: string;
|
|
readonly maximumRequestBytes: number;
|
|
readonly retentionClass: "STANDARD_7D";
|
|
}
|
|
|
|
export interface InstalledOfflineCommandContribution {
|
|
readonly datasetId: "OFFLINE_COMMANDS_V1";
|
|
readonly operations: readonly InstalledOfflineOperation[];
|
|
}
|
|
|
|
export type OfflineCommandState =
|
|
| "PENDING"
|
|
| "LEASED"
|
|
| "FOREGROUND_REQUIRED"
|
|
| "SENDING"
|
|
| "RETRY_WAIT"
|
|
| "ACKED"
|
|
| "CONFLICT"
|
|
| "EFFECT_UNKNOWN"
|
|
| "EXPIRED";
|
|
|
|
export interface OfflineCommandRecordV1 {
|
|
readonly recordVersion: 1;
|
|
readonly commandId: string;
|
|
readonly operationId: string;
|
|
readonly contractPackageId: string;
|
|
readonly contractPackageVersion: string;
|
|
readonly contractPackageDigest: `sha256:${string}`;
|
|
readonly scopePartition: string;
|
|
readonly requestDigest: `sha256:${string}`;
|
|
readonly requestPayload: Uint8Array;
|
|
readonly idempotencyKey: string;
|
|
readonly state: OfflineCommandState;
|
|
readonly attempt: number;
|
|
readonly createdAt: string;
|
|
readonly updatedAt: string;
|
|
readonly nextAttemptAt?: string;
|
|
readonly leaseOwner?: string;
|
|
readonly leaseExpiresAt?: string;
|
|
readonly terminalCode?: string;
|
|
}
|
|
|
|
/**
|
|
* §19.10. Anything not listed is corruption. In particular an expired
|
|
* `SENDING` record is never reset to `PENDING`: it becomes `EFFECT_UNKNOWN`.
|
|
*/
|
|
const ALLOWED_TRANSITIONS = Object.freeze({
|
|
PENDING: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]),
|
|
LEASED: Object.freeze(["SENDING", "PENDING"]),
|
|
SENDING: Object.freeze([
|
|
"ACKED",
|
|
"RETRY_WAIT",
|
|
"CONFLICT",
|
|
"EFFECT_UNKNOWN",
|
|
]),
|
|
RETRY_WAIT: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]),
|
|
FOREGROUND_REQUIRED: Object.freeze(["LEASED", "EXPIRED", "PENDING"]),
|
|
ACKED: Object.freeze([]),
|
|
CONFLICT: Object.freeze([]),
|
|
EFFECT_UNKNOWN: Object.freeze(["ACKED", "PENDING"]),
|
|
EXPIRED: Object.freeze([]),
|
|
} satisfies Readonly<Record<OfflineCommandState, readonly OfflineCommandState[]>>);
|
|
|
|
export function isAllowedOfflineTransition(
|
|
from: OfflineCommandState,
|
|
to: OfflineCommandState,
|
|
): boolean {
|
|
const allowed: readonly OfflineCommandState[] = ALLOWED_TRANSITIONS[from];
|
|
return allowed.includes(to);
|
|
}
|
|
|
|
/** §19.21. Nothing sensitive reaches the UI: no payload, key, digest or partition. */
|
|
export interface OfflineCommandSummary {
|
|
readonly commandId: string;
|
|
readonly operationLabelKey: string;
|
|
readonly state: OfflineCommandState;
|
|
readonly createdAt: string;
|
|
readonly nextAction:
|
|
| "WAIT"
|
|
| "OPEN_APP"
|
|
| "CHECK_STATUS"
|
|
| "RESOLVE_CONFLICT"
|
|
| "CONTACT_SUPPORT"
|
|
| "DISMISS";
|
|
}
|
|
|
|
export function validateOfflineCommandContribution(
|
|
contribution: InstalledOfflineCommandContribution,
|
|
): InstalledOfflineCommandContribution {
|
|
if (contribution.datasetId !== "OFFLINE_COMMANDS_V1") {
|
|
throw new TypeError("Offline command dataset identity is invalid.");
|
|
}
|
|
if (
|
|
contribution.operations.length === 0 ||
|
|
contribution.operations.length > OFFLINE_COMMAND_BOUNDS.operations
|
|
) {
|
|
throw new TypeError("Offline command operation count is out of range.");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const operation of contribution.operations) {
|
|
if (!operation.operationId || seen.has(operation.operationId)) {
|
|
throw new TypeError("Duplicate offline command operation.");
|
|
}
|
|
seen.add(operation.operationId);
|
|
if (
|
|
!Number.isSafeInteger(operation.maximumRequestBytes) ||
|
|
operation.maximumRequestBytes < 1 ||
|
|
operation.maximumRequestBytes > OFFLINE_COMMAND_BOUNDS.hardRequestBytes ||
|
|
operation.retentionClass !== "STANDARD_7D"
|
|
) {
|
|
throw new TypeError(
|
|
`Offline command operation bounds invalid: ${operation.operationId}`,
|
|
);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
datasetId: contribution.datasetId,
|
|
operations: Object.freeze([...contribution.operations]),
|
|
});
|
|
}
|