Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# `schemas/schemas.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas guarding the package's two persisted boundaries: the sync-queue entry
and the Web App Manifest. Every value crossing into IndexedDB / LocalStorage / a
`Blob` payload is parsed through these schemas so corrupt or attacker-controlled
data cannot survive a round-trip. The schemas also serve as the single source of
truth for the inferred TypeScript types consumed throughout the package.
## Features
- `SyncQueueEntrySchema` — full structural validation including a strict
`schemaVersion` literal so older entries are forced through `VersionMigration`
before they validate.
- `WebAppManifestSchema` — covers the spec-required surface (`name`, `icons`,
`display`, `orientation`, `start_url`, `scope`, theme + background colour,
`lang`, `dir`, shortcuts, related apps, share target).
- `JsonValueSchema` — recursive JSON-safe value validator that rejects functions,
symbols, and undefined so `metadata` never carries non-serialisable shapes.
- Inferred types (`WebAppManifest`, `ManifestIcon`, `ManifestShortcut`,
`RelatedApplication`, `ShareTarget`) for direct downstream use.
## Usage: Basic
```ts
import { SyncQueueEntrySchema, SYNC_QUEUE_SCHEMA_VERSION } from "@suaveplan/pwa";
const parsed = SyncQueueEntrySchema.parse({
id: "sq_1",
url: "/api/v1/orders",
method: "POST",
createdAt: Date.now(),
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
});
```
## Usage: Advanced
Use `safeParse` to surface the validation error before deciding whether to drop
the entry or migrate it:
```ts
import { SyncQueueEntrySchema } from "@suaveplan/pwa";
const result = SyncQueueEntrySchema.safeParse(loadedRow);
if (!result.success) {
metrics.queueValidationFailures.add(1, {
reason: result.error.issues[0]?.code ?? "unknown",
});
}
```
## API Reference
- `SyncQueueEntrySchema` — Zod schema for `SyncQueueEntry`.
- `JsonValueSchema` — recursive JSON-safe value validator.
- `ManifestIconSchema`, `ManifestShortcutSchema`, `RelatedApplicationSchema`,
`ShareTargetSchema` — manifest sub-schemas.
- `WebAppManifestSchema` — top-level manifest schema.
- Inferred type aliases: `ManifestIcon`, `ManifestShortcut`,
`RelatedApplication`, `ShareTarget`, `WebAppManifest`.
## Implementation Notes
`JsonValueSchema` uses `z.lazy(...)` so it can recurse through arbitrary JSON-like
structures without blowing the Zod type-inference stack. The recursion intentionally
forbids `undefined` (an allowed `JSON.stringify` no-op value) because IndexedDB
silently drops `undefined` keys, which would mask data loss between enqueue and
process. The manifest schema requires `name` and at least one icon, matching the
two fields the W3C spec calls out as `MUST` for installability. Optional fields
match the upstream specification field names (`short_name`, `start_url`, etc.) so
the builder can output JSON directly without renaming.

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
import {
JsonValueSchema,
SyncQueueEntrySchema,
WebAppManifestSchema,
} from "./schemas";
describe("SyncQueueEntrySchema", () => {
const valid = {
id: "sq_1",
url: "/api/v1/orders",
method: "POST",
createdAt: 1_700_000_000_000,
retryCount: 0,
maxRetries: 3,
schemaVersion: SYNC_QUEUE_SCHEMA_VERSION,
};
it("accepts a fully-populated entry", { timeout: 5000 }, () => {
const parsed = SyncQueueEntrySchema.parse({
...valid,
headers: { "x-trace": "abc" },
body: '{"a":1}',
metadata: { tenant: "acme", attempts: 2, flags: [true, "x"] },
});
expect(parsed.id).toBe("sq_1");
expect(parsed.metadata?.tenant).toBe("acme");
});
it("rejects entries with the wrong schemaVersion", {
timeout: 5000,
}, () => {
expect(() =>
SyncQueueEntrySchema.parse({ ...valid, schemaVersion: 1 })
).toThrow();
});
it("rejects negative retryCount", { timeout: 5000 }, () => {
expect(() =>
SyncQueueEntrySchema.parse({ ...valid, retryCount: -1 })
).toThrow();
});
it("rejects metadata containing non-JSON values", { timeout: 5000 }, () => {
expect(() =>
SyncQueueEntrySchema.parse({
...valid,
metadata: { fn: () => 1 },
})
).toThrow();
});
});
describe("JsonValueSchema", () => {
it("accepts nested JSON values", { timeout: 5000 }, () => {
expect(() =>
JsonValueSchema.parse({
a: [1, "x", true, null, { b: [false, "y"] }],
})
).not.toThrow();
});
it("rejects functions", { timeout: 5000 }, () => {
expect(() => JsonValueSchema.parse(() => 1)).toThrow();
});
});
describe("WebAppManifestSchema", () => {
it("accepts a minimal valid manifest", { timeout: 5000 }, () => {
const parsed = WebAppManifestSchema.parse({
name: "Suaveplan",
icons: [{ src: "/icon.png", sizes: "512x512" }],
});
expect(parsed.name).toBe("Suaveplan");
});
it("rejects manifests without icons", { timeout: 5000 }, () => {
expect(() =>
WebAppManifestSchema.parse({ name: "X", icons: [] })
).toThrow();
});
it("rejects manifests with invalid display", { timeout: 5000 }, () => {
expect(() =>
WebAppManifestSchema.parse({
name: "X",
icons: [{ src: "/i.png", sizes: "1x1" }],
display: "bogus",
})
).toThrow();
});
});

