gm-pantry 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -29,43 +29,146 @@ A repo is a directory. Its entry point is `repo.json`, and the URL of that file
29
29
  download; a mismatch discards the file.
30
30
  - File urls may be relative, so a mirror of the directory is a working repo.
31
31
  - Unknown fields are ignored; a malformed game or version is skipped and the rest of the repo still loads.
32
+ - A big repo lists content-addressed `shards` (files of at most 1 000 games) instead of inlining its
33
+ games; a bad or tampered shard is skipped and the rest still loads.
32
34
  - Nothing lists or indexes repos. A private repo is an unguessable URL.
33
35
 
34
36
  ## CLI
35
37
 
36
38
  ```
37
39
  npx gm-pantry init <dir> [--name NAME]
38
- npx gm-pantry build <dir>
40
+ npx gm-pantry build <dir> [--shard-size N] [--prune]
39
41
  npx gm-pantry add <dir> <archive> [--id ID] [--title TITLE] [--version LABEL]
40
42
  npx gm-pantry validate <url|path> [--hosting]
43
+ npx gm-pantry publish <dir> --endpoint URL --bucket NAME --region R [--prefix P] [--shard-size N] [--path-style|--virtual-host]
44
+ npx gm-pantry cors --endpoint URL --bucket NAME --region R [--prefix P] [--origin O] [--public-url URL] [--apply]
41
45
  ```
42
46
 
43
47
  - `build` hashes every `.zip`, `.7z` and `.rar` at the top of the folder (streaming), derives each
44
48
  game's id and title from the file name, and writes `repo.json`. An existing `repo.json` is kept:
45
49
  hand-edited metadata, other versions and unknown fields survive, and a changed archive only has its
46
- hash and size refreshed. It never deletes an entry. `updated` moves only when something changed, so
50
+ hash and size refreshed. By default it never deletes an entry (`--prune` removes games whose archive file is gone from the
51
+ folder). Above `--shard-size` games (default 1000) it writes `shards/<sha16>.json` files of at most
52
+ N games sorted by id plus an entry `repo.json`, deletes the shards it generated earlier that are no
53
+ longer referenced, and gives the same bytes for the same input; at or below it the output stays
54
+ inline. `add` refuses a sharded repo: put the archive in the folder and run `build`. `updated` moves only when something changed, so
47
55
  a rebuild of an unchanged folder is byte-identical.
48
56
  - `add` does the same for one archive, which must be inside the directory, as a new newest version.
49
57
  - `validate` parses a `repo.json` (a URL, a folder or a file) and reports every skipped entry; for a
50
- folder it also checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
58
+ folder it also loads every shard and checks each archive's hash. `--hosting` (URL only) fetches `repo.json` and a sample
51
59
  archive and checks CORS, Range, content types and validators. Exit code 1 on any problem.
52
60
 
61
+ - `publish` builds the folder as `build` does, then uploads to S3-compatible storage: archives (multipart
62
+ above 16 MiB, an archive the bucket already holds with the same size and hash is skipped), new shards,
63
+ and `repo.json` last, then deletes shards nothing lists any more. Credentials come from
64
+ `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (and `AWS_SESSION_TOKEN`) or `--access-key-id` /
65
+ `--secret-access-key` / `--session-token`. It checks the connection first and reports bad
66
+ credentials, a wrong region, a missing bucket or missing permissions separately.
67
+ - `cors` prints the CORS configuration to paste (below). With `--public-url` it also checks what a
68
+ browser would see; with `--apply` it calls `PutBucketCors` for you (needs a key allowed to).
69
+
53
70
  ## Library
54
71
 
55
72
  ```ts
56
- import { parseRepo, resolveUrl, verifyArchive, validateHosting } from "gm-pantry";
73
+ import { loadRepo, parseRepo, parseShard, verifyArchive, validateHosting } from "gm-pantry";
57
74
 
