Initial Commit
This commit is contained in:
79
packages/foundation/data/pwa/src/schemas/schemas.md
Normal file
79
packages/foundation/data/pwa/src/schemas/schemas.md
Normal 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.
|
||||
93
packages/foundation/data/pwa/src/schemas/schemas.test.ts
Normal file
93
packages/foundation/data/pwa/src/schemas/schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
packages/foundation/data/pwa/src/schemas/schemas.ts
Normal file
109
packages/foundation/data/pwa/src/schemas/schemas.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user