View File

@@ -0,0 +1,109 @@
import z from "zod";
import { SYNC_QUEUE_SCHEMA_VERSION } from "../constants/index";
const JSON_VALUE_LITERAL = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
/** Recursive JSON-safe value. Rejects functions, symbols, undefined. */
export const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
JSON_VALUE_LITERAL,
z.array(JsonValueSchema),
z.record(z.string(), JsonValueSchema),
])
);
/** Schema for a single sync-queue entry at the current schema version. */
export const SyncQueueEntrySchema = z.object({
id: z.string().min(1),
url: z.string().min(1),
method: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
createdAt: z.number().int().nonnegative(),
retryCount: z.number().int().nonnegative(),
maxRetries: z.number().int().nonnegative(),
metadata: z.record(z.string(), JsonValueSchema).optional(),
schemaVersion: z.literal(SYNC_QUEUE_SCHEMA_VERSION),
});
/** Schema for a manifest icon. */
export const ManifestIconSchema = z.object({
src: z.string().min(1),
sizes: z.string().min(1),
type: z.string().min(1).optional(),
purpose: z.string().min(1).optional(),
});
/** Schema for a manifest shortcut. */
export const ManifestShortcutSchema = z.object({
name: z.string().min(1),
shortName: z.string().min(1).optional(),
description: z.string().min(1).optional(),
url: z.string().min(1),
icons: z.array(ManifestIconSchema).optional(),
});
/** Schema for a related application entry. */
export const RelatedApplicationSchema = z.object({
platform: z.string().min(1),
url: z.string().min(1).optional(),
id: z.string().min(1).optional(),
});
/** Schema for a share-target descriptor. */
export const ShareTargetSchema = z.object({
action: z.string().min(1),
method: z.enum(["GET", "POST"]).optional(),
enctype: z.string().min(1).optional(),
params: z
.object({
title: z.string().optional(),
text: z.string().optional(),
url: z.string().optional(),
})
.optional(),
});
/** Schema for a Web App Manifest. */
export const WebAppManifestSchema = z.object({
name: z.string().min(1),
short_name: z.string().min(1).optional(),
description: z.string().min(1).optional(),
start_url: z.string().min(1).optional(),
scope: z.string().min(1).optional(),
display: z
.enum(["fullscreen", "standalone", "minimal-ui", "browser"])
.optional(),
orientation: z
.enum([
"any",
"natural",
"landscape",
"portrait",
"portrait-primary",
"portrait-secondary",
"landscape-primary",
"landscape-secondary",
])
.optional(),
background_color: z.string().min(1).optional(),
theme_color: z.string().min(1).optional(),
lang: z.string().min(1).optional(),
dir: z.enum(["ltr", "rtl", "auto"]).optional(),
icons: z.array(ManifestIconSchema).min(1),
shortcuts: z.array(ManifestShortcutSchema).optional(),
related_applications: z.array(RelatedApplicationSchema).optional(),
prefer_related_applications: z.boolean().optional(),
share_target: ShareTargetSchema.optional(),
});
export type ManifestIcon = z.infer<typeof ManifestIconSchema>;
export type ManifestShortcut = z.infer<typeof ManifestShortcutSchema>;
export type RelatedApplication = z.infer<typeof RelatedApplicationSchema>;
export type ShareTarget = z.infer<typeof ShareTargetSchema>;
export type WebAppManifest = z.infer<typeof WebAppManifestSchema>;