58
- const { repo, warnings } = parseRepo(await (await fetch(url)).json(), url); // throws RepoError if unusable
75
+ // repo.json plus every shard, verified by hash; throws RepoError only if repo.json is unusable
76
+ const { repo, warnings } = await loadRepo(url, { concurrency: 4, onProgress: (done, total) => {}, shardCache });
77
+ // parseRepo(json, url) and parseShard(json, url) read the two kinds of file you fetched yourself
78
+ // shardCache is { get(sha256), put(sha256, bytes) }: persist shards by hash and a hit skips the network
59
79
  const result = await verifyArchive(bytes, repo.games[0].versions[0].file); // { ok: true } or { ok: false, ... }
60
80
  ```
61
81
 
62
- The library uses Web Crypto and `fetch` only, so it runs in browsers and Node. Only the CLI uses Node
63
- APIs.
82
+ The library uses Web Crypto, `fetch` and [hash-wasm](https://github.com/Daninet/hash-wasm) (streaming
83
+ SHA-256), so it runs in browsers and Node. Only the CLI uses Node APIs.
84
+
85
+ ### Editing a repo in memory and hashing big files
86
+
87
+ ```ts
88
+ import { addVersion, emptyRepo, hashBlob, inlineShards, removeGame, removeVersion, serialize, updateGame } from "gm-pantry";
89
+
90
+ const { sha256, size } = await hashBlob(file, { onProgress: (bytes) => {}, signal }); // streams: a 4 GB File is fine
91
+ const repo = emptyRepo("My Pantry"); // or the raw repo.json, with inlineShards() when it is sharded
92
+ addVersion(repo, { title: "My Game", version: "1.0", file: { url: "My_Game.zip", sha256, size } });
93
+ updateGame(repo, "my-game", { meta: { author: "Me" }, work: { delfruit: 1234 } }); // null removes a key
94
+ removeVersion(repo, "my-game", "0.9"); removeGame(repo, "old-game");
95
+ const { repoJson, shards, orphans } = await serialize(repo, { shardSize: 1000, previous }); // previous: the repo.json being replaced
96
+ ```
97
+
98
+ `serialize` gives the bytes of `repo.json`, the shard files to write (`shards/<sha16>.json`) and the shards
99
+ the previous `repo.json` listed that are now unused. The same input always gives the same bytes. The
100
+ CLI's `build` and `add` use these functions. `verifyArchive` also accepts a `Blob` or stream and hashes it
101
+ chunk by chunk.
102
+
103
+ ### Publishing to S3-compatible storage from a browser (`gm-pantry/s3`)
104
+
105
+ ```ts
106
+ import { S3Store, publishRepo, checkCors } from "gm-pantry/s3";
107
+
108
+ const store = new S3Store({ endpoint, region, bucket, accessKeyId, secretAccessKey, sessionToken, prefix: "games", forcePathStyle });
109
+ const test = await store.testConnection(); // list, write, delete a probe object; bad credentials, region, bucket and permissions are told apart
110
+ const loaded = await store.getObject("repo.json"); // { bytes, etag } or undefined; keep the etag
111
+ const result = await publishRepo(store, { repoJson, shards, orphans }, {
112
+ archives: [{ url: "My_Game.zip", body: file, sha256 }], // uploaded first, multipart when large
113
+ ifMatch: loaded?.etag ?? null, // refuses with ConflictError if repo.json changed since
114
+ resume: (url) => saved.get(url), onState: (url, state) => saved.set(url, state), // persist the multipart state
115
+ onProgress: (url, done, total) => {},
116
+ });
117
+ ```
118
+
119
+ `publishRepo` uploads archives and new shards first and `repo.json` last (`Content-Type: application/json`,
120
+ `Cache-Control: no-cache`; shards get `public, max-age=31536000, immutable`), writes it with `If-Match` /
121
+ `If-None-Match: *`, and deletes unreferenced shards only afterwards. If the service rejects conditional
122
+ writes (`501`), it writes unconditionally and returns `conditional: false`: with several publishers,
123
+ the last writer wins. `store.uploadFile(key, blob, { partSize, concurrency, onProgress, signal, onState, resume })`
124
+ is the multipart upload on its own: parts default to 16 MiB and grow so any file fits in 10 000
125
+ parts, three are in flight at once, and an upload stopped by `signal` or a crash continues from
126
+ `resume`. `store.abortUpload` and `store.abortIncompleteUploads()` discard unfinished uploads. Requests are signed
127
+ with SigV4 by [aws4fetch](https://github.com/mhart/aws4fetch); archive bodies are sent with
128
+ `UNSIGNED-PAYLOAD`, so use HTTPS.
129
+
130
+ ### CORS: set it once, outside the browser
131
+
132
+ A browser can only call the bucket after the bucket allows it, and `PutBucketCors` is itself a
133
+ cross-origin request, so a web page cannot set the first rule. The owner sets it once with the
134
+ provider's tools (or `gm-pantry cors --apply`, or `store.setCors()` from Node or a desktop app, where
135
+ CORS is not enforced). `checkCors(publicBaseUrl, origin, { writeUrl })` runs the checks a browser would
136
+ (read `repo.json` with CORS, `ETag` exposed, `Range`, and preflights for the `PUT`, `POST` and `DELETE`
137
+ with the headers the publisher sends) and returns what is missing plus these snippets
138
+ (`corsSnippets(origin)`; `origin` is the web app, or `*`):
139
+
140
+ - **Amazon S3**: save as `cors.json`, then `aws s3api put-bucket-cors --bucket BUCKET --cors-configuration file://cors.json`
141
+
142
+ ```json
143
+ { "CORSRules": [{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"], "MaxAgeSeconds": 3600 }] }
144
+ ```
145
+
146
+ - **Cloudflare R2**: paste into the bucket's Settings, CORS policy in the dashboard:
147
+
148
+ ```json
149
+ [{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"], "MaxAgeSeconds": 3600 }]
150
+ ```
151
+
152
+ or, with wrangler, `npx wrangler r2 bucket cors set BUCKET --file cors.json` and this file:
153
+
154
+ ```json
155
+ { "rules": [{ "allowed": { "origins": ["*"], "methods": ["GET", "HEAD", "PUT", "POST", "DELETE"], "headers": ["*"] }, "exposeHeaders": ["ETag", "Content-Length", "Content-Range"], "maxAgeSeconds": 3600 }] }
156
+ ```
157
+
158
+ - **Backblaze B2**: its S3 API accepts the S3 rule above; with the B2 CLI,
159
+ `b2 bucket update --cors-rules '<json>' BUCKET allPrivate`, where the JSON is
160
+ `[{"corsRuleName":"gm-pantry","allowedOrigins":["*"],"allowedOperations":["s3_get","s3_head","s3_put","s3_post","s3_delete"],"allowedHeaders":["*"],"exposeHeaders":["ETag","Content-Length","Content-Range"],"maxAgeSeconds":3600}]`.
161
+ (`allPrivate` is the bucket type: use your bucket's current one.)
162
+
163
+ For publishing, prefer your web app's origin over `*`. For readers only, the `GET`/`HEAD` rule further
164
+ down is enough. These snippets were written from the providers' documentation and checked against a
165
+ fake S3, not against the real services.
64
166
 
65
167
  ## Hosting
66
168
 
67
169
  The host must serve HTTPS and send `Access-Control-Allow-Origin: *` on `repo.json` and the archives.
68
- `Range` on archives and `ETag`/`Last-Modified` on `repo.json` are recommended. Check with
170
+ `Range` on archives, `ETag`/`Last-Modified` on `repo.json`, and a long `Cache-Control: immutable` on
171
+ shards (they are named after their hash) are recommended. Check with
69
172
  `gm-pantry validate <url> --hosting`.
70
173
 
71
174
  - **GitHub Pages** serves `Access-Control-Allow-Origin: *` already. Keep archives under its size limits.
package/dist/build.d.ts CHANGED
@@ -1,29 +1,25 @@
1
- type Json = Record<string, any>;
1
+ import { type Json } from "./builder.ts";
2
2
  export declare const ARCHIVE_EXTENSIONS: string[];
3
3
  export declare function hashFile(path: string): Promise<{
4
4
  sha256: string;
5
5
  size: number;
6
6
  }>;
7
- export declare const slug: (s: string) => string;
8
7
  export declare function readRepoJson(dir: string): Promise<Json | undefined>;
9
- export declare function emptyRepo(name: string): Json;
10
- /** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
11
- export declare function upsertArchive(repo: Json, url: string, hashed: {
12
- sha256: string;
13
- size: number;
14
- }, opts?: {
15
- id?: string;
16
- title?: string;
17
- version?: string;
18
- }): void;
19
- /** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
20
- export declare function writeRepoJson(dir: string, repo: Json, now?: string): Promise<boolean>;
8
+ /** Writes repo.json unless it already holds exactly these bytes; true when it changed. */
9
+ export declare function writeRepoJson(dir: string, repoJson: string): Promise<boolean>;
21
10
  /** Every archive in `dir` (top level only), by name, as repo-relative urls. */
22
11
  export declare function listArchives(dir: string): Promise<string[]>;
23
- export declare function buildRepo(dir: string, name?: string, now?: string): Promise<{
12
+ export interface BuildOptions {
13
+ /** Games per shard; at or below this many games the output stays inline. Default 1000. */
14
+ shardSize?: number;
15
+ /** Remove games whose archive file is gone from the folder. By default entries are kept. */
16
+ prune?: boolean;
17
+ }
18
+ export declare function buildRepo(dir: string, name?: string, now?: string, { shardSize, prune }?: BuildOptions): Promise<{
24
19
  changed: boolean;
25
20
  count: number;
21
+ shards: number;
22
+ pruned: number;
26
23
  }>;
27
24
  /** The repo-relative url of an archive that must live inside the repo directory. */
28
25
  export declare function relativeUrl(dir: string, archive: string): Promise<string>;
29
- export {};
package/dist/build.js CHANGED
@@ -1,9 +1,9 @@
1
- // Writing a repo.json (SPEC.md §7): node-only, kept out of the library entry point.
2
- // Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
1
+ // Building a repo.json from a folder (SPEC.md §3.7): node-only, kept out of the library entry point. The editing and serialising live in builder.ts.
3
2
  import { createHash } from "node:crypto";
4
3
  import { createReadStream } from "node:fs";
5
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
5
  import { basename, extname, join, relative, sep } from "node:path";
6
+ import { emptyRepo, inlineShards, serialize, upsertArchive } from "./builder.js";
7
7
  export const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar"];
8
8
  export async function hashFile(path) {
9
9
  const hash = createHash("sha256");
@@ -14,9 +14,8 @@ export async function hashFile(path) {
14
14
  }
15
15
  return { sha256: hash.digest("hex"), size };
16
16
  }
17
- export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
18
- /** "My_Game-v2.zip" -> "My Game-v2" */
19
- const titleOf = (file) => basename(file, extname(file)).replace(/_+/g, " ").trim();
17
+ const SHARD_FILE = /^[0-9a-f]{16}\.json$/;
18
+ const isRelative = (url) => !/^([a-z][a-z0-9+.-]*:|\/)/i.test(url);
20
19
  export async function readRepoJson(dir) {
21
20
  try {
22
21
  return JSON.parse(await readFile(join(dir, "repo.json"), "utf8"));
@@ -27,46 +26,11 @@ export async function readRepoJson(dir) {
27
26
  throw new Error(`repo.json in ${dir} is unreadable: ${e.message}`);
28
27
  }
29
28
  }
30
- export function emptyRepo(name) {
31
- return { schema: 1, name, updated: new Date().toISOString(), games: [] };
32
- }
33
- /** Adds an archive, or refreshes sha256 and size of the version that already points at it. */
34
- export function upsertArchive(repo, url, hashed, opts = {}) {
35
- const file = { url, ...hashed };
36
- const games = (repo.games ??= []);
37
- for (const game of games) {
38
- for (const v of game.versions ?? []) {
39
- if (v.file?.url === url) {
40
- v.file = { ...v.file, ...hashed };
41
- return;
42
- }
43
- }
44
- }
45
- const id = opts.id ?? slug(titleOf(decodeURIComponent(url)));
46
- if (id === "")
47
- throw new Error(`cannot derive an id from ${url}; pass --id`);
48
- let game = games.find((g) => g.id === id);
49
- if (!game) {
50
- game = { id, title: opts.title ?? titleOf(decodeURIComponent(url)), versions: [] };
51
- games.push(game);
52
- games.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
53
- }
54
- game.versions.unshift({ version: opts.version ?? String(game.versions.length + 1), file });
55
- }
56
- /** Serialises repo.json; `updated` moves only when something else changed, so a rebuild is byte-identical. */
57
- export async function writeRepoJson(dir, repo, now = new Date().toISOString()) {
58
- const before = await readFile(join(dir, "repo.json"), "utf8").catch(() => undefined);
59
- const stable = (r) => JSON.stringify({ ...r, updated: undefined });
60
- let old;
61
- try {
62
- old = before ? JSON.parse(before) : undefined;
63
- }
64
- catch { /* rewritten below */ }
65
- const next = { ...repo, updated: old && stable(old) === stable(repo) ? old.updated : now };
66
- const out = JSON.stringify(next, null, 2) + "\n";
67
- if (out === before)
29
+ /** Writes repo.json unless it already holds exactly these bytes; true when it changed. */
30
+ export async function writeRepoJson(dir, repoJson) {
31
+ if (repoJson === (await readFile(join(dir, "repo.json"), "utf8").catch(() => undefined)))
68
32
  return false;
69
- await writeFile(join(dir, "repo.json"), out);
33
+ await writeFile(join(dir, "repo.json"), repoJson);
70
34
  return true;
71
35
  }
72
36
  /** Every archive in `dir` (top level only), by name, as repo-relative urls. */
@@ -76,12 +40,50 @@ export async function listArchives(dir) {
76
40
  .map((e) => e.name)
77
41
  .sort();
78
42
  }
79
- export async function buildRepo(dir, name, now) {
80
- const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir));
43
+ /** Moves the games of a sharded repo.json inline, so build can edit one flat list. */
44
+ async function inline(dir, repo) {
45
+ for (const shard of repo.shards ?? []) {
46
+ if (!isRelative(shard.url))
47
+ throw new Error(`shard ${shard.url} is not a relative url; build can only rewrite shards inside ${dir}`);
48
+ }
49
+ await inlineShards(repo, (url) => readFile(join(dir, decodeURIComponent(url)), "utf8"));
50
+ }
51
+ /** Drops versions whose relative archive url no longer exists in `dir`, then games left with none. */
52
+ async function pruneMissing(dir, repo) {
53
+ for (const game of repo.games) {
54
+ const kept = [];
55
+ for (const v of game.versions ?? []) {
56
+ const url = v.file?.url ?? "";
57
+ if (!isRelative(url) || (await stat(join(dir, decodeURIComponent(url))).then(() => true, () => false)))
58
+ kept.push(v);
59
+ }
60
+ game.versions = kept;
61
+ }
62
+ const before = repo.games.length;
63
+ repo.games = repo.games.filter((g) => g.versions.length > 0);
64
+ return before - repo.games.length;
65
+ }
66
+ export async function buildRepo(dir, name, now, { shardSize = 1000, prune = false } = {}) {
67
+ const repo = (await readRepoJson(dir)) ?? emptyRepo(name ?? basename(dir), now);
68
+ const previous = structuredClone(repo);
69
+ await inline(dir, repo);
81
70
  const files = await listArchives(dir);
82
71
  for (const f of files)
83
72
  upsertArchive(repo, encodeURIComponent(f), await hashFile(join(dir, f)));
84
- return { changed: await writeRepoJson(dir, repo, now), count: files.length };
73
+ const pruned = prune ? await pruneMissing(dir, repo) : 0;
74
+ const out = await serialize(repo, { shardSize, previous, now });
75
+ if (out.shards.size)
76
+ await mkdir(join(dir, "shards"), { recursive: true });
77
+ for (const [url, content] of out.shards)
78
+ await writeFile(join(dir, url), content);
79
+ const written = out.shards;
80
+ const changed = await writeRepoJson(dir, out.repoJson);
81
+ // Only files shaped like ours are touched; anything else in shards/ is the owner's.
82
+ for (const f of await readdir(join(dir, "shards")).catch(() => [])) {
83
+ if (SHARD_FILE.test(f) && !written.has(`shards/${f}`))
84
+ await rm(join(dir, "shards", f));
85
+ }
86
+ return { changed, count: files.length, shards: written.size, pruned };
85
87
  }
86
88
  /** The repo-relative url of an archive that must live inside the repo directory. */
87
89
  export async function relativeUrl(dir, archive) {
@@ -0,0 +1,68 @@
1
+ export type Json = Record<string, any>;
2
+ export declare const slug: (s: string) => string;
3
+ /** "dir/My_Game-v2.zip" -> "My Game-v2" */
4
+ export declare const titleOf: (file: string) => string;
5
+ export declare function emptyRepo(name: string, now?: string): Json;
6
+ export interface NewVersion {
7
+ /** Game id; defaults to the slug of `title`. */
8
+ id?: string;
9
+ /** Required when the game does not exist yet. */
10
+ title?: string;
11
+ work?: Record<string, string | number>;
12
+ meta?: Json;
13
+ /** Version label; defaults to the first unused number. */
14
+ version?: string;
15
+ notes?: string;
16
+ released?: string;
17
+ format?: string;
18
+ file: {
19
+ url: string;
20
+ sha256: string;
21
+ size: number;
22
+ };
23
+ }
24
+ /** Adds a version (newest first) to its game, creating the game if needed; a version already pointing at `file.url` just gets its hash and size refreshed. */
25
+ export declare function addVersion(repo: Json, v: NewVersion): Json;
26
+ /** Adds an archive by its url, deriving the game id and title from the file name. */
27
+ export declare function upsertArchive(repo: Json, url: string, hashed: {
28
+ sha256: string;
29
+ size: number;
30
+ }, opts?: {
31
+ id?: string;
32
+ title?: string;
33
+ version?: string;
34
+ }): void;
35
+ /** Drops one version; a game left with none is dropped too. False when nothing matched. */
36
+ export declare function removeVersion(repo: Json, gameId: string, version: string): boolean;
37
+ export declare function removeGame(repo: Json, gameId: string): boolean;
38
+ export interface GamePatch {
39
+ title?: string;
40
+ /** Merged key by key; a key set to null is removed, `null` for the whole object removes it. */
41
+ work?: Record<string, string | number | null> | null;
42
+ meta?: Json | null;
43
+ }
44
+ export declare function updateGame(repo: Json, gameId: string, patch: GamePatch): Json;
45
+ /** Moves the games of a sharded repo.json inline, so one flat list can be edited. `readShard` returns a shard file's text by its (repo-relative) url. */
46
+ export declare function inlineShards(repo: Json, readShard: (url: string) => Promise<string>): Promise<void>;
47
+ /** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
48
+ export declare function splitShards(games: Json[], size: number): Promise<{
49
+ files: Map<string, string>;
50
+ shards: Json[];
51
+ }>;
52
+ export interface SerializeOptions {
53
+ /** Games per shard; at or below this many games the output stays inline. Default 1000. */
54
+ shardSize?: number;
55
+ /** The repo.json this one replaces, as parsed JSON. `updated` is kept when nothing else changed, and the shards it listed that are no longer used are reported as orphans. */
56
+ previous?: Json;
57
+ now?: string;
58
+ }
59
+ export interface Serialized {
60
+ /** The bytes of repo.json. */
61
+ repoJson: string;
62
+ /** Shard files to write, by repo-relative url. */
63
+ shards: Map<string, string>;
64
+ /** Generated shards (`shards/<sha16>.json`) the previous repo.json listed that this one does not: delete them after repo.json is live. */
65
+ orphans: string[];
66
+ }
67
+ /** Serialises a flat repo. Byte-identical for the same input, and `updated` moves only when something else changed. */
68
+ export declare function serialize(repo: Json, { shardSize, previous, now }?: SerializeOptions): Promise<Serialized>;
@@ -0,0 +1,139 @@
1
+ // Editing and writing a repo.json (SPEC.md §3, §3.7) in memory. Browser-safe: no filesystem, no Node APIs.
2
+ // Works on the raw JSON, never on a parsed Repo, so hand-edited fields and unknown fields survive.
3
+ import { sha256Hex } from "./hash.js";
4
+ import { LIMITS } from "./parse.js";
5
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
6
+ const GENERATED_SHARD = /^shards\/[0-9a-f]{16}\.json$/;
7
+ const encoder = new TextEncoder();
8
+ export const slug = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
9
+ /** "dir/My_Game-v2.zip" -> "My Game-v2" */
10
+ export const titleOf = (file) => (file.split("/").pop() ?? file).replace(/\.[^.]*$/, "").replace(/_+/g, " ").trim();
11
+ export function emptyRepo(name, now = new Date().toISOString()) {
12
+ return { schema: 1, name, updated: now, games: [] };
13
+ }
14
+ /** Adds a version (newest first) to its game, creating the game if needed; a version already pointing at `file.url` just gets its hash and size refreshed. */
15
+ export function addVersion(repo, v) {
16
+ const games = (repo.games ??= []);
17
+ for (const game of games) {
18
+ for (const old of game.versions ?? []) {
19
+ if (old.file?.url === v.file.url) {
20
+ old.file = { ...old.file, sha256: v.file.sha256, size: v.file.size };
21
+ return game;
22
+ }
23
+ }
24
+ }
25
+ const id = v.id ?? slug(v.title ?? "");
26
+ if (id === "")
27
+ throw new Error("cannot derive a game id; pass an id or a title");
28
+ let game = games.find((g) => g.id === id);
29
+ if (!game) {
30
+ if (!v.title)
31
+ throw new Error(`game ${id} does not exist; a title is needed to create it`);
32
+ game = { id, title: v.title, ...(v.work && { work: v.work }), ...(v.meta && { meta: v.meta }), versions: [] };
33
+ games.push(game);
34
+ games.sort(byId);
35
+ }
36
+ const taken = new Set(game.versions.map((x) => x.version));
37
+ let label = v.version;
38
+ if (label === undefined)
39
+ for (let n = game.versions.length + 1; taken.has((label = String(n))); n++)
40
+ ;
41
+ else if (taken.has(label))
42
+ throw new Error(`game ${id} already has a version ${label}`);
43
+ const { notes, released, format, file } = v;
44
+ game.versions.unshift({ version: label, file, ...(format && { format }), ...(notes && { notes }), ...(released && { released }) });
45
+ return game;
46
+ }
47
+ /** Adds an archive by its url, deriving the game id and title from the file name. */
48
+ export function upsertArchive(repo, url, hashed, opts = {}) {
49
+ const title = opts.title ?? titleOf(decodeURIComponent(url));
50
+ addVersion(repo, { ...opts, id: opts.id ?? slug(titleOf(decodeURIComponent(url))), title, file: { url, ...hashed } });
51
+ }
52
+ /** Drops one version; a game left with none is dropped too. False when nothing matched. */
53
+ export function removeVersion(repo, gameId, version) {
54
+ const game = (repo.games ?? []).find((g) => g.id === gameId);
55
+ const i = game?.versions.findIndex((v) => v.version === version) ?? -1;
56
+ if (i < 0)
57
+ return false;
58
+ game.versions.splice(i, 1);
59
+ if (game.versions.length === 0)
60
+ removeGame(repo, gameId);
61
+ return true;
62
+ }
63
+ export function removeGame(repo, gameId) {
64
+ const before = repo.games?.length ?? 0;
65
+ repo.games = (repo.games ?? []).filter((g) => g.id !== gameId);
66
+ return repo.games.length < before;
67
+ }
68
+ export function updateGame(repo, gameId, patch) {
69
+ const game = (repo.games ?? []).find((g) => g.id === gameId);
70
+ if (!game)
71
+ throw new Error(`no game ${gameId}`);
72
+ if (patch.title !== undefined)
73
+ game.title = patch.title;
74
+ for (const key of ["work", "meta"]) {
75
+ const p = patch[key];
76
+ if (p === undefined)
77
+ continue;
78
+ if (p === null) {
79
+ delete game[key];
80
+ continue;
81
+ }
82
+ game[key] = { ...game[key], ...p };
83
+ for (const k of Object.keys(game[key]))
84
+ if (game[key][k] === null)
85
+ delete game[key][k];
86
+ if (Object.keys(game[key]).length === 0)
87
+ delete game[key];
88
+ }
89
+ return game;
90
+ }
91
+ /** Moves the games of a sharded repo.json inline, so one flat list can be edited. `readShard` returns a shard file's text by its (repo-relative) url. */
92
+ export async function inlineShards(repo, readShard) {
93
+ for (const shard of repo.shards ?? []) {
94
+ try {
95
+ (repo.games ??= []).push(...JSON.parse(await readShard(shard.url)).games);
96
+ }
97
+ catch (e) {
98
+ throw new Error(`shard ${shard.url} is unreadable: ${e.message}`);
99
+ }
100
+ }
101
+ delete repo.shards;
102
+ }
103
+ /** Splits games, sorted by id, into content-addressed shard files of at most `size` games (SPEC.md §3.7). Pure: same input, same bytes. */
104
+ export async function splitShards(games, size) {
105
+ const sorted = [...games].sort(byId);
106
+ const files = new Map();
107
+ const shards = [];
108
+ for (let i = 0; i < sorted.length; i += size) {
109
+ const chunk = sorted.slice(i, i + size);
110
+ const content = JSON.stringify({ games: chunk }) + "\n";
111
+ const bytes = encoder.encode(content);
112
+ const sha256 = await sha256Hex(bytes);
113
+ const url = `shards/${sha256.slice(0, 16)}.json`;
114
+ files.set(url, content);
115
+ shards.push({ url, sha256, size: bytes.byteLength, count: chunk.length });
116
+ }
117
+ return { files, shards };
118
+ }
119
+ /** Serialises a flat repo. Byte-identical for the same input, and `updated` moves only when something else changed. */
120
+ export async function serialize(repo, { shardSize = 1000, previous, now = new Date().toISOString() } = {}) {
121
+ if (!Number.isInteger(shardSize) || shardSize < 1 || shardSize > LIMITS.gamesPerFile) {
122
+ throw new Error(`shard size must be an integer from 1 to ${LIMITS.gamesPerFile}`);
123
+ }
124
+ if (repo.shards?.length)
125
+ throw new Error("repo has shards; inlineShards first");
126
+ let out = repo;
127
+ let shardFiles = new Map();
128
+ if ((repo.games?.length ?? 0) > shardSize) {
129
+ const { files, shards } = await splitShards(repo.games, shardSize);
130
+ if (shards.length > LIMITS.shards)
131
+ throw new Error(`${shards.length} shards exceeds the cap of ${LIMITS.shards}; raise the shard size`);
132
+ shardFiles = files;
133
+ out = { ...repo, games: [], shards };
134
+ }
135
+ const stable = (r) => JSON.stringify({ ...r, updated: undefined });
136
+ const next = { ...out, updated: previous && stable(previous) === stable(out) ? previous.updated : now };
137
+ const orphans = (previous?.shards ?? []).map((s) => s.url).filter((u) => GENERATED_SHARD.test(u) && !shardFiles.has(u));
138
+ return { repoJson: JSON.stringify(next, null, 2) + "\n", shards: shardFiles, orphans };
139
+ }