Files
tech-log-frontend/src/contracts/offline-command.ts
T

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]),
});
}