A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: move to civility namespace

+3073 -111
+1
.gitignore
··· 7 7 .next 8 8 ._* 9 9 **/*.log 10 + coverage
+8 -8
build.ts packages/ui/build.ts
··· 1 - import autoprefixer from 'autoprefixer' 2 1 import postcss from 'postcss' 2 + import autoprefixer from 'postcss-autoprefixer' 3 3 import atImport from 'postcss-import' 4 4 import { ensureDir } from '@std/fs' 5 5 6 6 console.log('Building CSS files...') 7 7 8 8 await transformCSS({ 9 - inputPath: './src/civility.css', 9 + inputPath: './civility.css', 10 10 outputPath: './dist/civility.css', 11 - from: './src/index.css', 11 + from: './index.css', 12 12 }) 13 13 console.log('Built: civility.css') 14 14 15 15 await transformCSS({ 16 - inputPath: './src/utilities.css', 16 + inputPath: './utilities.css', 17 17 outputPath: './dist/utilities.css', 18 - from: './src/utilities.css', 18 + from: './utilities.css', 19 19 }) 20 20 console.log('Built: utilities.css') 21 21 22 - for await (const dirEntry of Deno.readDir('./src/themes')) { 22 + for await (const dirEntry of Deno.readDir('./themes')) { 23 23 if (dirEntry.isFile && dirEntry.name.endsWith('.css')) { 24 24 await transformCSS({ 25 - inputPath: `./src/themes/${dirEntry.name}`, 25 + inputPath: `./themes/${dirEntry.name}`, 26 26 outputPath: `./dist/themes/${dirEntry.name}`, 27 - from: `./src/themes/${dirEntry.name}`, 27 + from: `./themes/${dirEntry.name}`, 28 28 }) 29 29 console.log(`Built theme: ${dirEntry.name.replace('.css', '')}`) 30 30 }
cli/commands/build/blog.ts packages/cli/commands/build/blog.ts
cli/commands/build/docs.ts packages/cli/commands/build/docs.ts
cli/commands/build/extension.ts packages/cli/commands/build/extension.ts
cli/commands/build/pwa.ts packages/cli/commands/build/pwa.ts
cli/commands/icons.ts packages/cli/commands/icons.ts
cli/commands/init.ts packages/cli/commands/init.ts
cli/commands/start.ts packages/cli/commands/start.ts
cli/commands/static.ts packages/cli/commands/static.ts
-54
cli/deno.json
··· 1 - { 2 - "name": "@civility/cli", 3 - "version": "0.1.2", 4 - "exports": { 5 - ".": "./main.ts" 6 - }, 7 - "compilerOptions": { 8 - "lib": [ 9 - "deno.ns", 10 - "dom", 11 - "dom.iterable", 12 - "dom.asynciterable", 13 - "esnext" 14 - ] 15 - }, 16 - "fmt": { 17 - "singleQuote": true, 18 - "proseWrap": "preserve", 19 - "semiColons": false 20 - }, 21 - "exclude": ["stubs"], 22 - "lint": { 23 - "exclude": ["dist"], 24 - "rules": { 25 - "exclude": ["no-import-prefix", "verbatim-module-syntax"] 26 - } 27 - }, 28 - "tasks": { 29 - "build": "deno run -A build.ts", 30 - "cli": "deno install cli/main.ts -Afg --name=civility --config ./deno.json" 31 - }, 32 - "imports": { 33 - "@astral/astral": "jsr:@astral/astral@^0.5.5", 34 - "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.56", 35 - "@cliffy/ansi": "jsr:@cliffy/ansi@1.0.0", 36 - "@cliffy/command": "jsr:@cliffy/command@1.0.0", 37 - "@cliffy/table": "jsr:@cliffy/table@1.0.0", 38 - "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", 39 - "@rodney/parsedown": "jsr:@rodney/parsedown@^1.4.3", 40 - "@std/assert": "jsr:@std/assert@^1.0.19", 41 - "@std/front-matter": "jsr:@std/front-matter@^1.0.9", 42 - "@std/fs": "jsr:@std/fs@^1.0.23", 43 - "@std/html": "jsr:@std/html@^1.0.5", 44 - "@std/http": "jsr:@std/http@^1.0.25", 45 - "@std/path": "jsr:@std/path@^1.1.4", 46 - "@std/testing": "jsr:@std/testing@^1.0.17", 47 - "autoprefixer": "npm:autoprefixer@^10.4.27", 48 - "cheerio": "npm:cheerio@^1.2.0", 49 - "esbuild": "npm:esbuild@^0.27.3", 50 - "lit": "npm:lit@^3.3.2", 51 - "postcss": "npm:postcss@^8.5.6", 52 - "postcss-import": "npm:postcss-import@^16.1.1" 53 - } 54 - }
cli/main.ts packages/cli/main.ts
cli/stubs/blog/.gitignore packages/cli/stubs/blog/.gitignore
cli/stubs/blog/README.md packages/cli/stubs/blog/README.md
cli/stubs/blog/civility.json packages/cli/stubs/blog/civility.json
cli/stubs/blog/deno.json packages/cli/stubs/blog/deno.json
cli/stubs/blog/md/my_first_post.md packages/cli/stubs/blog/md/my_first_post.md
cli/stubs/blog/www/blog.css packages/cli/stubs/blog/www/blog.css
cli/stubs/blog/www/template.html packages/cli/stubs/blog/www/template.html
cli/stubs/docs/.gitignore packages/cli/stubs/docs/.gitignore
cli/stubs/docs/README.md packages/cli/stubs/docs/README.md
cli/stubs/docs/civility.json packages/cli/stubs/docs/civility.json
cli/stubs/docs/deno.json packages/cli/stubs/docs/deno.json
cli/stubs/docs/docs.css packages/cli/stubs/docs/docs.css
cli/stubs/docs/index.md packages/cli/stubs/docs/index.md
cli/stubs/docs/template.html packages/cli/stubs/docs/template.html
cli/stubs/extension/.gitignore packages/cli/stubs/extension/.gitignore
cli/stubs/extension/README.md packages/cli/stubs/extension/README.md
cli/stubs/extension/civility.json packages/cli/stubs/extension/civility.json
cli/stubs/extension/deno.json packages/cli/stubs/extension/deno.json
cli/stubs/extension/www/background.ts packages/cli/stubs/extension/www/background.ts
cli/stubs/extension/www/content_script.ts packages/cli/stubs/extension/www/content_script.ts
cli/stubs/extension/www/manifest.json packages/cli/stubs/extension/www/manifest.json
cli/stubs/extension/www/options.html packages/cli/stubs/extension/www/options.html
cli/stubs/extension/www/options.tsx packages/cli/stubs/extension/www/options.tsx
cli/stubs/extension/www/popup.html packages/cli/stubs/extension/www/popup.html
cli/stubs/extension/www/popup.tsx packages/cli/stubs/extension/www/popup.tsx
cli/stubs/extension/www/static/icon.png packages/cli/stubs/extension/www/static/icon.png
cli/stubs/pwa/.gitignore packages/cli/stubs/pwa/.gitignore
cli/stubs/pwa/README.md packages/cli/stubs/pwa/README.md
cli/stubs/pwa/civility.json packages/cli/stubs/pwa/civility.json
cli/stubs/pwa/deno.json packages/cli/stubs/pwa/deno.json
cli/stubs/pwa/deno.lock packages/cli/stubs/pwa/deno.lock
cli/stubs/pwa/www/index.d.ts packages/cli/stubs/pwa/www/index.d.ts
cli/stubs/pwa/www/index.html packages/cli/stubs/pwa/www/index.html
cli/stubs/pwa/www/index.ts packages/cli/stubs/pwa/www/index.ts
cli/stubs/pwa/www/manifest.json packages/cli/stubs/pwa/www/manifest.json
cli/stubs/pwa/www/routes/clock.ts packages/cli/stubs/pwa/www/routes/clock.ts
cli/stubs/pwa/www/routes/stopwatch.ts packages/cli/stubs/pwa/www/routes/stopwatch.ts
cli/stubs/pwa/www/static/civility.css packages/cli/stubs/pwa/www/static/civility.css
cli/stubs/pwa/www/static/icon.png packages/cli/stubs/pwa/www/static/icon.png
cli/stubs/pwa/www/static/icons/clock.svg packages/cli/stubs/pwa/www/static/icons/clock.svg
cli/stubs/pwa/www/static/icons/download.svg packages/cli/stubs/pwa/www/static/icons/download.svg
cli/stubs/pwa/www/static/icons/watch.svg packages/cli/stubs/pwa/www/static/icons/watch.svg
cli/stubs/pwa/www/static/icons/x.svg packages/cli/stubs/pwa/www/static/icons/x.svg
cli/stubs/pwa/www/static/theme.css packages/cli/stubs/pwa/www/static/theme.css
cli/stubs/pwa/www/static/utilities.css packages/cli/stubs/pwa/www/static/utilities.css
cli/stubs/pwa/www/utils/updates.ts packages/cli/stubs/pwa/www/utils/updates.ts
cli/stubs/pwa/www/worker.js packages/cli/stubs/pwa/www/worker.js
cli/utils/build.ts packages/cli/utils/build.ts
cli/utils/config.ts packages/cli/utils/config.ts
cli/utils/strings.ts packages/cli/utils/strings.ts
cli/utils/ui.ts packages/cli/utils/ui.ts
cli/utils/validation.ts packages/cli/utils/validation.ts
+10 -21
deno.json
··· 1 1 { 2 - "name": "@bpev/civility", 3 2 "version": "0.1.2", 4 - "workspace": ["./cli"], 5 - "exports": { 6 - ".": "./src/mod.ts", 7 - "./workers": "./src/workers/mod.ts" 8 - }, 3 + "workspace": [ 4 + "./packages/cli", 5 + "./packages/store", 6 + "./packages/ui", 7 + "./packages/workers" 8 + ], 9 9 "compilerOptions": { 10 10 "lib": [ 11 11 "deno.ns", 12 12 "dom", 13 13 "dom.iterable", 14 14 "dom.asynciterable", 15 - "webworker", 16 - "esnext" 15 + "esnext", 16 + "webworker" 17 17 ], 18 - "types": ["./src/index.d.ts"] 18 + "types": ["./index.d.ts"] 19 19 }, 20 20 "fmt": { 21 21 "singleQuote": true, ··· 29 29 } 30 30 }, 31 31 "tasks": { 32 - "build": "deno run -A build.ts", 33 32 "cli": "deno install cli/main.ts -Afg --name=civility --config ./deno.json" 34 33 }, 35 34 "imports": { 36 35 "@astral/astral": "jsr:@astral/astral@^0.5.5", 37 36 "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.56", 38 - "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", 39 37 "@std/assert": "jsr:@std/assert@^1.0.19", 40 - "@std/fs": "jsr:@std/fs@^1.0.23", 41 - "@std/html": "jsr:@std/html@^1.0.5", 42 - "@std/http": "jsr:@std/http@^1.0.25", 43 - "@std/path": "jsr:@std/path@^1.1.4", 44 - "@std/testing": "jsr:@std/testing@^1.0.17", 45 - "autoprefixer": "npm:autoprefixer@^10.4.27", 46 - "esbuild": "npm:esbuild@^0.27.3", 47 - "lit": "npm:lit@^3.3.2", 48 - "postcss": "npm:postcss@^8.5.6", 49 - "postcss-import": "npm:postcss-import@^16.1.1" 38 + "@std/testing": "jsr:@std/testing@^1.0.17" 50 39 } 51 40 }
+17 -16
deno.lock
··· 760 760 "dependencies": [ 761 761 "jsr:@astral/astral@~0.5.5", 762 762 "jsr:@b-fuze/deno-dom@~0.1.56", 763 - "jsr:@luca/esbuild-deno-loader@~0.11.1", 764 763 "jsr:@std/assert@^1.0.19", 765 - "jsr:@std/fs@^1.0.23", 766 - "jsr:@std/html@^1.0.5", 767 - "jsr:@std/http@^1.0.25", 768 - "jsr:@std/path@^1.1.4", 769 - "jsr:@std/testing@^1.0.17", 770 - "npm:autoprefixer@^10.4.27", 771 - "npm:esbuild@~0.27.3", 772 - "npm:lit@^3.3.2", 773 - "npm:postcss-import@^16.1.1", 774 - "npm:postcss@^8.5.6" 764 + "jsr:@std/testing@^1.0.17" 775 765 ], 776 766 "members": { 777 - "cli": { 767 + "packages/cli": { 778 768 "dependencies": [ 779 769 "jsr:@astral/astral@~0.5.5", 780 770 "jsr:@b-fuze/deno-dom@~0.1.56", ··· 783 773 "jsr:@cliffy/table@1.0.0", 784 774 "jsr:@luca/esbuild-deno-loader@~0.11.1", 785 775 "jsr:@rodney/parsedown@^1.4.3", 786 - "jsr:@std/assert@^1.0.19", 787 776 "jsr:@std/front-matter@^1.0.9", 788 777 "jsr:@std/fs@^1.0.23", 789 - "jsr:@std/html@^1.0.5", 790 778 "jsr:@std/http@^1.0.25", 791 779 "jsr:@std/path@^1.1.4", 792 - "jsr:@std/testing@^1.0.17", 793 780 "npm:autoprefixer@^10.4.27", 794 781 "npm:cheerio@^1.2.0", 795 - "npm:esbuild@~0.27.3", 782 + "npm:esbuild@~0.27.3" 783 + ] 784 + }, 785 + "packages/store": { 786 + "dependencies": [ 787 + "jsr:@std/fs@^1.0.23", 788 + "jsr:@std/path@^1.1.4", 789 + "jsr:@std/semver@^1.0.8" 790 + ] 791 + }, 792 + "packages/ui": { 793 + "dependencies": [ 794 + "jsr:@std/fs@^1.0.23", 795 + "jsr:@std/html@^1.0.5", 796 + "npm:autoprefixer@^10.4.27", 796 797 "npm:lit@^3.3.2", 797 798 "npm:postcss-import@^16.1.1", 798 799 "npm:postcss@^8.5.6"
+8 -8
dist/civility.css packages/ui/dist/civility.css
··· 28 28 -webkit-text-size-adjust: 100%; 29 29 /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 30 30 -moz-tab-size: 4; 31 - -o-tab-size: 4; 32 - tab-size: 4; 31 + -o-tab-size: 4; 32 + tab-size: 4; 33 33 /* 3. Use a more readable tab size (opinionated). */ 34 34 } 35 35 ··· 1577 1577 width: 100%; 1578 1578 height: 100%; 1579 1579 -o-object-fit: cover; 1580 - object-fit: cover; 1580 + object-fit: cover; 1581 1581 } 1582 1582 1583 1583 /* UI Dropdown */ ··· 1910 1910 padding: 0; 1911 1911 margin: 0; 1912 1912 -webkit-appearance: textfield; 1913 - appearance: textfield; 1913 + appearance: textfield; 1914 1914 -moz-appearance: textfield; 1915 1915 } 1916 1916 ··· 2204 2204 2205 2205 .input-reset { 2206 2206 -webkit-appearance: none; 2207 - -moz-appearance: none; 2208 - appearance: none; 2207 + -moz-appearance: none; 2208 + appearance: none; 2209 2209 background: transparent; 2210 2210 } 2211 2211 ··· 2248 2248 .no-select { 2249 2249 cursor: default; 2250 2250 -webkit-user-select: none; 2251 - -moz-user-select: none; 2252 - user-select: none; 2251 + -moz-user-select: none; 2252 + user-select: none; 2253 2253 } 2254 2254 2255 2255 /* Position utilities */
dist/themes/blog.css packages/ui/dist/themes/blog.css
dist/themes/dark.css packages/ui/dist/themes/dark.css
dist/themes/default.css packages/ui/dist/themes/default.css
dist/themes/docs.css packages/ui/dist/themes/docs.css
+4 -4
dist/utilities.css packages/ui/dist/utilities.css
··· 282 282 283 283 .input-reset { 284 284 -webkit-appearance: none; 285 - -moz-appearance: none; 286 - appearance: none; 285 + -moz-appearance: none; 286 + appearance: none; 287 287 background: transparent; 288 288 } 289 289 ··· 326 326 .no-select { 327 327 cursor: default; 328 328 -webkit-user-select: none; 329 - -moz-user-select: none; 330 - user-select: none; 329 + -moz-user-select: none; 330 + user-select: none; 331 331 } 332 332 333 333 /* Position utilities */
examples/basic.html docs/examples/basic.html
examples/color.html docs/examples/color.html
examples/drawer-demo.html docs/examples/drawer-demo.html
examples/drawer-height-test.html docs/examples/drawer-height-test.html
examples/feather/activity.svg docs/examples/feather/activity.svg
examples/feather/airplay.svg docs/examples/feather/airplay.svg
examples/feather/alert-circle.svg docs/examples/feather/alert-circle.svg
examples/feather/alert-octagon.svg docs/examples/feather/alert-octagon.svg
examples/feather/alert-triangle.svg docs/examples/feather/alert-triangle.svg
examples/feather/align-center.svg docs/examples/feather/align-center.svg
examples/feather/align-justify.svg docs/examples/feather/align-justify.svg
examples/feather/align-left.svg docs/examples/feather/align-left.svg
examples/feather/align-right.svg docs/examples/feather/align-right.svg
examples/feather/anchor.svg docs/examples/feather/anchor.svg
examples/feather/aperture.svg docs/examples/feather/aperture.svg
examples/feather/archive.svg docs/examples/feather/archive.svg
examples/feather/arrow-down-circle.svg docs/examples/feather/arrow-down-circle.svg
examples/feather/arrow-down-left.svg docs/examples/feather/arrow-down-left.svg
examples/feather/arrow-down-right.svg docs/examples/feather/arrow-down-right.svg
examples/feather/arrow-down.svg docs/examples/feather/arrow-down.svg
examples/feather/arrow-left-circle.svg docs/examples/feather/arrow-left-circle.svg
examples/feather/arrow-left.svg docs/examples/feather/arrow-left.svg
examples/feather/arrow-right-circle.svg docs/examples/feather/arrow-right-circle.svg
examples/feather/arrow-right.svg docs/examples/feather/arrow-right.svg
examples/feather/arrow-up-circle.svg docs/examples/feather/arrow-up-circle.svg
examples/feather/arrow-up-left.svg docs/examples/feather/arrow-up-left.svg
examples/feather/arrow-up-right.svg docs/examples/feather/arrow-up-right.svg
examples/feather/arrow-up.svg docs/examples/feather/arrow-up.svg
examples/feather/at-sign.svg docs/examples/feather/at-sign.svg
examples/feather/award.svg docs/examples/feather/award.svg
examples/feather/bar-chart-2.svg docs/examples/feather/bar-chart-2.svg
examples/feather/bar-chart.svg docs/examples/feather/bar-chart.svg
examples/feather/battery-charging.svg docs/examples/feather/battery-charging.svg
examples/feather/battery.svg docs/examples/feather/battery.svg
examples/feather/bell-off.svg docs/examples/feather/bell-off.svg
examples/feather/bell.svg docs/examples/feather/bell.svg
examples/feather/bluetooth.svg docs/examples/feather/bluetooth.svg
examples/feather/bold.svg docs/examples/feather/bold.svg
examples/feather/book-open.svg docs/examples/feather/book-open.svg
examples/feather/book.svg docs/examples/feather/book.svg
examples/feather/bookmark.svg docs/examples/feather/bookmark.svg
examples/feather/box.svg docs/examples/feather/box.svg
examples/feather/briefcase.svg docs/examples/feather/briefcase.svg
examples/feather/calendar.svg docs/examples/feather/calendar.svg
examples/feather/camera-off.svg docs/examples/feather/camera-off.svg
examples/feather/camera.svg docs/examples/feather/camera.svg
examples/feather/cast.svg docs/examples/feather/cast.svg
examples/feather/check-circle.svg docs/examples/feather/check-circle.svg
examples/feather/check-square.svg docs/examples/feather/check-square.svg
examples/feather/check.svg docs/examples/feather/check.svg
examples/feather/chevron-down.svg docs/examples/feather/chevron-down.svg
examples/feather/chevron-left.svg docs/examples/feather/chevron-left.svg
examples/feather/chevron-right.svg docs/examples/feather/chevron-right.svg
examples/feather/chevron-up.svg docs/examples/feather/chevron-up.svg
examples/feather/chevrons-down.svg docs/examples/feather/chevrons-down.svg
examples/feather/chevrons-left.svg docs/examples/feather/chevrons-left.svg
examples/feather/chevrons-right.svg docs/examples/feather/chevrons-right.svg
examples/feather/chevrons-up.svg docs/examples/feather/chevrons-up.svg
examples/feather/chrome.svg docs/examples/feather/chrome.svg
examples/feather/circle.svg docs/examples/feather/circle.svg
examples/feather/clipboard.svg docs/examples/feather/clipboard.svg
examples/feather/clock.svg docs/examples/feather/clock.svg
examples/feather/cloud-drizzle.svg docs/examples/feather/cloud-drizzle.svg
examples/feather/cloud-lightning.svg docs/examples/feather/cloud-lightning.svg
examples/feather/cloud-off.svg docs/examples/feather/cloud-off.svg
examples/feather/cloud-rain.svg docs/examples/feather/cloud-rain.svg
examples/feather/cloud-snow.svg docs/examples/feather/cloud-snow.svg
examples/feather/cloud.svg docs/examples/feather/cloud.svg
examples/feather/code.svg docs/examples/feather/code.svg
examples/feather/codepen.svg docs/examples/feather/codepen.svg
examples/feather/codesandbox.svg docs/examples/feather/codesandbox.svg
examples/feather/coffee.svg docs/examples/feather/coffee.svg
examples/feather/columns.svg docs/examples/feather/columns.svg
examples/feather/command.svg docs/examples/feather/command.svg
examples/feather/compass.svg docs/examples/feather/compass.svg
examples/feather/copy.svg docs/examples/feather/copy.svg
examples/feather/corner-down-left.svg docs/examples/feather/corner-down-left.svg
examples/feather/corner-down-right.svg docs/examples/feather/corner-down-right.svg
examples/feather/corner-left-down.svg docs/examples/feather/corner-left-down.svg
examples/feather/corner-left-up.svg docs/examples/feather/corner-left-up.svg
examples/feather/corner-right-down.svg docs/examples/feather/corner-right-down.svg
examples/feather/corner-right-up.svg docs/examples/feather/corner-right-up.svg
examples/feather/corner-up-left.svg docs/examples/feather/corner-up-left.svg
examples/feather/corner-up-right.svg docs/examples/feather/corner-up-right.svg
examples/feather/cpu.svg docs/examples/feather/cpu.svg
examples/feather/credit-card.svg docs/examples/feather/credit-card.svg
examples/feather/crop.svg docs/examples/feather/crop.svg
examples/feather/crosshair.svg docs/examples/feather/crosshair.svg
examples/feather/database.svg docs/examples/feather/database.svg
examples/feather/delete.svg docs/examples/feather/delete.svg
examples/feather/disc.svg docs/examples/feather/disc.svg
examples/feather/divide-circle.svg docs/examples/feather/divide-circle.svg
examples/feather/divide-square.svg docs/examples/feather/divide-square.svg
examples/feather/divide.svg docs/examples/feather/divide.svg
examples/feather/dollar-sign.svg docs/examples/feather/dollar-sign.svg
examples/feather/download-cloud.svg docs/examples/feather/download-cloud.svg
examples/feather/download.svg docs/examples/feather/download.svg
examples/feather/dribbble.svg docs/examples/feather/dribbble.svg
examples/feather/droplet.svg docs/examples/feather/droplet.svg
examples/feather/edit-2.svg docs/examples/feather/edit-2.svg
examples/feather/edit-3.svg docs/examples/feather/edit-3.svg
examples/feather/edit.svg docs/examples/feather/edit.svg
examples/feather/external-link.svg docs/examples/feather/external-link.svg
examples/feather/eye-off.svg docs/examples/feather/eye-off.svg
examples/feather/eye.svg docs/examples/feather/eye.svg
examples/feather/facebook.svg docs/examples/feather/facebook.svg
examples/feather/fast-forward.svg docs/examples/feather/fast-forward.svg
examples/feather/feather.svg docs/examples/feather/feather.svg
examples/feather/figma.svg docs/examples/feather/figma.svg
examples/feather/file-minus.svg docs/examples/feather/file-minus.svg
examples/feather/file-plus.svg docs/examples/feather/file-plus.svg
examples/feather/file-text.svg docs/examples/feather/file-text.svg
examples/feather/file.svg docs/examples/feather/file.svg
examples/feather/film.svg docs/examples/feather/film.svg
examples/feather/filter.svg docs/examples/feather/filter.svg
examples/feather/flag.svg docs/examples/feather/flag.svg
examples/feather/folder-minus.svg docs/examples/feather/folder-minus.svg
examples/feather/folder-plus.svg docs/examples/feather/folder-plus.svg
examples/feather/folder.svg docs/examples/feather/folder.svg
examples/feather/framer.svg docs/examples/feather/framer.svg
examples/feather/frown.svg docs/examples/feather/frown.svg
examples/feather/gift.svg docs/examples/feather/gift.svg
examples/feather/git-branch.svg docs/examples/feather/git-branch.svg
examples/feather/git-commit.svg docs/examples/feather/git-commit.svg
examples/feather/git-merge.svg docs/examples/feather/git-merge.svg
examples/feather/git-pull-request.svg docs/examples/feather/git-pull-request.svg
examples/feather/github.svg docs/examples/feather/github.svg
examples/feather/gitlab.svg docs/examples/feather/gitlab.svg
examples/feather/globe.svg docs/examples/feather/globe.svg
examples/feather/grid.svg docs/examples/feather/grid.svg
examples/feather/hard-drive.svg docs/examples/feather/hard-drive.svg
examples/feather/hash.svg docs/examples/feather/hash.svg
examples/feather/headphones.svg docs/examples/feather/headphones.svg
examples/feather/heart.svg docs/examples/feather/heart.svg
examples/feather/help-circle.svg docs/examples/feather/help-circle.svg
examples/feather/hexagon.svg docs/examples/feather/hexagon.svg
examples/feather/home.svg docs/examples/feather/home.svg
examples/feather/image.svg docs/examples/feather/image.svg
examples/feather/inbox.svg docs/examples/feather/inbox.svg
examples/feather/info.svg docs/examples/feather/info.svg
examples/feather/instagram.svg docs/examples/feather/instagram.svg
examples/feather/italic.svg docs/examples/feather/italic.svg
examples/feather/key.svg docs/examples/feather/key.svg
examples/feather/layers.svg docs/examples/feather/layers.svg
examples/feather/layout.svg docs/examples/feather/layout.svg
examples/feather/life-buoy.svg docs/examples/feather/life-buoy.svg
examples/feather/link.svg docs/examples/feather/link.svg
examples/feather/linkedin.svg docs/examples/feather/linkedin.svg
examples/feather/list.svg docs/examples/feather/list.svg
examples/feather/loader.svg docs/examples/feather/loader.svg
examples/feather/lock.svg docs/examples/feather/lock.svg
examples/feather/log-in.svg docs/examples/feather/log-in.svg
examples/feather/log-out.svg docs/examples/feather/log-out.svg
examples/feather/mail.svg docs/examples/feather/mail.svg
examples/feather/map-pin.svg docs/examples/feather/map-pin.svg
examples/feather/map.svg docs/examples/feather/map.svg
examples/feather/maximize-2.svg docs/examples/feather/maximize-2.svg
examples/feather/maximize.svg docs/examples/feather/maximize.svg
examples/feather/meh.svg docs/examples/feather/meh.svg
examples/feather/menu.svg docs/examples/feather/menu.svg
examples/feather/message-circle.svg docs/examples/feather/message-circle.svg
examples/feather/message-square.svg docs/examples/feather/message-square.svg
examples/feather/mic-off.svg docs/examples/feather/mic-off.svg
examples/feather/mic.svg docs/examples/feather/mic.svg
examples/feather/minimize-2.svg docs/examples/feather/minimize-2.svg
examples/feather/minimize.svg docs/examples/feather/minimize.svg
examples/feather/minus-circle.svg docs/examples/feather/minus-circle.svg
examples/feather/minus-square.svg docs/examples/feather/minus-square.svg
examples/feather/minus.svg docs/examples/feather/minus.svg
examples/feather/monitor.svg docs/examples/feather/monitor.svg
examples/feather/moon.svg docs/examples/feather/moon.svg
examples/feather/more-horizontal.svg docs/examples/feather/more-horizontal.svg
examples/feather/more-vertical.svg docs/examples/feather/more-vertical.svg
examples/feather/mouse-pointer.svg docs/examples/feather/mouse-pointer.svg
examples/feather/move.svg docs/examples/feather/move.svg
examples/feather/music.svg docs/examples/feather/music.svg
examples/feather/navigation-2.svg docs/examples/feather/navigation-2.svg
examples/feather/navigation.svg docs/examples/feather/navigation.svg
examples/feather/octagon.svg docs/examples/feather/octagon.svg
examples/feather/package.svg docs/examples/feather/package.svg
examples/feather/paperclip.svg docs/examples/feather/paperclip.svg
examples/feather/pause-circle.svg docs/examples/feather/pause-circle.svg
examples/feather/pause.svg docs/examples/feather/pause.svg
examples/feather/pen-tool.svg docs/examples/feather/pen-tool.svg
examples/feather/percent.svg docs/examples/feather/percent.svg
examples/feather/phone-call.svg docs/examples/feather/phone-call.svg
examples/feather/phone-forwarded.svg docs/examples/feather/phone-forwarded.svg
examples/feather/phone-incoming.svg docs/examples/feather/phone-incoming.svg
examples/feather/phone-missed.svg docs/examples/feather/phone-missed.svg
examples/feather/phone-off.svg docs/examples/feather/phone-off.svg
examples/feather/phone-outgoing.svg docs/examples/feather/phone-outgoing.svg
examples/feather/phone.svg docs/examples/feather/phone.svg
examples/feather/pie-chart.svg docs/examples/feather/pie-chart.svg
examples/feather/play-circle.svg docs/examples/feather/play-circle.svg
examples/feather/play.svg docs/examples/feather/play.svg
examples/feather/plus-circle.svg docs/examples/feather/plus-circle.svg
examples/feather/plus-square.svg docs/examples/feather/plus-square.svg
examples/feather/plus.svg docs/examples/feather/plus.svg
examples/feather/pocket.svg docs/examples/feather/pocket.svg
examples/feather/power.svg docs/examples/feather/power.svg
examples/feather/printer.svg docs/examples/feather/printer.svg
examples/feather/radio.svg docs/examples/feather/radio.svg
examples/feather/refresh-ccw.svg docs/examples/feather/refresh-ccw.svg
examples/feather/refresh-cw.svg docs/examples/feather/refresh-cw.svg
examples/feather/repeat.svg docs/examples/feather/repeat.svg
examples/feather/rewind.svg docs/examples/feather/rewind.svg
examples/feather/rotate-ccw.svg docs/examples/feather/rotate-ccw.svg
examples/feather/rotate-cw.svg docs/examples/feather/rotate-cw.svg
examples/feather/rss.svg docs/examples/feather/rss.svg
examples/feather/save.svg docs/examples/feather/save.svg
examples/feather/scissors.svg docs/examples/feather/scissors.svg
examples/feather/search.svg docs/examples/feather/search.svg
examples/feather/send.svg docs/examples/feather/send.svg
examples/feather/server.svg docs/examples/feather/server.svg
examples/feather/settings.svg docs/examples/feather/settings.svg
examples/feather/share-2.svg docs/examples/feather/share-2.svg
examples/feather/share.svg docs/examples/feather/share.svg
examples/feather/shield-off.svg docs/examples/feather/shield-off.svg
examples/feather/shield.svg docs/examples/feather/shield.svg
examples/feather/shopping-bag.svg docs/examples/feather/shopping-bag.svg
examples/feather/shopping-cart.svg docs/examples/feather/shopping-cart.svg
examples/feather/shuffle.svg docs/examples/feather/shuffle.svg
examples/feather/sidebar.svg docs/examples/feather/sidebar.svg
examples/feather/skip-back.svg docs/examples/feather/skip-back.svg
examples/feather/skip-forward.svg docs/examples/feather/skip-forward.svg
examples/feather/slack.svg docs/examples/feather/slack.svg
examples/feather/slash.svg docs/examples/feather/slash.svg
examples/feather/sliders.svg docs/examples/feather/sliders.svg
examples/feather/smartphone.svg docs/examples/feather/smartphone.svg
examples/feather/smile.svg docs/examples/feather/smile.svg
examples/feather/speaker.svg docs/examples/feather/speaker.svg
examples/feather/square.svg docs/examples/feather/square.svg
examples/feather/star.svg docs/examples/feather/star.svg
examples/feather/stop-circle.svg docs/examples/feather/stop-circle.svg
examples/feather/sun.svg docs/examples/feather/sun.svg
examples/feather/sunrise.svg docs/examples/feather/sunrise.svg
examples/feather/sunset.svg docs/examples/feather/sunset.svg
examples/feather/table.svg docs/examples/feather/table.svg
examples/feather/tablet.svg docs/examples/feather/tablet.svg
examples/feather/tag.svg docs/examples/feather/tag.svg
examples/feather/target.svg docs/examples/feather/target.svg
examples/feather/terminal.svg docs/examples/feather/terminal.svg
examples/feather/thermometer.svg docs/examples/feather/thermometer.svg
examples/feather/thumbs-down.svg docs/examples/feather/thumbs-down.svg
examples/feather/thumbs-up.svg docs/examples/feather/thumbs-up.svg
examples/feather/toggle-left.svg docs/examples/feather/toggle-left.svg
examples/feather/toggle-right.svg docs/examples/feather/toggle-right.svg
examples/feather/tool.svg docs/examples/feather/tool.svg
examples/feather/trash-2.svg docs/examples/feather/trash-2.svg
examples/feather/trash.svg docs/examples/feather/trash.svg
examples/feather/trello.svg docs/examples/feather/trello.svg
examples/feather/trending-down.svg docs/examples/feather/trending-down.svg
examples/feather/trending-up.svg docs/examples/feather/trending-up.svg
examples/feather/triangle.svg docs/examples/feather/triangle.svg
examples/feather/truck.svg docs/examples/feather/truck.svg
examples/feather/tv.svg docs/examples/feather/tv.svg
examples/feather/twitch.svg docs/examples/feather/twitch.svg
examples/feather/twitter.svg docs/examples/feather/twitter.svg
examples/feather/type.svg docs/examples/feather/type.svg
examples/feather/umbrella.svg docs/examples/feather/umbrella.svg
examples/feather/underline.svg docs/examples/feather/underline.svg
examples/feather/unlock.svg docs/examples/feather/unlock.svg
examples/feather/upload-cloud.svg docs/examples/feather/upload-cloud.svg
examples/feather/upload.svg docs/examples/feather/upload.svg
examples/feather/user-check.svg docs/examples/feather/user-check.svg
examples/feather/user-minus.svg docs/examples/feather/user-minus.svg
examples/feather/user-plus.svg docs/examples/feather/user-plus.svg
examples/feather/user-x.svg docs/examples/feather/user-x.svg
examples/feather/user.svg docs/examples/feather/user.svg
examples/feather/users.svg docs/examples/feather/users.svg
examples/feather/video-off.svg docs/examples/feather/video-off.svg
examples/feather/video.svg docs/examples/feather/video.svg
examples/feather/voicemail.svg docs/examples/feather/voicemail.svg
examples/feather/volume-1.svg docs/examples/feather/volume-1.svg
examples/feather/volume-2.svg docs/examples/feather/volume-2.svg
examples/feather/volume-x.svg docs/examples/feather/volume-x.svg
examples/feather/volume.svg docs/examples/feather/volume.svg
examples/feather/watch.svg docs/examples/feather/watch.svg
examples/feather/wifi-off.svg docs/examples/feather/wifi-off.svg
examples/feather/wifi.svg docs/examples/feather/wifi.svg
examples/feather/wind.svg docs/examples/feather/wind.svg
examples/feather/x-circle.svg docs/examples/feather/x-circle.svg
examples/feather/x-octagon.svg docs/examples/feather/x-octagon.svg
examples/feather/x-square.svg docs/examples/feather/x-square.svg
examples/feather/x.svg docs/examples/feather/x.svg
examples/feather/youtube.svg docs/examples/feather/youtube.svg
examples/feather/zap-off.svg docs/examples/feather/zap-off.svg
examples/feather/zap.svg docs/examples/feather/zap.svg
examples/feather/zoom-in.svg docs/examples/feather/zoom-in.svg
examples/feather/zoom-out.svg docs/examples/feather/zoom-out.svg
examples/layout-test.html docs/examples/layout-test.html
examples/mobile.html docs/examples/mobile.html
examples/page-layout.html docs/examples/page-layout.html
+27
packages/cli/deno.json
··· 1 + { 2 + "name": "@civility/cli", 3 + "version": "0.1.2", 4 + "exports": { 5 + ".": "./main.ts" 6 + }, 7 + "exclude": ["stubs"], 8 + "tasks": { 9 + "install": "deno install main.ts -Afg --name=civ --config ./deno.json" 10 + }, 11 + "imports": { 12 + "@astral/astral": "jsr:@astral/astral@^0.5.5", 13 + "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.56", 14 + "@cliffy/ansi": "jsr:@cliffy/ansi@1.0.0", 15 + "@cliffy/command": "jsr:@cliffy/command@1.0.0", 16 + "@cliffy/table": "jsr:@cliffy/table@1.0.0", 17 + "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", 18 + "@rodney/parsedown": "jsr:@rodney/parsedown@^1.4.3", 19 + "@std/front-matter": "jsr:@std/front-matter@^1.0.9", 20 + "@std/fs": "jsr:@std/fs@^1.0.23", 21 + "@std/http": "jsr:@std/http@^1.0.25", 22 + "@std/path": "jsr:@std/path@^1.1.4", 23 + "autoprefixer": "npm:autoprefixer@^10.4.27", 24 + "cheerio": "npm:cheerio@^1.2.0", 25 + "esbuild": "npm:esbuild@^0.27.3" 26 + } 27 + }
+105
packages/store/__tests__/state.test.ts
··· 1 + import { assertEquals } from '@std/assert' 2 + import { assertSpyCall, assertSpyCalls, spy } from '@std/testing/mock' 3 + import LocalStorage from '../storage/local_storage.ts' 4 + import State from '../state.ts' 5 + 6 + Deno.test('sets and gets state', () => { 7 + class Counter extends State<{ count: number }> { 8 + constructor(count: number = 0) { 9 + super({ count }) 10 + } 11 + increment() { 12 + this.state.count++ 13 + } 14 + } 15 + 16 + const counter = new Counter(0) 17 + assertEquals(counter.state.count, 0) 18 + counter.increment() 19 + assertEquals(counter.state.count, 1) 20 + }) 21 + 22 + Deno.test('watches value', () => { 23 + class Counter extends State<{ count: number }> { 24 + constructor(count: number = 0) { 25 + super({ count }) 26 + } 27 + increment() { 28 + this.state.count++ 29 + this.notify() 30 + } 31 + } 32 + 33 + const counter = new Counter(0) 34 + const listener = spy() 35 + counter.addEventListener(listener) 36 + counter.increment() 37 + assertSpyCalls(listener, 1) 38 + assertSpyCall(listener, 0, { args: [{ count: 1 }] }) 39 + 40 + counter.removeEventListener(listener) 41 + counter.increment() 42 + assertSpyCalls(listener, 1) 43 + }) 44 + 45 + Deno.test('can batch changes to reduce notifies', () => { 46 + class Counter extends State<{ count: number | undefined }> { 47 + constructor(count: number = 0) { 48 + super({ count }) 49 + } 50 + increment() { 51 + this.state.count = (this.state.count || 0) + 1 52 + } 53 + } 54 + 55 + const counter = new Counter(0) 56 + const listener = spy() 57 + counter.addEventListener(listener) 58 + counter.batch(() => { 59 + counter.increment() 60 + delete counter.state.count 61 + counter.increment() 62 + counter.notify() // Should do nothing 63 + counter.increment() 64 + }) 65 + assertSpyCalls(listener, 1) 66 + assertSpyCall(listener, 0, { args: [{ count: 2 }] }) 67 + }) 68 + 69 + Deno.test('can use save/load to interact with storage', async () => { 70 + type CountState = { count: number } 71 + 72 + const store = new LocalStorage<CountState>({ 73 + name: 'count', 74 + defaultValue: { count: 0 }, 75 + deserialize: (str) => str ? JSON.parse(str) : null, 76 + serialize: (state) => JSON.stringify(state), 77 + verify: (state) => Boolean((state as CountState)?.count != null), 78 + }) 79 + 80 + class Counter extends State<CountState> { 81 + constructor(count: number = 0) { 82 + super({ count }) 83 + } 84 + } 85 + 86 + const counter = new Counter(0) 87 + const rand = Math.floor(Math.random() * 100) 88 + counter.state.count = rand 89 + 90 + const saved = counter.save((state) => store.set(state)) 91 + assertEquals(counter.saving, true, 'Should signal saving during processing') 92 + await saved 93 + assertEquals(counter.saving, false, 'Should stop saving after processing') 94 + assertEquals(await store.get(), { count: rand }, 'Recover state') 95 + 96 + counter.state.count = 0 97 + 98 + const load = counter.load(async () => { 99 + return await store.get() || { count: 0 } 100 + }) 101 + assertEquals(counter.loading, true, 'Should signal loading during processing') 102 + await load 103 + assertEquals(counter.loading, false, 'Should stop loading after processing') 104 + assertEquals(counter.state, { count: rand }) 105 + })
+49
packages/store/__tests__/storage.test.ts
··· 1 + import { assertEquals, assertThrows } from '@std/assert' 2 + import Storage from '../storage.ts' 3 + 4 + Deno.test('Storage base class initialization', () => { 5 + const props = { 6 + name: 'storage-test', 7 + defaultValue: { value: 'default' }, 8 + deserialize: (str: string) => JSON.parse(str), 9 + serialize: (obj: unknown) => JSON.stringify(obj), 10 + verify: (obj: unknown) => Boolean((obj as { value: string })?.value), 11 + } 12 + 13 + const storage = new Storage(props) 14 + assertEquals(storage.name, 'storage-test') 15 + assertEquals(storage.defaultValue, { value: 'default' }) 16 + assertEquals(storage.serialize({ value: 'test' }), '{"value":"test"}') 17 + assertEquals(storage.deserialize('{"value":"test"}'), { value: 'test' }) 18 + assertEquals(storage.verify({ value: 'test' }), true) 19 + assertEquals(storage.verify({ other: 'property' }), false) 20 + }) 21 + 22 + Deno.test('Storage base methods throw errors when not implemented', () => { 23 + const storage = new Storage({ 24 + name: 'storage-test', 25 + defaultValue: null, 26 + deserialize: (str: string) => JSON.parse(str), 27 + serialize: (obj: unknown) => JSON.stringify(obj), 28 + verify: () => true, 29 + }) 30 + 31 + assertThrows(() => storage.has(), Error, 'not implemented') 32 + assertThrows(() => storage.get(), Error, 'not implemented') 33 + assertThrows(() => storage.set({ test: 'value' }), Error, 'not implemented') 34 + assertThrows(() => storage.remove(), Error, 'not implemented') 35 + }) 36 + 37 + Deno.test('safeParse returns defaultValue for invalid input', () => { 38 + const storage = new Storage({ 39 + name: 'storage-test', 40 + defaultValue: { default: true }, 41 + deserialize: (str: string) => JSON.parse(str), 42 + serialize: (obj: unknown) => JSON.stringify(obj), 43 + verify: () => true, 44 + }) 45 + 46 + assertEquals(storage.safeParse(''), { default: true }) 47 + assertEquals(storage.safeParse('invalid json'), { default: true }) 48 + assertEquals(storage.safeParse('{"valid":"json"}'), { valid: 'json' }) 49 + })
+15
packages/store/deno.json
··· 1 + { 2 + "name": "@civility/store", 3 + "version": "0.1.2", 4 + "exports": { 5 + ".": "./mod.ts", 6 + "./deno-fs": "./storage/deno_fs_storage.ts", 7 + "./idb": "./storage/index_db_storage.ts", 8 + "./local-storage": "./storage/local_storage.ts" 9 + }, 10 + "imports": { 11 + "@std/fs": "jsr:@std/fs@^1.0.23", 12 + "@std/path": "jsr:@std/path@^1.1.4", 13 + "@std/semver": "jsr:@std/semver@^1.0.8" 14 + } 15 + }
+2
packages/store/mod.ts
··· 1 + export * from './state.ts' 2 + export * from './storage.ts'
+180
packages/store/state.ts
··· 1 + /** 2 + * @module 3 + * Generic state and listener class used by all the simple-tools. This helps us: 4 + * 1. Get event listening for free in all of our tools for UI hookin 5 + * 2. Help define a structure for public state 6 + * 7 + * State is meant to be extended into Classes, and used with an Object state 8 + */ 9 + import type Storage from './storage.ts' 10 + 11 + /** Options to modify how State works */ 12 + export interface Options<T> { 13 + /** Optional storage mechanism for saving state outside of memory */ 14 + storage?: Storage<T> 15 + } 16 + 17 + /** 18 + * Default options for State. Add these during state construction: 19 + * `super(defaultState, options)` 20 + */ 21 + export const DefaultOptions: Options<unknown> = {} 22 + 23 + /** 24 + * State Class 25 + * @example Basic Usage (See more in state.test.ts) 26 + * ```ts 27 + * import State from '@inro/simple-tools/state' 28 + * 29 + * class Counter extends State<{ count: number }> { 30 + * constructor(count: number = 0) { 31 + * super({ count }) // Super params are the initial value of `this.state` 32 + * } 33 + * increment() { 34 + * this.state.count++ 35 + * this.notify() // Triggers all listeners 36 + * } 37 + * } 38 + * 39 + * const counter = new Counter(0) 40 + * counter.addEventListener((state) => { console.log(state.count) }) 41 + * counter.increment() 42 + * ``` 43 + */ 44 + export default class State<InternalState extends object> { 45 + #storage?: Storage<InternalState> 46 + #isBatchingUpdates = false 47 + #isPendingNotification = false 48 + #options = DefaultOptions as Options<InternalState> 49 + #state: InternalState 50 + #watchers: Array<(state: InternalState) => void> = [] 51 + 52 + /** Error returned from load/save */ 53 + error: Error | null = null 54 + /** State has initial value successfully loaded */ 55 + initialized = false 56 + /** State is currently loading data */ 57 + loading = false 58 + /** State is currently saving data */ 59 + saving = false 60 + 61 + /** 62 + * Define public state on initialization 63 + * @param state The initial state for the app 64 + */ 65 + constructor(state: InternalState, options?: Partial<Options<InternalState>>) { 66 + this.#options = { ...this.#options, ...options } 67 + this.#state = state 68 + if (!options?.storage) { 69 + this.initialized = true 70 + } else { 71 + this.#storage = options.storage 72 + this.load(async () => { 73 + const intialState = (await this.#storage?.get()) ?? state 74 + this.initialized = true 75 + return intialState 76 + }) 77 + } 78 + } 79 + 80 + /** 81 + * Returns a reference to the state. 82 + * This is used for easy editing state as well. 83 + */ 84 + get state(): InternalState { 85 + return this.#state 86 + } 87 + 88 + /** Adds an event listener. Triggered by `this.notify` */ 89 + addEventListener(func: (state: InternalState) => void) { 90 + this.#watchers.push(func) 91 + } 92 + 93 + /** 94 + * Runs the provided function in a batch update context 95 + * @param updateFn Function that will make updates to the state 96 + */ 97 + batch<T = void>(updateFn: (state: InternalState) => T): T { 98 + let resp: T 99 + this.#isBatchingUpdates = true 100 + try { 101 + resp = updateFn(this.state) 102 + } finally { 103 + this.#isBatchingUpdates = false 104 + if (this.#isPendingNotification) { 105 + this.notify() 106 + this.#isPendingNotification = false 107 + } 108 + } 109 + return resp 110 + } 111 + 112 + /** Load state from somewhere */ 113 + async load(loader: () => Promise<InternalState>): Promise<void> { 114 + this.loading = true 115 + this.notify() 116 + try { 117 + const result = await loader() 118 + this.#state = { ...this.#state, ...result } 119 + this.error = null 120 + } catch (err) { 121 + this.error = err as Error 122 + } finally { 123 + this.loading = false 124 + this.notify() 125 + } 126 + } 127 + 128 + /** 129 + * Notify only returns a COPY of the state. 130 + * This is because notify is often used to track history over time, 131 + * so a reference to a mutating state is not useful. 132 + */ 133 + notify({ bypassSave = false }: { bypassSave?: boolean } = {}) { 134 + if (this.#isBatchingUpdates) { 135 + this.#isPendingNotification = true 136 + return 137 + } 138 + if (this.initialized && this.#storage && !bypassSave) { 139 + this.save(async () => { 140 + await this.#storage?.set(this.#state) 141 + return true 142 + }) 143 + } 144 + this.#watchers.forEach((cb) => cb({ ...this.#state })) 145 + } 146 + 147 + /** Removes an event listener. */ 148 + removeEventListener(func: (state: InternalState) => void) { 149 + this.#watchers = this.#watchers.filter((watcher) => watcher !== func) 150 + } 151 + 152 + /** Saves state to somewhere */ 153 + async save(saver: (state: InternalState) => Promise<boolean>): Promise<void> { 154 + this.saving = true 155 + this.notify({ bypassSave: true }) 156 + try { 157 + await saver(this.#state) 158 + this.error = null 159 + } catch (err) { 160 + this.error = err as Error 161 + } finally { 162 + this.saving = false 163 + this.notify({ bypassSave: true }) 164 + } 165 + } 166 + 167 + /** Resolves the next time that state is ready */ 168 + waitUntilReady(): Promise<boolean> { 169 + return new Promise((resolve) => { 170 + if (this.initialized && !this.loading && !this.saving) resolve(true) 171 + const listener = () => { 172 + if (this.initialized && !this.loading && !this.saving) { 173 + this.removeEventListener(listener) 174 + resolve(true) 175 + } 176 + } 177 + this.addEventListener(listener) 178 + }) 179 + } 180 + }
+660
packages/store/storage.ts
··· 1 + /** 2 + * @module 3 + * Creates a common interface for interacting with different storage mechanisms. 4 + * Specifically, designed for dealing with key-value mechanisms where the value 5 + * is a collection entity. So if you want to deal with two different items in 6 + * LocalStorage, you would create TWO LocalStorage classes; one for each key. 7 + * 8 + * Provides type-safety by forcing declaration of serialization mechanisms 9 + * and internally tracks metadata (createdAt, updatedAt) for synchronization. 10 + * 11 + * Migrations Execution order: 12 + * 1. Load data from storage 13 + * 2. Extract version from data 14 + * 3. Compare versions 15 + * 4. IF mismatch AND onVersionMismatch exists: 16 + * - Call onVersionMismatch 17 + * - Handle return value 18 + * 5. ELSE IF mismatch: 19 + * - Build migration chain 20 + * - Apply migrations 21 + * 6. Return final data 22 + */ 23 + 24 + export enum MigrationMismatchAction { 25 + Continue = 'continue', 26 + UseCurrent = 'use-current', 27 + UseDefault = 'use-default', 28 + } 29 + const { Continue, UseCurrent, UseDefault } = MigrationMismatchAction 30 + 31 + export enum ErrorCode { 32 + NoPath = 'migration-no-path', 33 + BrokenChain = 'migration-broken-chain', 34 + MigrationFailed = 'migration-failed', 35 + } 36 + 37 + /** 38 + * Version can be a string (e.g., "1.0.0") or a number (e.g., 1, 2, 3) 39 + */ 40 + export type Version = string | number 41 + 42 + /** 43 + * Default version comparison function 44 + * Returns: negative if v1 < v2, zero if v1 === v2, positive if v1 > v2 45 + */ 46 + export function defaultCompareVersions( 47 + v1: Version | undefined, 48 + v2: Version | undefined, 49 + ): number { 50 + // Handle undefined (treat as version 0) 51 + if (v1 === undefined && v2 === undefined) return 0 52 + if (v1 === undefined) return -1 53 + if (v2 === undefined) return 1 54 + 55 + // Both are numbers 56 + if (typeof v1 === 'number' && typeof v2 === 'number') { 57 + return v1 - v2 58 + } 59 + 60 + // Both are strings 61 + if (typeof v1 === 'string' && typeof v2 === 'string') { 62 + if (v1 === v2) return 0 63 + return v1 < v2 ? -1 : 1 64 + } 65 + 66 + // Mixed types: convert to strings and compare 67 + const s1 = String(v1) 68 + const s2 = String(v2) 69 + if (s1 === s2) return 0 70 + return s1 < s2 ? -1 : 1 71 + } 72 + 73 + /** 74 + * Internal metadata wrapper for storage items 75 + */ 76 + export interface StorageMetadata<T> { 77 + /** The actual user data */ 78 + data: T 79 + /** When the item was first created */ 80 + createdAt: string 81 + /** When the item was last updated */ 82 + updatedAt: string 83 + /** Schema version of the data (optional, used for migrations) */ 84 + version?: Version 85 + } 86 + 87 + /** 88 + * Migration function that upgrades data from one version to the next 89 + * @param data The data to migrate (type unknown as it may be from an older schema) 90 + * @returns Migrated data (unknown since intermediate steps may have different types) 91 + */ 92 + export type MigrationFunction = (data: unknown) => unknown 93 + 94 + /** 95 + * Single migration step in a migration chain 96 + */ 97 + export interface MigrationStep { 98 + /** Version this migration upgrades FROM (undefined means no version field exists) */ 99 + fromVersion: Version | undefined 100 + /** Version this migration upgrades TO */ 101 + toVersion: Version 102 + /** Migration function to apply */ 103 + migrate: MigrationFunction 104 + /** 105 + * Extract version from data for this specific migration step 106 + * Useful when different versions store version info differently 107 + * If not provided, falls back to the global extractVersion 108 + */ 109 + extractVersion?: (data: unknown) => Version | undefined 110 + } 111 + 112 + /** 113 + * Configuration for schema migrations 114 + */ 115 + export interface MigrationConfig<T> { 116 + /** Current schema version */ 117 + currentVersion: Version 118 + /** 119 + * Ordered array of migration steps 120 + * Each step migrates from version N to version N+1 121 + * Example: [v0->v1, v1->v2, v2->v3] will chain automatically 122 + */ 123 + migrations: MigrationStep[] 124 + /** 125 + * Default function to extract version from data 126 + * Can be overridden per migration step 127 + */ 128 + extractVersion?: (data: unknown) => Version | undefined 129 + /** 130 + * Function to compare two versions 131 + * Returns: negative if v1 < v2, zero if v1 === v2, positive if v1 > v2 132 + * 133 + * Default implementation: 134 + * - If both are numbers: numeric comparison 135 + * - If both are strings: lexicographic comparison 136 + * - Mixed types: convert to strings and compare 137 + * 138 + * Override for semantic versioning or custom version schemes 139 + */ 140 + compareVersions?: (v1: Version | undefined, v2: Version | undefined) => number 141 + /** 142 + * Called when stored data version doesn't match current version 143 + * Provides opportunity to handle version mismatches before attempting migration chain 144 + * 145 + * Common use cases: 146 + * - Handle data from newer app versions (e.g., trigger cache refresh) 147 + * - Implement custom fallback strategies 148 + * - Log version mismatch analytics 149 + * 150 + * @param dataVersion Version of the stored data 151 + * @param currentVersion Current app/schema version 152 + * @param data The stored data that has version mismatch 153 + * @returns Strategy for handling the mismatch: 154 + * - 'continue': Proceed with normal migration chain (may fail if no path exists) 155 + * - 'use-current': Use current data as-is (bypass migration) 156 + * - 'use-default': Use default value (ignore stored data) 157 + * - Custom data: Use provided data directly 158 + */ 159 + onVersionMismatch?: ( 160 + dataVersion: Version | undefined, 161 + currentVersion: Version, 162 + data: unknown, 163 + ) => T | MigrationMismatchAction 164 + /** 165 + * Called after migration/version handling is complete, before returning final data 166 + * Useful for final parsing/validation (e.g., Zod transforms, date string parsing) 167 + * 168 + * This runs regardless of whether: 169 + * - No migration was needed (versions matched) 170 + * - onVersionMismatch returned 'use-current' 171 + * - Migration chain was applied 172 + * - Custom data was returned from onVersionMismatch 173 + * 174 + * @param data The data after migration processing 175 + * @param wasMigrated Whether any migration/transformation occurred 176 + * @returns Final processed data 177 + */ 178 + onMigrationComplete?: (data: unknown, wasMigrated: boolean) => T 179 + } 180 + 181 + /** 182 + * Storage error information passed to onError handler 183 + */ 184 + export interface StorageError { 185 + /** Error code identifying the type of error */ 186 + code: ErrorCode 187 + /** Human-readable error message */ 188 + message: string 189 + /** Version being migrated from (if applicable) */ 190 + fromVersion?: Version 191 + /** Version being migrated to (if applicable) */ 192 + toVersion?: Version 193 + /** Original error cause (if migration function threw) */ 194 + cause?: Error 195 + /** The data that failed to migrate/process */ 196 + data: unknown 197 + } 198 + 199 + /** 200 + * Error class for storage-related errors 201 + * Extends Error with StorageError properties 202 + */ 203 + export class StorageErrorClass extends Error implements StorageError { 204 + code: ErrorCode 205 + fromVersion?: Version 206 + toVersion?: Version 207 + override cause?: Error 208 + data: unknown 209 + 210 + constructor(error: StorageError) { 211 + super(error.message) 212 + this.name = 'StorageError' 213 + this.code = error.code 214 + this.fromVersion = error.fromVersion 215 + this.toVersion = error.toVersion 216 + this.cause = error.cause 217 + this.data = error.data 218 + } 219 + } 220 + 221 + /** 222 + * Provided functionality to tell storage how to interact with your data 223 + */ 224 + export interface StorageProps<T> { 225 + /** Default value, if the item doesn't exist within storage */ 226 + defaultValue: T 227 + /** Function for deserializing data from the external data source */ 228 + deserialize: (str: string) => T 229 + /** Key name that external data is stored under */ 230 + name: string 231 + /** Function for serializing data to external data source */ 232 + serialize: (toSerialize: T) => string 233 + /** Function for determining whether a value matches our expected data */ 234 + verify: (toCheck: unknown) => boolean 235 + /** 236 + * Handler for exceptional storage errors (migrations, etc.) 237 + * If not provided, errors will throw by default 238 + * Return value will be used as the data, or throw to propagate error 239 + * 240 + * Common patterns: 241 + * - Return defaultValue to recover from errors 242 + * - Log and throw custom error 243 + * - Return data as-is (dangerous for broken migrations!) 244 + */ 245 + onError?: (error: StorageError) => T 246 + /** Optional migration configuration for schema versioning */ 247 + migrations?: MigrationConfig<T> 248 + } 249 + 250 + /** 251 + * Storage Class is not meant to be used by itself. Extend it with different 252 + * functionality and different storage systems. 253 + * 254 + * @example 255 + * ```ts 256 + * import LocalStorage from '@inro/simple-tools/storage/local-storage' 257 + * 258 + * const store = new LocalStorage<{ count: number } | null>({ 259 + * name: 'count', 260 + * defaultValue: null, 261 + * deserialize: (str) => str ? JSON.parse(str) : null, 262 + * serialize: (state) => JSON.stringify(state), 263 + * verify: (state) => Boolean((state as { count?: number })?.count), 264 + * }) 265 + * await store.set({ count: 5 }) 266 + * await store.get() 267 + * ```` 268 + */ 269 + export default class Storage<T> implements StorageProps<T> { 270 + /** Initializes storage with props */ 271 + constructor(props: StorageProps<T>) { 272 + this.name = props.name 273 + this.defaultValue = props.defaultValue 274 + this.deserialize = props.deserialize 275 + this.serialize = props.serialize 276 + this.verify = props.verify 277 + this.onError = props.onError 278 + this.migrations = props.migrations 279 + } 280 + 281 + /** Key for finding in storage */ 282 + name: string 283 + 284 + /** Default value if there is no value in storage */ 285 + defaultValue: T 286 + 287 + /** Transform from database entity to js object */ 288 + deserialize: (str: string) => T 289 + 290 + /** Transform from js object to database entity */ 291 + serialize: (toStringify: T) => string 292 + 293 + /** Predicate function that returns true if it is the correct entity */ 294 + verify: (toCheck: unknown) => boolean 295 + 296 + /** Error handler for exceptional storage errors */ 297 + onError?: (error: StorageError) => T 298 + 299 + /** Optional migration configuration */ 300 + migrations?: MigrationConfig<T> 301 + 302 + /** Check if a value exists in storage */ 303 + has(): Promise<boolean> { 304 + throw new Error('not implemented') 305 + } 306 + 307 + /** Retrieve a value from storage */ 308 + get(): Promise<T> { 309 + throw new Error('not implemented') 310 + } 311 + 312 + /** Add a value to storage */ 313 + set(_value: T): Promise<boolean> { 314 + throw new Error('not implemented') 315 + } 316 + 317 + /** Remove a value from storage */ 318 + remove(): Promise<boolean> { 319 + throw new Error('not implemented') 320 + } 321 + 322 + /** Deserialize, returning defaultValue if an error occurs */ 323 + safeParse(toParse: string): T { 324 + try { 325 + if (!toParse) return this.defaultValue 326 + return this.deserialize(toParse) 327 + } catch { 328 + return this.defaultValue 329 + } 330 + } 331 + 332 + /** Get current ISO timestamp */ 333 + protected now(): string { 334 + return new Date().toISOString() 335 + } 336 + 337 + /** 338 + * Get the version comparison function (user-provided or default) 339 + */ 340 + protected getCompareVersions(): ( 341 + v1: Version | undefined, 342 + v2: Version | undefined, 343 + ) => number { 344 + return this.migrations?.compareVersions ?? defaultCompareVersions 345 + } 346 + 347 + /** 348 + * Extract version from data using step-specific or global extractor 349 + * @param data The data to extract version from 350 + * @param step Optional migration step with its own extractor 351 + * @returns Version or undefined 352 + */ 353 + protected extractVersionFromData( 354 + data: unknown, 355 + step?: MigrationStep, 356 + ): Version | undefined { 357 + // Try step-specific extractor first 358 + if (step?.extractVersion) { 359 + const version = step.extractVersion(data) 360 + if (version !== undefined) return version 361 + } 362 + 363 + // Try global extractor 364 + if (this.migrations?.extractVersion) { 365 + const version = this.migrations.extractVersion(data) 366 + if (version !== undefined) return version 367 + } 368 + 369 + // If no specific step provided, try all step extractors 370 + if (!step && this.migrations?.migrations) { 371 + for (const migrationStep of this.migrations.migrations) { 372 + if (migrationStep.extractVersion) { 373 + const version = migrationStep.extractVersion(data) 374 + if (version !== undefined) return version 375 + } 376 + } 377 + } 378 + 379 + return undefined 380 + } 381 + 382 + /** 383 + * Build a chain of migrations from start version to end version 384 + * @param fromVersion Starting version (undefined for v0/no version) 385 + * @param toVersion Target version 386 + * @param data The data being migrated (for error reporting) 387 + * @returns Array of migration steps to apply in order 388 + * @throws StorageErrorClass if no migration path exists or chain is broken 389 + */ 390 + protected buildMigrationChain( 391 + fromVersion: Version | undefined, 392 + toVersion: Version, 393 + data: unknown, 394 + ): MigrationStep[] { 395 + if (!this.migrations) return [] 396 + 397 + const compareVersions = this.getCompareVersions() 398 + const chain: MigrationStep[] = [] 399 + let currentVersion = fromVersion 400 + 401 + // Keep finding next migration step until we reach target version 402 + while (compareVersions(currentVersion, toVersion) !== 0) { 403 + const nextStep = this.migrations.migrations.find( 404 + (step) => compareVersions(step.fromVersion, currentVersion) === 0, 405 + ) 406 + 407 + if (!nextStep) { 408 + // No migration path found 409 + if (chain.length === 0) { 410 + throw new StorageErrorClass({ 411 + code: ErrorCode.NoPath, 412 + message: 413 + `No migration found from version ${currentVersion} to ${toVersion}`, 414 + fromVersion: currentVersion, 415 + toVersion, 416 + data, 417 + }) 418 + } else { 419 + throw new StorageErrorClass({ 420 + code: ErrorCode.BrokenChain, 421 + message: 422 + `Migration chain broken at version ${currentVersion}, cannot reach ${toVersion}`, 423 + fromVersion: currentVersion, 424 + toVersion, 425 + data, 426 + }) 427 + } 428 + } 429 + 430 + chain.push(nextStep) 431 + currentVersion = nextStep.toVersion 432 + } 433 + 434 + return chain 435 + } 436 + 437 + /** 438 + * Apply migrations by chaining from old version to current version 439 + * Example: v0 -> v1 -> v2 -> v3 440 + * @param data The data to migrate 441 + * @param dataVersion The version of the data 442 + * @returns Migrated data 443 + * @throws StorageErrorClass if migration fails and no onError handler is provided 444 + */ 445 + protected applyMigrations( 446 + data: unknown, 447 + dataVersion: Version | undefined, 448 + ): T { 449 + if (!this.migrations) return data as T 450 + 451 + const compareVersions = this.getCompareVersions() 452 + const currentVersion = this.migrations.currentVersion 453 + let finalData: unknown = data 454 + let wasMigrated = false 455 + 456 + // No migration needed if versions are equal 457 + if (compareVersions(dataVersion, currentVersion) === 0) { 458 + return this.migrations.onMigrationComplete 459 + ? this.migrations.onMigrationComplete(data, false) 460 + : data as T 461 + } 462 + 463 + // Check if there's a version mismatch handler 464 + if (this.migrations.onVersionMismatch) { 465 + const result = this.migrations.onVersionMismatch( 466 + dataVersion, 467 + currentVersion, 468 + data, 469 + ) 470 + 471 + if (result === UseCurrent) { 472 + finalData = data 473 + wasMigrated = false 474 + } else if (result === UseDefault) { 475 + finalData = this.defaultValue 476 + wasMigrated = true 477 + } else if (result !== Continue) { 478 + finalData = result 479 + wasMigrated = true 480 + } else { 481 + // Continue with normal migration chain 482 + try { 483 + finalData = this.performMigrationChain( 484 + data, 485 + dataVersion, 486 + currentVersion, 487 + ) 488 + wasMigrated = true 489 + } catch (error) { 490 + if (error instanceof StorageErrorClass && this.onError) { 491 + finalData = this.onError(error) 492 + wasMigrated = true 493 + } else { 494 + throw error 495 + } 496 + } 497 + } 498 + } else { 499 + // No onVersionMismatch handler, proceed with migration chain 500 + try { 501 + finalData = this.performMigrationChain( 502 + data, 503 + dataVersion, 504 + currentVersion, 505 + ) 506 + wasMigrated = true 507 + } catch (error) { 508 + if (error instanceof StorageErrorClass && this.onError) { 509 + finalData = this.onError(error) 510 + wasMigrated = true 511 + } else { 512 + throw error 513 + } 514 + } 515 + } 516 + 517 + // Apply final processing hook if provided 518 + return this.migrations.onMigrationComplete 519 + ? this.migrations.onMigrationComplete(finalData, wasMigrated) 520 + : finalData as T 521 + } 522 + 523 + /** 524 + * Helper method to perform the actual migration chain 525 + */ 526 + private performMigrationChain( 527 + data: unknown, 528 + dataVersion: Version | undefined, 529 + currentVersion: Version, 530 + ): unknown { 531 + const chain = this.buildMigrationChain(dataVersion, currentVersion, data) 532 + 533 + let migratedData = data 534 + for (const step of chain) { 535 + const { fromVersion, toVersion } = step 536 + console.info(`Applying migration: ${fromVersion} -> ${toVersion}`) 537 + try { 538 + migratedData = step.migrate(migratedData) 539 + } catch (error) { 540 + console.error(`Migration failed: ${fromVersion} -> ${toVersion}`, error) 541 + throw new StorageErrorClass({ 542 + code: ErrorCode.MigrationFailed, 543 + message: `Migration failed at step ${fromVersion} -> ${toVersion}: ${ 544 + error instanceof Error ? error.message : String(error) 545 + }`, 546 + fromVersion, 547 + toVersion, 548 + cause: error instanceof Error ? error : undefined, 549 + data: migratedData, 550 + }) 551 + } 552 + } 553 + 554 + return migratedData 555 + } 556 + 557 + /** Wrap user data with metadata */ 558 + protected wrapWithMetadata( 559 + data: T, 560 + existingMetadata?: StorageMetadata<T>, 561 + ): StorageMetadata<T> { 562 + const now = this.now() 563 + return { 564 + data, 565 + createdAt: existingMetadata?.createdAt ?? now, 566 + updatedAt: now, 567 + version: this.migrations?.currentVersion, 568 + } 569 + } 570 + 571 + /** Parse metadata wrapper, returning defaultValue if invalid */ 572 + parseMetadata(toParse: string): StorageMetadata<T> { 573 + try { 574 + if (!toParse) return this.wrapWithMetadata(this.defaultValue) 575 + 576 + const parsed = JSON.parse(toParse) 577 + 578 + if ( 579 + parsed && typeof parsed === 'object' && 'data' in parsed && 580 + 'createdAt' in parsed && 'updatedAt' in parsed 581 + ) { 582 + let data: unknown = typeof parsed.data === 'string' 583 + ? this.safeParse(parsed.data) 584 + : parsed.data 585 + 586 + // Extract version from metadata wrapper or from data itself 587 + const metadataVersion = parsed.version 588 + const dataVersion = metadataVersion ?? this.extractVersionFromData(data) 589 + 590 + if (this.migrations) { 591 + const compare = this.getCompareVersions() 592 + if (compare(dataVersion, this.migrations.currentVersion) !== 0) { 593 + data = this.applyMigrations(data, dataVersion) 594 + } 595 + } 596 + 597 + if (this.verify(data)) { 598 + return { 599 + data: data as T, 600 + createdAt: parsed.createdAt, 601 + updatedAt: parsed.updatedAt, 602 + version: this.migrations?.currentVersion, 603 + } 604 + } 605 + } 606 + 607 + // If JSON.parse succeeded but it's not valid metadata format, 608 + // treat it as raw data (backward compatibility) 609 + let rawData: unknown = this.safeParse(toParse) 610 + const dataVersion = this.extractVersionFromData(rawData) 611 + 612 + if (this.migrations) { 613 + const compare = this.getCompareVersions() 614 + if (compare(dataVersion, this.migrations.currentVersion) !== 0) { 615 + rawData = this.applyMigrations(rawData, dataVersion) 616 + } 617 + } 618 + 619 + return this.wrapWithMetadata(rawData as T) 620 + } catch (error) { 621 + // Rethrow StorageErrors - they've already been through onError handler 622 + if (error instanceof StorageErrorClass) throw error 623 + 624 + // JSON.parse failed, so this might be raw data (backward compatibility) 625 + let rawData: unknown = this.safeParse(toParse) 626 + const dataVersion = this.extractVersionFromData(rawData) 627 + 628 + if (this.migrations) { 629 + const compare = this.getCompareVersions() 630 + if (compare(dataVersion, this.migrations.currentVersion) !== 0) { 631 + rawData = this.applyMigrations(rawData, dataVersion) 632 + } 633 + } 634 + 635 + return this.wrapWithMetadata(rawData as T) 636 + } 637 + } 638 + 639 + /** Serialize metadata wrapper */ 640 + protected serializeMetadata(metadata: StorageMetadata<T>): string { 641 + return JSON.stringify({ 642 + data: this.serialize(metadata.data), 643 + createdAt: metadata.createdAt, 644 + updatedAt: metadata.updatedAt, 645 + version: metadata.version, 646 + }) 647 + } 648 + 649 + /** Get metadata for the stored item (useful for sync implementations) */ 650 + getMetadata(): Promise<StorageMetadata<T> | null> { 651 + throw new Error('not implemented') 652 + } 653 + 654 + /** Get metadata, creating it with current timestamp if no data exists */ 655 + async getOrCreateMetadata(): Promise<StorageMetadata<T>> { 656 + const metadata = await this.getMetadata() 657 + if (metadata === null) return this.wrapWithMetadata(this.defaultValue) 658 + return metadata 659 + } 660 + }
+102
packages/store/storage/deno_fs_storage.test.ts
··· 1 + import { assertEquals, assertRejects } from '@std/assert' 2 + import { join } from '@std/path' 3 + import DenoFsStorage from './deno_fs_storage.ts' 4 + 5 + const TEST_DIR = join(Deno.makeTempDirSync(), 'deno-fs-storage-test') 6 + const filePath = join(TEST_DIR, 'test-data.json') 7 + 8 + Deno.test.afterEach(() => { 9 + try { 10 + Deno.removeSync(TEST_DIR, { recursive: true }) 11 + } catch { 12 + // Ignore errors if directory doesn't exist 13 + } 14 + }) 15 + 16 + function createTestStorage() { 17 + Deno.mkdirSync(TEST_DIR, { recursive: true }) 18 + 19 + return new DenoFsStorage<{ count: number }>({ 20 + name: filePath, 21 + defaultValue: { count: 0 }, 22 + deserialize: (str) => JSON.parse(str), 23 + serialize: (data) => JSON.stringify(data), 24 + verify: (data) => 25 + typeof data === 'object' && data !== null && 'count' in data, 26 + }) 27 + } 28 + 29 + Deno.test('DenoFsStorage class initialization', () => { 30 + const storage = createTestStorage() 31 + assertEquals(storage.name, filePath) 32 + assertEquals(storage.defaultValue, { count: 0 }) 33 + }) 34 + 35 + Deno.test('has() returns false when file does not exist', async () => { 36 + const storage = createTestStorage() 37 + assertEquals(await storage.has(), false) 38 + }) 39 + 40 + Deno.test('has() returns true when file exists', async () => { 41 + const storage = createTestStorage() 42 + await storage.set({ count: 1 }) 43 + assertEquals(await storage.has(), true) 44 + }) 45 + 46 + Deno.test('get() returns defaultValue when file does not exist', async () => { 47 + const storage = createTestStorage() 48 + assertEquals(await storage.get(), { count: 0 }) 49 + }) 50 + 51 + Deno.test('set() and get() works with valid data', async () => { 52 + const storage = createTestStorage() 53 + await storage.set({ count: 5 }) 54 + assertEquals(await storage.get(), { count: 5 }) 55 + }) 56 + 57 + Deno.test('set() creates parent directories if they do not exist', async () => { 58 + const nestedPath = join(TEST_DIR, 'nested', 'path', 'test-data.json') 59 + const storage = new DenoFsStorage<{ count: number }>({ 60 + name: nestedPath, 61 + defaultValue: { count: 0 }, 62 + deserialize: (str) => JSON.parse(str), 63 + serialize: (data) => JSON.stringify(data), 64 + verify: (data) => 65 + typeof data === 'object' && data !== null && 'count' in data, 66 + }) 67 + 68 + await storage.set({ count: 10 }) 69 + assertEquals(await storage.get(), { count: 10 }) 70 + }) 71 + 72 + Deno.test('set() rejects invalid values', async () => { 73 + const storage = createTestStorage() 74 + await assertRejects( 75 + async () => { 76 + await storage.set({ invalid: true } as unknown as { count: number }) 77 + }, 78 + Error, 79 + 'invalid value', 80 + ) 81 + }) 82 + 83 + Deno.test('remove() works as expected', async () => { 84 + const storage = createTestStorage() 85 + await storage.set({ count: 5 }) 86 + const result = await storage.remove() 87 + assertEquals(result, true) 88 + assertEquals(await storage.has(), false) 89 + }) 90 + 91 + Deno.test('remove() succeeds when file does not exist', async () => { 92 + const storage = createTestStorage() 93 + const result = await storage.remove() 94 + assertEquals(result, true) 95 + }) 96 + 97 + Deno.test('safeParse handles parsing correctly', () => { 98 + const storage = createTestStorage() 99 + assertEquals(storage.safeParse('{"count":5}'), { count: 5 }, 'parses JSON') 100 + assertEquals(storage.safeParse(''), { count: 0 }, 'Handles empty strings') 101 + assertEquals(storage.safeParse('blah'), { count: 0 }, 'invalid JSON') 102 + })
+116
packages/store/storage/deno_fs_storage.ts
··· 1 + /** 2 + * @module stores data as a json documents on the fs 3 + */ 4 + import Storage, { type StorageMetadata, type StorageProps } from '../storage.ts' 5 + import { ensureFile } from '@std/fs/ensure-file' 6 + 7 + /** 8 + * A mechanism for using Deno's file system as a Storage instance. 9 + * The `name` property can be used as a path for the data file. 10 + * @example 11 + * ```ts 12 + * import { Todo } from '@inro/simple-tools/todolist' 13 + * import DenoFsStorage from '@inro/simple-tools/storage/deno-fs' 14 + * 15 + * const storage = new DenoFsStorage<Todo[]>({ 16 + * name: 'todos', 17 + * defaultValue: [], 18 + * deserialize: (str) => JSON.parse(str), 19 + * serialize: (todos) => JSON.stringify(todos), 20 + * verify: (todos) => Array.isArray(todos), 21 + * }) 22 + * await storage.set([{ name: '1', description: 'do it', isDone: false }]) 23 + * const todos = await storage.get() 24 + * ``` 25 + */ 26 + export default class DenoFsStorage<T> extends Storage<T> { 27 + /** Initializes storage */ 28 + constructor(props: StorageProps<T>) { 29 + super(props) 30 + } 31 + 32 + /** Check if a value exists in storage */ 33 + override async has(): Promise<boolean> { 34 + try { 35 + await Deno.stat(this.name) 36 + return true 37 + } catch (error) { 38 + if (error instanceof Deno.errors.NotFound) { 39 + return false 40 + } 41 + throw error 42 + } 43 + } 44 + 45 + /** Retrieve a value from storage */ 46 + override async get(): Promise<T> { 47 + try { 48 + const value = await Deno.readTextFile(this.name) 49 + return this.parseMetadata(value).data 50 + } catch (error) { 51 + if (error instanceof Deno.errors.NotFound) { 52 + return this.defaultValue 53 + } 54 + throw error 55 + } 56 + } 57 + 58 + /** Add a value to storage */ 59 + override async set(value: T): Promise<boolean> { 60 + if (!this.verify(value)) throw new Error('invalid value') 61 + 62 + try { 63 + // Get existing metadata to preserve createdAt 64 + let existingMetadata: StorageMetadata<T> | undefined 65 + try { 66 + const existingValue = await Deno.readTextFile(this.name) 67 + existingMetadata = this.parseMetadata(existingValue) 68 + } catch (error) { 69 + if (!(error instanceof Deno.errors.NotFound)) { 70 + throw error 71 + } 72 + // File doesn't exist, existingMetadata remains undefined 73 + } 74 + 75 + const metadata = this.wrapWithMetadata(value, existingMetadata) 76 + 77 + await ensureFile(this.name) 78 + 79 + await Deno.writeTextFile(this.name, this.serializeMetadata(metadata)) 80 + return true 81 + } catch (error) { 82 + throw new Error( 83 + `Failed to write to file: ${ 84 + error instanceof Error ? error.message : String(error) 85 + }`, 86 + ) 87 + } 88 + } 89 + 90 + /** Remove a value from storage */ 91 + override async remove(): Promise<boolean> { 92 + try { 93 + await Deno.remove(this.name) 94 + return true 95 + } catch (error) { 96 + if (error instanceof Deno.errors.NotFound) { 97 + // If the file doesn't exist, we can consider it successfully removed 98 + return true 99 + } 100 + throw error 101 + } 102 + } 103 + 104 + /** Get metadata for the stored item */ 105 + override async getMetadata(): Promise<StorageMetadata<T> | null> { 106 + try { 107 + const value = await Deno.readTextFile(this.name) 108 + return this.parseMetadata(value) 109 + } catch (error) { 110 + if (error instanceof Deno.errors.NotFound) { 111 + return null 112 + } 113 + throw error 114 + } 115 + } 116 + }
+204
packages/store/storage/index_db_storage.ts
··· 1 + /** 2 + * @module stores data in IndexedDB 3 + */ 4 + import Storage, { type StorageMetadata, type StorageProps } from '../storage.ts' 5 + 6 + /** 7 + * A mechanism for using IndexedDB as a Storage instance. 8 + * ```ts 9 + * import IndexDBStorage from '@inro/simple-tools/storage/idb' 10 + * 11 + * const storage = new IndexDBStorage<{ data: string[] }>({ 12 + * name: 'myData', 13 + * defaultValue: { data: [] }, 14 + * deserialize: (str) => JSON.parse(str), 15 + * serialize: (obj) => JSON.stringify(obj), 16 + * verify: (obj) => Array.isArray((obj as { data?: unknown[] })?.data), 17 + * }, { 18 + * dbName: 'MyAppDB', 19 + * dbVersion: 1, 20 + * storeName: 'myStore' 21 + * }); 22 + * ``` 23 + */ 24 + export default class IndexDBStorage<T> extends Storage<T> { 25 + /** Database name */ 26 + #dbName: string 27 + /** Database version */ 28 + #dbVersion: number 29 + /** Object store name */ 30 + #storeName: string 31 + /** Database connection */ 32 + #dbConnection: Promise<IDBDatabase> | null = null 33 + 34 + /** 35 + * Initializes storage 36 + * @param props Storage properties 37 + * @param options IndexDB specific options 38 + */ 39 + constructor( 40 + props: StorageProps<T>, 41 + options: { dbName: string; dbVersion?: number; storeName?: string } = { 42 + dbName: 'SimpleToolsDB', 43 + dbVersion: 1, 44 + storeName: 'keyValueStore', 45 + }, 46 + ) { 47 + super(props) 48 + this.#dbName = options.dbName 49 + this.#dbVersion = options.dbVersion || 1 50 + this.#storeName = options.storeName || 'keyValueStore' 51 + } 52 + 53 + /** Gets or creates a database connection */ 54 + #getDB(): Promise<IDBDatabase> { 55 + if (this.#dbConnection) return this.#dbConnection 56 + 57 + this.#dbConnection = new Promise<IDBDatabase>((resolve, reject) => { 58 + const request = globalThis.indexedDB.open(this.#dbName, this.#dbVersion) 59 + 60 + request.onerror = () => { 61 + reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`)) 62 + this.#dbConnection = null 63 + } 64 + 65 + request.onupgradeneeded = (event) => { 66 + const db = (event.target as IDBOpenDBRequest).result 67 + if (!db.objectStoreNames.contains(this.#storeName)) { 68 + db.createObjectStore(this.#storeName) 69 + } 70 + } 71 + 72 + request.onsuccess = () => resolve(request.result) 73 + }) 74 + 75 + return this.#dbConnection 76 + } 77 + 78 + /** Perform a transaction on the object store */ 79 + #transaction = async <R>( 80 + mode: IDBTransactionMode, 81 + callback: (store: IDBObjectStore) => IDBRequest<R>, 82 + ): Promise<R> => { 83 + const db = await this.#getDB() 84 + return new Promise<R>((resolve, reject) => { 85 + const transaction = db.transaction(this.#storeName, mode) 86 + const store = transaction.objectStore(this.#storeName) 87 + const request = callback(store) 88 + 89 + request.onsuccess = () => resolve(request.result) 90 + request.onerror = () => reject(request.error) 91 + }) 92 + } 93 + 94 + /** Check if a value exists in storage */ 95 + override async has(): Promise<boolean> { 96 + try { 97 + const result = await this.#transaction( 98 + 'readonly', 99 + (store) => store.getKey(this.name), 100 + ) 101 + return result !== undefined 102 + } catch (error) { 103 + console.error('IndexDBStorage.has error:', error) 104 + return false 105 + } 106 + } 107 + 108 + /** Retrieve a value from storage */ 109 + override async get(): Promise<T> { 110 + try { 111 + const result = await this.#transaction<string | null | undefined>( 112 + 'readonly', 113 + (store) => store.get(this.name), 114 + ) 115 + 116 + if (result == null) return this.defaultValue 117 + 118 + if (typeof result !== 'string') { 119 + console.error('Unexpected non-string value from IndexedDB:', result) 120 + return this.defaultValue 121 + } 122 + 123 + const metadata = this.parseMetadata(result) 124 + return metadata.data 125 + } catch (error) { 126 + console.error('IndexDBStorage.get error:', error) 127 + return this.defaultValue 128 + } 129 + } 130 + 131 + /** Add a value to storage */ 132 + override async set(value: T): Promise<boolean> { 133 + if (!this.verify(value)) throw new Error('invalid value') 134 + 135 + try { 136 + // Get existing metadata to preserve createdAt 137 + const existingResult = await this.#transaction<string | null | undefined>( 138 + 'readonly', 139 + (store) => store.get(this.name), 140 + ).catch(() => null) 141 + 142 + const existingMetadata = 143 + existingResult && typeof existingResult === 'string' 144 + ? this.parseMetadata(existingResult) 145 + : undefined 146 + 147 + // Wrap with metadata 148 + const metadata = this.wrapWithMetadata(value, existingMetadata) 149 + 150 + await this.#transaction( 151 + 'readwrite', 152 + (store) => store.put(this.serializeMetadata(metadata), this.name), 153 + ) 154 + return true 155 + } catch (error) { 156 + console.error('IndexDBStorage.set error:', error) 157 + throw error 158 + } 159 + } 160 + 161 + /** Remove a value from storage */ 162 + override async remove(): Promise<boolean> { 163 + try { 164 + await this.#transaction('readwrite', (store) => store.delete(this.name)) 165 + return true 166 + } catch (error) { 167 + console.error('IndexDBStorage.remove error:', error) 168 + return false 169 + } 170 + } 171 + 172 + /** Get metadata for the stored item */ 173 + override async getMetadata(): Promise<StorageMetadata<T> | null> { 174 + try { 175 + const result = await this.#transaction<string | null | undefined>( 176 + 'readonly', 177 + (store) => store.get(this.name), 178 + ) 179 + 180 + if (result == null) { 181 + return null 182 + } 183 + 184 + if (typeof result !== 'string') { 185 + console.error('Unexpected non-string value from IndexedDB:', result) 186 + return null 187 + } 188 + 189 + return this.parseMetadata(result) 190 + } catch (error) { 191 + console.error('IndexDBStorage.getMetadata error:', error) 192 + return null 193 + } 194 + } 195 + 196 + /** Close the database connection */ 197 + async close(): Promise<void> { 198 + if (this.#dbConnection) { 199 + const db = await this.#dbConnection 200 + db.close() 201 + this.#dbConnection = null 202 + } 203 + } 204 + }
+60
packages/store/storage/local_storage.test.ts
··· 1 + import { assertEquals } from '@std/assert' 2 + import LocalStorage from './local_storage.ts' 3 + 4 + Deno.test.afterEach(() => { 5 + localStorage.clear() 6 + }) 7 + 8 + function createTestStorage() { 9 + return new LocalStorage<{ count: number }>({ 10 + name: 'local-storage-test', 11 + defaultValue: { count: 0 }, 12 + deserialize: (str) => JSON.parse(str), 13 + serialize: (data) => JSON.stringify(data), 14 + verify: (data) => 15 + typeof data === 'object' && data !== null && 'count' in data, 16 + }) 17 + } 18 + 19 + Deno.test('LocalStorage class initialization', () => { 20 + const storage = createTestStorage() 21 + assertEquals(storage.name, 'local-storage-test') 22 + assertEquals(storage.defaultValue, { count: 0 }) 23 + }) 24 + 25 + Deno.test('safeParse handles parsing correctly', () => { 26 + const storage = createTestStorage() 27 + assertEquals(storage.safeParse('{"count":5}'), { count: 5 }, 'parses JSON') 28 + assertEquals(storage.safeParse(''), { count: 0 }, 'Handles empty strings') 29 + assertEquals(storage.safeParse('blah'), { count: 0 }, 'invalid JSON') 30 + }) 31 + 32 + Deno.test('has() returns false when item does not exist', async () => { 33 + const storage = createTestStorage() 34 + assertEquals(await storage.has(), false) 35 + }) 36 + 37 + Deno.test('get() returns defaultValue when item does not exist', async () => { 38 + const storage = createTestStorage() 39 + assertEquals(await storage.get(), { count: 0 }) 40 + }) 41 + 42 + Deno.test('set() rejects invalid values', async () => { 43 + const storage = createTestStorage() 44 + try { 45 + await storage.set({ invalid: true } as unknown as { count: number }) 46 + assertEquals(true, false, 'Should throw error for invalid data') 47 + } catch (error) { 48 + if (error instanceof Error) { 49 + assertEquals(error.message, 'invalid value') 50 + } else { 51 + assertEquals(true, false, 'Expected an Error instance') 52 + } 53 + } 54 + }) 55 + 56 + Deno.test('remove() works as expected', async () => { 57 + const storage = createTestStorage() 58 + const result = await storage.remove() 59 + assertEquals(result, true) 60 + })
+81
packages/store/storage/local_storage.ts
··· 1 + /** 2 + * @module stores data in localStorage 3 + */ 4 + import Storage, { type StorageMetadata, type StorageProps } from '../storage.ts' 5 + 6 + /** 7 + * A mechanism for using localStorage as a Storage instance. 8 + * @example 9 + * ```ts 10 + * import LocalStorage from './local_storage.ts' 11 + * 12 + * type User = { id: number, name: string } | null 13 + * const storage = new LocalStorage<User>({ 14 + * name: 'current-user', 15 + * defaultValue: null, 16 + * deserialize: (str) => JSON.parse(str), 17 + * serialize: (user) => JSON.stringify(user), 18 + * verify: (user) => user === null || (typeof user === 'object' && 'id' in user), 19 + * }) 20 + * 21 + * await storage.set({ id: 1, name: 'User' }) 22 + * const user = await storage.get() 23 + * ``` 24 + */ 25 + export default class LocalStorage<T> extends Storage<T> { 26 + /** Initializes storage */ 27 + constructor(props: StorageProps<T>) { 28 + super(props) 29 + } 30 + 31 + /** Check if a value exists in storage */ 32 + override has(): Promise<boolean> { 33 + return Promise.resolve(globalThis.localStorage.getItem(this.name) !== null) 34 + } 35 + 36 + /** Retrieve a value from storage */ 37 + override get(): Promise<T> { 38 + const value = globalThis.localStorage.getItem(this.name) 39 + if (value == null) return Promise.resolve(this.defaultValue) 40 + return Promise.resolve(this.parseMetadata(value).data) 41 + } 42 + 43 + /** Add a value to storage */ 44 + override set(value: T): Promise<boolean> { 45 + if (!this.verify(value)) throw new Error('invalid value') 46 + try { 47 + const existingValue = globalThis.localStorage.getItem(this.name) 48 + const existingMetadata = existingValue 49 + ? this.parseMetadata(existingValue) 50 + : undefined 51 + 52 + const metadata = this.wrapWithMetadata(value, existingMetadata) 53 + 54 + globalThis.localStorage.setItem( 55 + this.name, 56 + this.serializeMetadata(metadata), 57 + ) 58 + return Promise.resolve(true) 59 + } catch (err: unknown) { 60 + if (err instanceof Error && err.name == 'QuotaExceededError') { 61 + throw new Error('Local-storage is full.', { cause: err }) 62 + } 63 + throw err 64 + } 65 + } 66 + 67 + /** Remove a value from storage */ 68 + override remove(): Promise<boolean> { 69 + globalThis.localStorage.removeItem(this.name) 70 + return Promise.resolve(true) 71 + } 72 + 73 + /** Get metadata for the stored item */ 74 + override getMetadata(): Promise<StorageMetadata<T> | null> { 75 + const value = globalThis.localStorage.getItem(this.name) 76 + if (value == null) { 77 + return Promise.resolve(null) 78 + } 79 + return Promise.resolve(this.parseMetadata(value)) 80 + } 81 + }
+223
packages/store/storage/metadata.test.ts
··· 1 + import { assert, assertEquals, assertFalse } from '@std/assert' 2 + import { FakeTime } from '@std/testing/time' 3 + import LocalStorage from './local_storage.ts' 4 + 5 + const uniqueKey = () => `test-${crypto.randomUUID()}` 6 + 7 + Deno.test.afterAll(() => { 8 + localStorage.clear() 9 + }) 10 + 11 + Deno.test('Storage metadata integration', async () => { 12 + const time = new FakeTime() 13 + 14 + try { 15 + const storage = new LocalStorage<{ count: number }>({ 16 + name: uniqueKey(), 17 + defaultValue: { count: 0 }, 18 + deserialize: (str) => JSON.parse(str), 19 + serialize: (data) => JSON.stringify(data), 20 + verify: (data) => 21 + typeof data === 'object' && data !== null && 'count' in data, 22 + }) 23 + 24 + await storage.remove() 25 + 26 + const initialData = await storage.get() 27 + assertEquals(initialData.count, 0, 'Should return default value initially') 28 + 29 + await storage.set({ count: 0 }) 30 + 31 + const initialMetadata = await storage.getMetadata() 32 + assert(initialMetadata !== null, 'Metadata should exist after storing data') 33 + assertEquals(initialMetadata!.data.count, 0, 'initial data') 34 + assertEquals(typeof initialMetadata!.createdAt, 'string', 'createdAt') 35 + assertEquals(typeof initialMetadata!.updatedAt, 'string', 'updatedAt') 36 + assertEquals( 37 + initialMetadata!.createdAt, 38 + initialMetadata!.updatedAt, 39 + 'Initial timestamps should be equal', 40 + ) 41 + 42 + // Wait a bit to ensure timestamp difference 43 + time.tick(10) 44 + 45 + await storage.set({ count: 42 }) 46 + const newData = await storage.get() 47 + assertEquals(newData.count, 42, 'Should return updated data') 48 + 49 + const updatedMetadata = await storage.getMetadata() 50 + assert(updatedMetadata !== null, 'Metadata should exist after update') 51 + assertEquals(updatedMetadata!.data.count, 42, 'updated data') 52 + assertEquals( 53 + updatedMetadata!.createdAt, 54 + initialMetadata!.createdAt, 55 + 'updated createdAt', 56 + ) 57 + assertEquals( 58 + typeof updatedMetadata!.updatedAt, 59 + 'string', 60 + 'updatedAt timestamp', 61 + ) 62 + 63 + const createdTime = new Date(updatedMetadata!.createdAt).getTime() 64 + const updatedTime = new Date(updatedMetadata!.updatedAt).getTime() 65 + assert(updatedTime >= createdTime, 'updatedAt should be >= createdAt') 66 + 67 + await storage.remove() 68 + } finally { 69 + time.restore() 70 + } 71 + }) 72 + 73 + Deno.test('Storage backward compatibility', async () => { 74 + const testKey = uniqueKey() 75 + const storage = new LocalStorage<{ value: string }>({ 76 + name: testKey, 77 + defaultValue: { value: 'default' }, 78 + deserialize: (str) => JSON.parse(str), 79 + serialize: (data) => JSON.stringify(data), 80 + verify: (data) => 81 + typeof data === 'object' && data !== null && 'value' in data, 82 + }) 83 + 84 + await storage.remove() 85 + 86 + const oldFormatData = { value: 'old-data' } 87 + globalThis.localStorage.setItem(testKey, JSON.stringify(oldFormatData)) 88 + 89 + const data = await storage.get() 90 + assertEquals(data.value, 'old-data', 'Should read old format data') 91 + 92 + const metadata = await storage.getMetadata() 93 + assert(metadata !== null, 'Should create metadata from old format') 94 + assertEquals( 95 + metadata!.data.value, 96 + 'old-data', 97 + 'Should wrap old data with metadata', 98 + ) 99 + assertEquals( 100 + typeof metadata!.createdAt, 101 + 'string', 102 + 'Should create createdAt for old data', 103 + ) 104 + assertEquals( 105 + typeof metadata!.updatedAt, 106 + 'string', 107 + 'Should create updatedAt for old data', 108 + ) 109 + 110 + await storage.set({ value: 'new-data' }) 111 + 112 + const rawStored = globalThis.localStorage.getItem(testKey) 113 + const parsedStored = JSON.parse(rawStored!) 114 + assertEquals( 115 + typeof parsedStored.data, 116 + 'string', 117 + 'Should store serialized data in new metadata format', 118 + ) 119 + assertEquals( 120 + typeof parsedStored.createdAt, 121 + 'string', 122 + 'Should have metadata.createdAt', 123 + ) 124 + assertEquals( 125 + typeof parsedStored.updatedAt, 126 + 'string', 127 + 'Should have metadata.updatedAt', 128 + ) 129 + assertEquals( 130 + JSON.parse(parsedStored.data).value, 131 + 'new-data', 132 + 'Should store serialized data that deserializes to actual data', 133 + ) 134 + 135 + await storage.remove() 136 + }) 137 + 138 + Deno.test('Storage handles corrupted data gracefully', async () => { 139 + const testKey = uniqueKey() 140 + const storage = new LocalStorage<{ num: number }>({ 141 + name: testKey, 142 + defaultValue: { num: 0 }, 143 + deserialize: (str) => JSON.parse(str), 144 + serialize: (data) => JSON.stringify(data), 145 + verify: (data) => 146 + typeof data === 'object' && data !== null && 'num' in data, 147 + }) 148 + 149 + await storage.remove() 150 + 151 + globalThis.localStorage.setItem(testKey, 'invalid-json-data') 152 + 153 + const data = await storage.get() 154 + assertEquals(data.num, 0, 'Should return default for corrupted data') 155 + 156 + const metadata = await storage.getMetadata() 157 + assert(metadata !== null, 'Should create metadata for corrupted data') 158 + assertEquals(metadata!.data.num, 0, 'default metadata for corrupted data') 159 + 160 + await storage.remove() 161 + }) 162 + 163 + Deno.test('Storage metadata uses ISO timestamps', async () => { 164 + const storage = new LocalStorage<{ test: boolean }>({ 165 + name: uniqueKey(), 166 + defaultValue: { test: false }, 167 + deserialize: (str) => JSON.parse(str), 168 + serialize: (data) => JSON.stringify(data), 169 + verify: (data) => 170 + typeof data === 'object' && data !== null && 'test' in data, 171 + }) 172 + 173 + await storage.remove() 174 + 175 + await storage.set({ test: true }) 176 + const metadata = await storage.getMetadata() 177 + assert(metadata !== null, 'Metadata should exist after setting data') 178 + 179 + const createdDate = new Date(metadata!.createdAt) 180 + const updatedDate = new Date(metadata!.updatedAt) 181 + 182 + assertFalse(isNaN(createdDate.getTime()), 'createdAt should be valid date') 183 + assertFalse(isNaN(updatedDate.getTime()), 'updatedAt should be valid date') 184 + 185 + assertEquals( 186 + metadata!.createdAt, 187 + createdDate.toISOString(), 188 + 'createdAt should be ISO string', 189 + ) 190 + assertEquals( 191 + metadata!.updatedAt, 192 + updatedDate.toISOString(), 193 + 'updatedAt should be ISO string', 194 + ) 195 + 196 + await storage.remove() 197 + }) 198 + 199 + Deno.test('Storage getMetadata returns null for empty storage', async () => { 200 + const storage = new LocalStorage<{ value: string }>({ 201 + name: uniqueKey(), 202 + defaultValue: { value: 'default' }, 203 + deserialize: (str) => JSON.parse(str), 204 + serialize: (data) => JSON.stringify(data), 205 + verify: (data) => 206 + typeof data === 'object' && data !== null && 'value' in data, 207 + }) 208 + 209 + await storage.remove() 210 + 211 + const metadata = await storage.getMetadata() 212 + assertEquals(metadata, null, 'null when no data exists') 213 + 214 + const createdMetadata = await storage.getOrCreateMetadata() 215 + assert(createdMetadata !== null, 'getOrCreateMetadata should create metadata') 216 + assertEquals( 217 + createdMetadata.data.value, 218 + 'default', 219 + 'getOrCreateMetadata should use default value', 220 + ) 221 + 222 + await storage.remove() 223 + })
+1152
packages/store/storage/migrations.test.ts
··· 1 + import { assert, assertEquals, assertFalse } from '@std/assert' 2 + import { parse as parseSemver } from '@std/semver' 3 + import { 4 + defaultCompareVersions, 5 + MigrationMismatchAction, 6 + type StorageError, 7 + type Version, 8 + } from '../storage.ts' 9 + import LocalStorage from './local_storage.ts' 10 + 11 + const { Continue, UseCurrent, UseDefault } = MigrationMismatchAction 12 + 13 + type V0 = { count: number } // No version field 14 + type V1 = { count: number; version: string } // Added version 15 + type V2 = { count: number; name: string; version: string } // Added name field 16 + type V3 = { value: number; name: string; version: string } // Renamed count to value 17 + 18 + const verifyV0 = (data: unknown): data is V0 => 19 + typeof data === 'object' && data !== null && 'count' in data 20 + const verifyV1 = (data: unknown): data is V1 => 21 + typeof data === 'object' && data !== null && 'count' in data && 22 + 'version' in data 23 + const verifyV2 = (data: unknown): data is V2 => 24 + typeof data === 'object' && data !== null && 'count' in data && 25 + 'name' in data && 'version' in data 26 + const verifyV3 = (data: unknown): data is V3 => 27 + typeof data === 'object' && data !== null && 'value' in data && 28 + 'name' in data && 'version' in data 29 + 30 + const extractVersion = (data: unknown) => 31 + (data && typeof data === 'object' && 'version' in data) 32 + ? String(data.version) 33 + : undefined 34 + const extractNumberVersion = (data: unknown) => { 35 + if (data && typeof data === 'object' && 'version' in data) { 36 + return data.version as number 37 + } 38 + return undefined 39 + } 40 + 41 + Deno.test.afterAll(() => { 42 + localStorage.clear() 43 + }) 44 + 45 + const uniqueKey = () => `test-${crypto.randomUUID()}` 46 + 47 + Deno.test('Migration: No migration config works normally', async () => { 48 + const storage = new LocalStorage<{ count: number }>({ 49 + name: uniqueKey(), 50 + defaultValue: { count: 0 }, 51 + deserialize: (str) => JSON.parse(str), 52 + serialize: (data) => JSON.stringify(data), 53 + verify: verifyV0, 54 + }) 55 + 56 + await storage.set({ count: 42 }) 57 + const data = await storage.get() 58 + assertEquals(data.count, 42, 'Should work without migrations') 59 + }) 60 + 61 + Deno.test('Migration: v0 -> v1 (add version field)', async () => { 62 + const testKey = uniqueKey() 63 + const v0Data: V0 = { count: 42 } 64 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 65 + 66 + const storage = new LocalStorage<V1>({ 67 + name: testKey, 68 + defaultValue: { count: 0, version: '1.0.0' }, 69 + deserialize: (str) => JSON.parse(str), 70 + serialize: (data) => JSON.stringify(data), 71 + verify: verifyV1, 72 + migrations: { 73 + currentVersion: '1.0.0', 74 + extractVersion, 75 + migrations: [ 76 + { 77 + fromVersion: undefined, // v0 78 + toVersion: '1.0.0', 79 + migrate: (d): V1 => ({ ...(d as V0), version: '1.0.0' }), 80 + }, 81 + ], 82 + }, 83 + }) 84 + 85 + const data = await storage.get() 86 + assertEquals(data.count, 42, 'Should preserve count') 87 + assertEquals(data.version, '1.0.0', 'Should add version field') 88 + 89 + const metadata = await storage.getMetadata() 90 + assertEquals(metadata?.version, '1.0.0', 'Metadata should have version') 91 + }) 92 + 93 + Deno.test('Migration: v0 -> v1 -> v2 -> v3 (waterfall chain)', async () => { 94 + const testKey = uniqueKey() 95 + // Simulate v0 data 96 + const v0Data: V0 = { count: 42 } 97 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 98 + 99 + const storage = new LocalStorage<V3>({ 100 + name: testKey, 101 + defaultValue: { value: 0, name: '', version: '3.0.0' }, 102 + deserialize: (str) => JSON.parse(str), 103 + serialize: (data) => JSON.stringify(data), 104 + verify: verifyV3, 105 + migrations: { 106 + currentVersion: '3.0.0', 107 + extractVersion, 108 + migrations: [ 109 + { 110 + fromVersion: undefined, 111 + toVersion: '1.0.0', 112 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' } as V1), 113 + }, 114 + { 115 + fromVersion: '1.0.0', 116 + toVersion: '2.0.0', 117 + migrate: ( 118 + data, 119 + ): V2 => ({ ...data as V1, name: 'default', version: '2.0.0' }), 120 + }, 121 + { 122 + fromVersion: '2.0.0', 123 + toVersion: '3.0.0', 124 + migrate: (data): V3 => { 125 + const { count, name } = data as V2 126 + return { name, value: count, version: '3.0.0' } 127 + }, 128 + }, 129 + ], 130 + }, 131 + }) 132 + 133 + const data = await storage.get() 134 + assertEquals(data.value, 42, 'Should rename count to value') 135 + assertEquals(data.name, 'default', 'Should preserve name') 136 + assertEquals(data.version, '3.0.0', 'Should be at v3') 137 + }) 138 + 139 + Deno.test('Migration: v1 -> v2 -> v3 (waterfall chain)', async () => { 140 + const testKey = uniqueKey() 141 + const v1Data: V1 = { count: 42, version: '1.0.0' } 142 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 143 + 144 + const storage = new LocalStorage<V3>({ 145 + name: testKey, 146 + defaultValue: { value: 0, name: '', version: '3.0.0' }, 147 + deserialize: (str) => JSON.parse(str), 148 + serialize: (data) => JSON.stringify(data), 149 + verify: (data) => 150 + typeof data === 'object' && data !== null && 'value' in data && 151 + 'name' in data && 'version' in data, 152 + migrations: { 153 + currentVersion: '3.0.0', 154 + extractVersion, 155 + migrations: [ 156 + { 157 + fromVersion: undefined, 158 + toVersion: '1.0.0', 159 + migrate: (data): V1 => ({ ...(data as V0), version: '1.0.0' }), 160 + }, 161 + { 162 + fromVersion: '1.0.0', 163 + toVersion: '2.0.0', 164 + migrate: ( 165 + data, 166 + ): V2 => ({ ...(data as V1), name: 'default', version: '2.0.0' }), 167 + }, 168 + { 169 + fromVersion: '2.0.0', 170 + toVersion: '3.0.0', 171 + migrate: (data): V3 => { 172 + const { count, name } = data as V2 173 + return { value: count, name, version: '3.0.0' } 174 + }, 175 + }, 176 + ], 177 + }, 178 + }) 179 + 180 + const data = await storage.get() 181 + assertEquals(data.value, 42, 'Should apply v1->v2->v3 migrations') 182 + assertEquals(data.name, 'default', 'Should add name field') 183 + assertEquals(data.version, '3.0.0', 'Should be at v3') 184 + }) 185 + 186 + Deno.test('Migration: No migration needed when versions match', async () => { 187 + const storage = new LocalStorage<V2>({ 188 + name: uniqueKey(), 189 + defaultValue: { count: 0, name: '', version: '2.0.0' }, 190 + deserialize: (str) => JSON.parse(str), 191 + serialize: (data) => JSON.stringify(data), 192 + verify: verifyV2, 193 + migrations: { 194 + currentVersion: '2.0.0', 195 + extractVersion, 196 + migrations: [ 197 + { 198 + fromVersion: undefined, 199 + toVersion: '1.0.0', 200 + migrate: () => { 201 + throw new Error('Should not be called!') 202 + }, 203 + }, 204 + { 205 + fromVersion: '1.0.0', 206 + toVersion: '2.0.0', 207 + migrate: () => { 208 + throw new Error('Should not be called!') 209 + }, 210 + }, 211 + ], 212 + }, 213 + }) 214 + 215 + await storage.set({ count: 42, name: 'test', version: '2.0.0' }) 216 + const data = await storage.get() 217 + 218 + assertEquals(data.count, 42, 'Should return data without migration') 219 + assertEquals(data.name, 'test', 'Should preserve name') 220 + assertEquals(data.version, '2.0.0', 'Version should match') 221 + }) 222 + 223 + Deno.test('Migration: Per-step extractVersion override', async () => { 224 + const testKey = uniqueKey() 225 + type V0Nested = { meta: { v: string }; count: number } 226 + 227 + const v0Data: V0Nested = { meta: { v: '0.5.0' }, count: 42 } 228 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 229 + 230 + const storage = new LocalStorage<V1>({ 231 + name: testKey, 232 + defaultValue: { count: 0, version: '1.0.0' }, 233 + deserialize: (str) => JSON.parse(str), 234 + serialize: (data) => JSON.stringify(data), 235 + verify: verifyV1, 236 + migrations: { 237 + currentVersion: '1.0.0', 238 + extractVersion, 239 + migrations: [ 240 + { 241 + fromVersion: '0.5.0', 242 + toVersion: '1.0.0', 243 + extractVersion: (data: unknown) => { 244 + if ( 245 + data && typeof data === 'object' && 'meta' in data && 246 + typeof data.meta === 'object' && data.meta !== null && 247 + 'v' in data.meta 248 + ) { 249 + return String(data.meta.v) 250 + } 251 + return undefined 252 + }, 253 + migrate: (data: unknown): V1 => { 254 + const { count } = data as V0Nested 255 + return { count, version: '1.0.0' } 256 + }, 257 + }, 258 + ], 259 + }, 260 + }) 261 + 262 + const data = await storage.get() 263 + assertEquals(data.count, 42, 'Should preserve count') 264 + assertEquals(data.version, '1.0.0', 'Should flatten version') 265 + }) 266 + 267 + Deno.test('Migration: Broken chain throws error by default', async () => { 268 + const testKey = uniqueKey() 269 + const v0Data: V0 = { count: 42 } 270 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 271 + 272 + const storage = new LocalStorage<V2>({ 273 + name: testKey, 274 + defaultValue: { count: 0, name: '', version: '2.0.0' }, 275 + deserialize: (str) => JSON.parse(str), 276 + serialize: (data) => JSON.stringify(data), 277 + verify: () => true, // Accept anything for this test 278 + migrations: { 279 + currentVersion: '2.0.0', 280 + extractVersion, 281 + migrations: [ 282 + { 283 + fromVersion: undefined, 284 + toVersion: '1.0.0', 285 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 286 + }, 287 + // Missing v1 -> v2 migration! Chain is broken. 288 + ], 289 + }, 290 + }) 291 + 292 + try { 293 + await storage.get() 294 + throw new Error('Expected storage.get() to throw') 295 + } catch (error) { 296 + assert(error instanceof Error && error.name === 'StorageError') 297 + } 298 + }) 299 + 300 + Deno.test('Migration: Metadata wrapper with version preserved', async () => { 301 + const testKey = uniqueKey() 302 + const storage = new LocalStorage<V1>({ 303 + name: testKey, 304 + defaultValue: { count: 0, version: '1.0.0' }, 305 + deserialize: (str) => JSON.parse(str), 306 + serialize: (data) => JSON.stringify(data), 307 + verify: verifyV1, 308 + migrations: { 309 + currentVersion: '1.0.0', 310 + extractVersion, 311 + migrations: [ 312 + { 313 + fromVersion: undefined, 314 + toVersion: '1.0.0', 315 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 316 + }, 317 + ], 318 + }, 319 + }) 320 + 321 + await storage.set({ count: 42, version: '1.0.0' }) 322 + 323 + const metadata = await storage.getMetadata() 324 + assertEquals(metadata?.version, '1.0.0', 'Metadata should include version') 325 + assertEquals(metadata?.data.count, 42, 'Data should be correct') 326 + 327 + const parsed = JSON.parse(localStorage.getItem(testKey)!) 328 + assertEquals(parsed.version, '1.0.0', 'Raw metadata should have version') 329 + }) 330 + 331 + Deno.test('Migration: Old metadata format (without version) gets migrated', async () => { 332 + const testKey = uniqueKey() 333 + const oldMetadata = { 334 + data: JSON.stringify({ count: 42 }), 335 + createdAt: '2024-01-01T00:00:00.000Z', 336 + updatedAt: '2024-01-01T00:00:00.000Z', 337 + } 338 + localStorage.setItem(testKey, JSON.stringify(oldMetadata)) 339 + 340 + const storage = new LocalStorage<V1>({ 341 + name: testKey, 342 + defaultValue: { count: 0, version: '1.0.0' }, 343 + deserialize: (str) => JSON.parse(str), 344 + serialize: (data) => JSON.stringify(data), 345 + verify: verifyV1, 346 + migrations: { 347 + currentVersion: '1.0.0', 348 + extractVersion, 349 + migrations: [ 350 + { 351 + fromVersion: undefined, 352 + toVersion: '1.0.0', 353 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 354 + }, 355 + ], 356 + }, 357 + }) 358 + 359 + const data = await storage.get() 360 + assertEquals(data.count, 42, 'Should migrate data from old metadata format') 361 + assertEquals(data.version, '1.0.0', 'Should add version field') 362 + 363 + const { version, createdAt } = (await storage.getMetadata())! 364 + assertEquals(version, '1.0.0', 'Metadata should now have version') 365 + assertEquals(createdAt, '2024-01-01T00:00:00.000Z', 'Should preserve created') 366 + }) 367 + 368 + Deno.test('Non-JSON Migration: CSV-like serialization', async () => { 369 + const testKey = uniqueKey() 370 + type Data = { x: number; y: number; z: number; version: number } 371 + 372 + const metadataWrapper = JSON.stringify({ 373 + // Store OLD CSV format: "x,y,version" (no z field yet) 374 + // x=10, y=20, version=1 375 + data: '10,20,1', 376 + createdAt: '2024-01-01T00:00:00.000Z', 377 + updatedAt: '2024-01-01T00:00:00.000Z', 378 + }) 379 + localStorage.setItem(testKey, metadataWrapper) 380 + 381 + const storage = new LocalStorage<Data>({ 382 + name: testKey, 383 + defaultValue: { x: 0, y: 0, z: 0, version: 2 }, 384 + 385 + deserialize: (str: string): Data => { 386 + const parts = str.split(',').map(Number) 387 + // Handle both old (3 parts: x,y,version) and new (4 parts: x,y,z,version) formats 388 + if (parts.length === 3) { 389 + // Old format: x,y,version (no z) 390 + return { x: parts[0], y: parts[1], z: 0, version: parts[2] } 391 + } else { 392 + // New format: x,y,z,version 393 + return { x: parts[0], y: parts[1], z: parts[2], version: parts[3] } 394 + } 395 + }, 396 + serialize: (data: Data) => `${data.x},${data.y},${data.z},${data.version}`, 397 + verify: (data) => 398 + typeof data === 'object' && data !== null && 399 + 'x' in data && 'y' in data && 'z' in data && 'version' in data, 400 + 401 + migrations: { 402 + currentVersion: 2, 403 + extractVersion: extractNumberVersion, 404 + migrations: [ 405 + { 406 + fromVersion: 1, 407 + toVersion: 2, 408 + migrate: (data) => ({ ...(data as Data), version: 2 }), // bump ver 409 + }, 410 + ], 411 + }, 412 + }) 413 + 414 + const data = await storage.get() 415 + assertEquals(data.x, 10, 'Should preserve x from CSV') 416 + assertEquals(data.y, 20, 'Should preserve y from CSV') 417 + assertEquals(data.z, 0, 'Should have default z') 418 + assertEquals(data.version, 2, 'Should be migrated to version 2') 419 + 420 + await storage.set(data) 421 + const rawStored = localStorage.getItem(testKey) 422 + const parsed = JSON.parse(rawStored!) 423 + assertEquals(parsed.data, '10,20,0,2', 'Should store as CSV: x,y,z,version') 424 + }) 425 + 426 + Deno.test('Non-JSON Migration: Base64 encoded format', async () => { 427 + const testKey = uniqueKey() 428 + type Data = { value: number; name: string; version: number } 429 + 430 + const metadataWrapper = JSON.stringify({ 431 + // Simulate old base64 format: first byte is version, second is value 432 + // version=1, value=42 433 + data: btoa(String.fromCharCode(...new Uint8Array([1, 42]))), 434 + createdAt: '2024-01-01T00:00:00.000Z', 435 + updatedAt: '2024-01-01T00:00:00.000Z', 436 + }) 437 + localStorage.setItem(testKey, metadataWrapper) 438 + 439 + const storage = new LocalStorage<Data>({ 440 + name: testKey, 441 + defaultValue: { value: 0, name: '', version: 2 }, 442 + 443 + deserialize: (str: string): Data => { 444 + const binary = Uint8Array.from(atob(str), (c) => c.charCodeAt(0)) 445 + const [version, value] = binary 446 + if (binary.length === 2) return { value, name: '', version } 447 + const name = new TextDecoder().decode(binary.slice(2)) 448 + return { value, name, version } 449 + }, 450 + 451 + serialize: (data: Data) => { 452 + const nameBytes = new TextEncoder().encode(data.name) 453 + const buffer = new Uint8Array(2 + nameBytes.length) 454 + buffer[0] = data.version 455 + buffer[1] = data.value 456 + buffer.set(nameBytes, 2) 457 + return btoa(String.fromCharCode(...buffer)) 458 + }, 459 + 460 + verify: (data) => 461 + typeof data === 'object' && data !== null && 462 + 'value' in data && 'name' in data && 'version' in data, 463 + 464 + migrations: { 465 + currentVersion: 2, 466 + extractVersion: extractNumberVersion, 467 + migrations: [ 468 + { 469 + fromVersion: 1, 470 + toVersion: 2, 471 + migrate: (d) => ({ ...(d as Data), name: 'migrated', version: 2 }), 472 + }, 473 + ], 474 + }, 475 + }) 476 + 477 + const data = await storage.get() 478 + assertEquals(data.value, 42, 'Should extract value from binary') 479 + assertEquals(data.name, 'migrated', 'Should add name via migration') 480 + assertEquals(data.version, 2, 'Should be migrated to version 2') 481 + }) 482 + 483 + Deno.test('Non-JSON Migration: protobuf serialization', async () => { 484 + const testKey = uniqueKey() 485 + type Data = { count: number; tags: string[]; version: number } 486 + 487 + const metadataWrapper = JSON.stringify({ 488 + // Simulate protobuf-like wire format: "field_id:value;field_id:value" 489 + data: '1:1;2:100', 490 + createdAt: '2024-01-01T00:00:00.000Z', 491 + updatedAt: '2024-01-01T00:00:00.000Z', 492 + }) 493 + localStorage.setItem(testKey, metadataWrapper) 494 + 495 + const storage = new LocalStorage<Data>({ 496 + name: testKey, 497 + defaultValue: { count: 0, tags: [], version: 2 }, 498 + deserialize: (str: string): Data => { 499 + const fields = new Map<number, string>() 500 + str.split(';').forEach((pair) => { 501 + const [id, value] = pair.split(':') 502 + fields.set(Number(id), value) 503 + }) 504 + 505 + const version = Number(fields.get(1) || 1) 506 + const count = Number(fields.get(2) || 0) 507 + const tags = fields.get(3)?.split(',').filter(Boolean) || [] 508 + 509 + return { count, tags, version } 510 + }, 511 + serialize: (data: Data) => { 512 + return `1:${data.version};2:${data.count};3:${data.tags.join(',')}` 513 + }, 514 + 515 + verify: (data) => 516 + typeof data === 'object' && data !== null && 517 + 'count' in data && 'tags' in data && 'version' in data, 518 + 519 + migrations: { 520 + currentVersion: 2, 521 + extractVersion: extractNumberVersion, 522 + migrations: [ 523 + { 524 + fromVersion: 1, 525 + toVersion: 2, 526 + migrate: (d) => ({ ...(d as Data), tags: ['default'], version: 2 }), 527 + }, 528 + ], 529 + }, 530 + }) 531 + 532 + const data = await storage.get() 533 + assertEquals(data.count, 100, 'Should parse protobuf-like format') 534 + assertEquals(data.tags, ['default'], 'Should add tags via migration') 535 + assertEquals(data.version, 2, 'Should be migrated to version 2') 536 + }) 537 + 538 + Deno.test('defaultCompareVersions: undefined handling', () => { 539 + assertEquals( 540 + defaultCompareVersions(undefined, undefined), 541 + 0, 542 + 'undefined === undefined', 543 + ) 544 + assertEquals(defaultCompareVersions(undefined, 1), -1, 'undefined < 1') 545 + assertEquals(defaultCompareVersions(1, undefined), 1, '1 > undefined') 546 + }) 547 + 548 + Deno.test('defaultCompareVersions: numeric versions', () => { 549 + assertEquals(defaultCompareVersions(1, 1), 0, '1 === 1') 550 + assertEquals(defaultCompareVersions(1, 2), -1, '1 < 2') 551 + assertEquals(defaultCompareVersions(2, 1), 1, '2 > 1') 552 + assertEquals(defaultCompareVersions(0, 10), -10, '0 < 10') 553 + }) 554 + 555 + Deno.test('defaultCompareVersions: string versions', () => { 556 + assertEquals(defaultCompareVersions('1.0.0', '1.0.0'), 0, '1.0.0 === 1.0.0') 557 + assertEquals(defaultCompareVersions('1.0.0', '2.0.0'), -1, '1.0.0 < 2.0.0') 558 + assertEquals(defaultCompareVersions('2.0.0', '1.0.0'), 1, '2.0.0 > 1.0.0') 559 + assertEquals(defaultCompareVersions('a', 'b'), -1, 'a < b') 560 + }) 561 + 562 + Deno.test('defaultCompareVersions: mixed types', () => { 563 + assertEquals(defaultCompareVersions(1, '1'), 0, '1 === "1"') 564 + assertEquals(defaultCompareVersions(1, '2'), -1, '1 < "2"') 565 + assertEquals(defaultCompareVersions(2, '1'), 1, '2 > "1"') 566 + }) 567 + 568 + Deno.test('Migration: Numeric versions (1 -> 2 -> 3)', async () => { 569 + const testKey = uniqueKey() 570 + type V1 = { count: number; version: number } 571 + type V2 = { count: number; name: string; version: number } 572 + type V3 = { value: number; name: string; version: number } 573 + 574 + const v1Data: V1 = { count: 42, version: 1 } 575 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 576 + 577 + const storage = new LocalStorage<V3>({ 578 + name: testKey, 579 + defaultValue: { value: 0, name: '', version: 3 }, 580 + deserialize: (str) => JSON.parse(str), 581 + serialize: (data) => JSON.stringify(data), 582 + verify: verifyV3, 583 + migrations: { 584 + currentVersion: 3, 585 + extractVersion: extractNumberVersion, 586 + migrations: [ 587 + { 588 + fromVersion: undefined, 589 + toVersion: 1, 590 + migrate: (data: unknown): V1 => ({ ...(data as V0), version: 1 }), 591 + }, 592 + { 593 + fromVersion: 1, 594 + toVersion: 2, 595 + migrate: ( 596 + data: unknown, 597 + ): V2 => ({ ...(data as V1), name: 'default', version: 2 }), 598 + }, 599 + { 600 + fromVersion: 2, 601 + toVersion: 3, 602 + migrate: (data: unknown): V3 => { 603 + const v2 = data as V2 604 + return { value: v2.count, name: v2.name, version: 3 } 605 + }, 606 + }, 607 + ], 608 + }, 609 + }) 610 + 611 + const data = await storage.get() 612 + assertEquals(data.value, 42, 'Should preserve value via numeric versions') 613 + assertEquals(data.name, 'default', 'Should add name field') 614 + assertEquals(data.version, 3, 'Should be at version 3') 615 + }) 616 + 617 + Deno.test('Migration: Semantic versioning with custom comparator', async () => { 618 + const testKey = uniqueKey() 619 + type V1 = { count: number; version: string } 620 + 621 + const v1Data: V1 = { count: 42, version: '1.0.5' } 622 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 623 + 624 + const storage = new LocalStorage<V1>({ 625 + name: testKey, 626 + defaultValue: { count: 0, version: '1.0.0' }, 627 + deserialize: (str) => JSON.parse(str), 628 + serialize: (data) => JSON.stringify(data), 629 + verify: verifyV1, 630 + migrations: { 631 + currentVersion: '1.0.0', 632 + extractVersion, 633 + compareVersions: (v1: Version | undefined, v2: Version | undefined) => { 634 + if (v1 === undefined && v2 === undefined) return 0 635 + if (v1 === undefined) return -1 636 + if (v2 === undefined) return 1 637 + 638 + try { 639 + const semver1 = parseSemver(String(v1)) 640 + const semver2 = parseSemver(String(v2)) 641 + 642 + if (semver1.major !== semver2.major) { 643 + return semver1.major - semver2.major 644 + } 645 + if (semver1.minor !== semver2.minor) { 646 + return semver1.minor - semver2.minor 647 + } 648 + return 0 649 + } catch { 650 + const s1 = String(v1) 651 + const s2 = String(v2) 652 + if (s1 === s2) return 0 653 + return s1 < s2 ? -1 : 1 654 + } 655 + }, 656 + migrations: [], 657 + }, 658 + }) 659 + 660 + const data = await storage.get() 661 + assertEquals(data.count, 42, 'Should not migrate when patch version differs') 662 + assertEquals(data.version, '1.0.5', 'Should preserve original patch version') 663 + }) 664 + 665 + Deno.test('Migration: Numeric versions with gaps', async () => { 666 + const testKey = uniqueKey() 667 + type V5 = { count: number; version: number } 668 + type V10 = { count: number; name: string; version: number } 669 + 670 + const v5Data: V5 = { count: 42, version: 5 } 671 + localStorage.setItem(testKey, JSON.stringify(v5Data)) 672 + 673 + const storage = new LocalStorage<V10>({ 674 + name: testKey, 675 + defaultValue: { count: 0, name: '', version: 10 }, 676 + deserialize: (str) => JSON.parse(str), 677 + serialize: (data) => JSON.stringify(data), 678 + verify: verifyV2, 679 + migrations: { 680 + currentVersion: 10, 681 + extractVersion: extractNumberVersion, 682 + migrations: [ 683 + { 684 + fromVersion: 5, 685 + toVersion: 10, 686 + migrate: ( 687 + data, 688 + ) => ({ ...(data as V5), name: 'migrate-direct', version: 10 }), 689 + }, 690 + ], 691 + }, 692 + }) 693 + 694 + const data = await storage.get() 695 + assertEquals(data.count, 42, 'Should handle version gaps') 696 + assertEquals(data.name, 'migrate-direct', 'Should apply single migration') 697 + assertEquals(data.version, 10, 'Should be at version 10') 698 + }) 699 + 700 + // Test that versions are properly compared in metadata 701 + Deno.test('Migration: Version comparison in metadata wrapper', async () => { 702 + const testKey = uniqueKey() 703 + type V1 = { count: number; version: number } 704 + type V2 = { count: number; name: string; version: number } 705 + 706 + const storage1 = new LocalStorage<V1>({ 707 + name: testKey, 708 + defaultValue: { count: 0, version: 1 }, 709 + deserialize: (str) => JSON.parse(str), 710 + serialize: (data) => JSON.stringify(data), 711 + verify: verifyV1, 712 + migrations: { 713 + currentVersion: 1, 714 + extractVersion: extractNumberVersion, 715 + migrations: [], 716 + }, 717 + }) 718 + 719 + await storage1.set({ count: 42, version: 1 }) 720 + 721 + const storage2 = new LocalStorage<V2>({ 722 + name: testKey, 723 + defaultValue: { count: 0, name: '', version: 2 }, 724 + deserialize: (str) => JSON.parse(str), 725 + serialize: (data) => JSON.stringify(data), 726 + verify: verifyV2, 727 + migrations: { 728 + currentVersion: 2, 729 + extractVersion: extractNumberVersion, 730 + migrations: [ 731 + { 732 + fromVersion: 1, 733 + toVersion: 2, 734 + migrate: (d) => ({ ...(d as V1), name: 'upgraded', version: 2 }), 735 + }, 736 + ], 737 + }, 738 + }) 739 + 740 + const data = await storage2.get() 741 + assertEquals(data.count, 42, 'Should migrate from metadata wrapper version') 742 + assertEquals(data.name, 'upgraded', 'Should add new field') 743 + assertEquals(data.version, 2, 'Should be at version 2') 744 + }) 745 + 746 + Deno.test('Migration: onError can return defaultValue on broken chain', async () => { 747 + const testKey = uniqueKey() 748 + const v0Data: V0 = { count: 42 } 749 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 750 + 751 + const storage = new LocalStorage<V2>({ 752 + name: testKey, 753 + defaultValue: { count: 0, name: 'default', version: '2.0.0' }, 754 + deserialize: (str) => JSON.parse(str), 755 + serialize: (data) => JSON.stringify(data), 756 + verify: verifyV2, 757 + onError: (error: StorageError) => { 758 + console.log(`Handling error: ${error.code}`) 759 + return { count: 0, name: 'default', version: '2.0.0' } 760 + }, 761 + migrations: { 762 + currentVersion: '2.0.0', 763 + extractVersion, 764 + migrations: [ 765 + { 766 + fromVersion: undefined, 767 + toVersion: '1.0.0', 768 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 769 + }, 770 + // Missing v1 -> v2 migration! Chain is broken. 771 + ], 772 + }, 773 + }) 774 + 775 + const data = await storage.get() 776 + assertEquals(data.count, 0, 'Should use defaultValue from onError') 777 + assertEquals(data.name, 'default', 'Should use defaultValue from onError') 778 + assertEquals(data.version, '2.0.0', 'Should use defaultValue from onError') 779 + }) 780 + 781 + Deno.test( 782 + 'Migration: onError with custom logic for unknown versions', 783 + async () => { 784 + const testKey = uniqueKey() 785 + type V4 = { count: number; name: string; version: string } 786 + 787 + // Simulate data from an unknown future version 788 + const futureData = { count: 100, name: 'future', version: '99.0.0' } 789 + localStorage.setItem(testKey, JSON.stringify(futureData)) 790 + 791 + let errorHandled = false 792 + 793 + const storage = new LocalStorage<V2>({ 794 + name: testKey, 795 + defaultValue: { count: 0, name: '', version: '2.0.0' }, 796 + deserialize: (str) => JSON.parse(str), 797 + serialize: (data) => JSON.stringify(data), 798 + verify: verifyV2, 799 + onError: (error: StorageError) => { 800 + errorHandled = true 801 + if (error.code === 'migration-no-path') { 802 + const data = error.data as V4 803 + if ('count' in data && 'name' in data) { 804 + const { count, name } = data 805 + // force current version 806 + return { count, name, version: '2.0.0' } 807 + } 808 + } 809 + 810 + throw new Error(`Cannot handle migration error: ${error.message}`) 811 + }, 812 + migrations: { 813 + currentVersion: '2.0.0', 814 + extractVersion, 815 + migrations: [ 816 + { 817 + fromVersion: undefined, 818 + toVersion: '1.0.0', 819 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 820 + }, 821 + { 822 + fromVersion: '1.0.0', 823 + toVersion: '2.0.0', 824 + migrate: (data): V2 => ({ 825 + ...data as V1, 826 + name: 'default', 827 + version: '2.0.0', 828 + }), 829 + }, 830 + // No migration from 99.0.0! 831 + ], 832 + }, 833 + }) 834 + 835 + const data = await storage.get() 836 + assert(errorHandled, 'onError should have been called') 837 + assertEquals(data.count, 100, 'Should preserve count from future version') 838 + assertEquals(data.name, 'future', 'Should preserve future name') 839 + assertEquals(data.version, '2.0.0', 'Should force current version') 840 + }, 841 + ) 842 + 843 + Deno.test('Migration: onError can throw custom errors', async () => { 844 + const testKey = uniqueKey() 845 + const v0Data: V0 = { count: 42 } 846 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 847 + 848 + const storage = new LocalStorage<V2>({ 849 + name: testKey, 850 + defaultValue: { count: 0, name: '', version: '2.0.0' }, 851 + deserialize: (str) => JSON.parse(str), 852 + serialize: (data) => JSON.stringify(data), 853 + verify: verifyV2, 854 + onError: (error: StorageError) => { 855 + throw new Error(`Error ${error.code} - Please clear your browser data.`) 856 + }, 857 + migrations: { 858 + currentVersion: '2.0.0', 859 + extractVersion, 860 + migrations: [ 861 + { 862 + fromVersion: undefined, 863 + toVersion: '1.0.0', 864 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 865 + }, 866 + // Missing v1 -> v2 migration! 867 + ], 868 + }, 869 + }) 870 + 871 + try { 872 + await storage.get() 873 + throw new Error('Expected storage.get() to throw') 874 + } catch (error) { 875 + assert( 876 + error instanceof Error && 877 + error.message.includes('Please clear your browser data.'), 878 + 'Should throw custom error from onError handler', 879 + ) 880 + } 881 + }) 882 + 883 + Deno.test('onVersionMismatch: use-default strategy', async () => { 884 + const testKey = uniqueKey() 885 + const v1Data: V1 = { count: 42, version: '1.0.0' } 886 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 887 + 888 + let mismatchCalled = false 889 + 890 + const storage = new LocalStorage<V0>({ 891 + name: testKey, 892 + defaultValue: { count: 0 }, 893 + deserialize: (str) => JSON.parse(str), 894 + serialize: (data) => JSON.stringify(data), 895 + verify: verifyV0, 896 + migrations: { 897 + currentVersion: '0.5.0', // Lower than stored version 898 + extractVersion, 899 + onVersionMismatch: (dataVersion, currentVersion, data) => { 900 + mismatchCalled = true 901 + assertEquals(dataVersion, '1.0.0') 902 + assertEquals(currentVersion, '0.5.0') 903 + assertEquals((data as V1).count, 42) 904 + return UseDefault 905 + }, 906 + migrations: [], 907 + }, 908 + }) 909 + 910 + const data = await storage.get() 911 + assert(mismatchCalled, 'onVersionMismatch should have been called') 912 + assertEquals(data.count, 0, 'Should use default value') 913 + }) 914 + 915 + Deno.test('onVersionMismatch: use-current strategy', async () => { 916 + const testKey = uniqueKey() 917 + const v1Data: V1 = { count: 42, version: '1.0.0' } 918 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 919 + 920 + let mismatchCalled = false 921 + 922 + const storage = new LocalStorage<V1>({ 923 + name: testKey, 924 + defaultValue: { count: 0, version: '0.5.0' }, 925 + deserialize: (str) => JSON.parse(str), 926 + serialize: (data) => JSON.stringify(data), 927 + verify: verifyV1, 928 + migrations: { 929 + currentVersion: '0.5.0', // Lower than stored version 930 + extractVersion, 931 + onVersionMismatch: (_dataVersion, _currentVersion, _data) => { 932 + mismatchCalled = true 933 + return UseCurrent 934 + }, 935 + migrations: [], 936 + }, 937 + }) 938 + 939 + const data = await storage.get() 940 + assert(mismatchCalled, 'onVersionMismatch should have been called') 941 + assertEquals(data.count, 42, 'Should use current stored data') 942 + assertEquals(data.version, '1.0.0', 'Should preserve stored version') 943 + }) 944 + 945 + Deno.test('onVersionMismatch: custom data strategy', async () => { 946 + const testKey = uniqueKey() 947 + const v1Data: V1 = { count: 42, version: '1.0.0' } 948 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 949 + 950 + let mismatchCalled = false 951 + 952 + const storage = new LocalStorage<V1>({ 953 + name: testKey, 954 + defaultValue: { count: 0, version: '0.5.0' }, 955 + deserialize: (str) => JSON.parse(str), 956 + serialize: (data) => JSON.stringify(data), 957 + verify: verifyV1, 958 + migrations: { 959 + currentVersion: '0.5.0', // Lower than stored version 960 + extractVersion, 961 + onVersionMismatch: (_dataVersion, currentVersion, data) => { 962 + mismatchCalled = true 963 + const stored = data as V1 964 + // Return modified data with forced version 965 + return { count: stored.count + 10, version: currentVersion as string } 966 + }, 967 + migrations: [], 968 + }, 969 + }) 970 + 971 + const data = await storage.get() 972 + assert(mismatchCalled, 'onVersionMismatch should have been called') 973 + assertEquals(data.count, 52, 'Should use custom modified data') 974 + assertEquals(data.version, '0.5.0', 'Should use forced version') 975 + }) 976 + 977 + Deno.test('onVersionMismatch: continue strategy falls back to migrations', async () => { 978 + const testKey = uniqueKey() 979 + const v0Data: V0 = { count: 42 } 980 + localStorage.setItem(testKey, JSON.stringify(v0Data)) 981 + 982 + let mismatchCalled = false 983 + 984 + const storage = new LocalStorage<V1>({ 985 + name: testKey, 986 + defaultValue: { count: 0, version: '1.0.0' }, 987 + deserialize: (str) => JSON.parse(str), 988 + serialize: (data) => JSON.stringify(data), 989 + verify: verifyV1, 990 + migrations: { 991 + currentVersion: '1.0.0', 992 + extractVersion, 993 + onVersionMismatch: (dataVersion, currentVersion, _data) => { 994 + mismatchCalled = true 995 + assertEquals(dataVersion, undefined) 996 + assertEquals(currentVersion, '1.0.0') 997 + return Continue 998 + }, 999 + migrations: [ 1000 + { 1001 + fromVersion: undefined, 1002 + toVersion: '1.0.0', 1003 + migrate: (data): V1 => ({ ...data as V0, version: '1.0.0' }), 1004 + }, 1005 + ], 1006 + }, 1007 + }) 1008 + 1009 + const data = await storage.get() 1010 + assert(mismatchCalled, 'onVersionMismatch should have been called') 1011 + assertEquals(data.count, 42, 'Should apply migration after continue') 1012 + assertEquals(data.version, '1.0.0', 'Should be migrated to current version') 1013 + }) 1014 + 1015 + Deno.test('onVersionMismatch: not called when versions match', async () => { 1016 + const testKey = uniqueKey() 1017 + const v1Data: V1 = { count: 42, version: '1.0.0' } 1018 + localStorage.setItem(testKey, JSON.stringify(v1Data)) 1019 + 1020 + let mismatchCalled = false 1021 + 1022 + const storage = new LocalStorage<V1>({ 1023 + name: testKey, 1024 + defaultValue: { count: 0, version: '1.0.0' }, 1025 + deserialize: (str) => JSON.parse(str), 1026 + serialize: (data) => JSON.stringify(data), 1027 + verify: verifyV1, 1028 + migrations: { 1029 + currentVersion: '1.0.0', // Same as stored version 1030 + extractVersion, 1031 + onVersionMismatch: () => { 1032 + mismatchCalled = true 1033 + return UseDefault 1034 + }, 1035 + migrations: [], 1036 + }, 1037 + }) 1038 + 1039 + const data = await storage.get() 1040 + assertFalse( 1041 + mismatchCalled, 1042 + 'onVersionMismatch should NOT be called for matching versions', 1043 + ) 1044 + assertEquals(data.count, 42, 'Should return data normally') 1045 + assertEquals(data.version, '1.0.0', 'Should preserve version') 1046 + }) 1047 + 1048 + Deno.test('onVersionMismatch: handles future version (fit app use case)', async () => { 1049 + const testKey = uniqueKey() 1050 + type FutureData = { count: number; newField: string; version: string } 1051 + const futureData: FutureData = { 1052 + count: 42, 1053 + newField: 'future', 1054 + version: '3.0.0', 1055 + } 1056 + localStorage.setItem(testKey, JSON.stringify(futureData)) 1057 + 1058 + let mismatchCalled = false 1059 + let shouldRefreshApp = false 1060 + 1061 + const storage = new LocalStorage<V2>({ 1062 + name: testKey, 1063 + defaultValue: { count: 0, name: '', version: '2.0.0' }, 1064 + deserialize: (str) => JSON.parse(str), 1065 + serialize: (data) => JSON.stringify(data), 1066 + verify: verifyV2, 1067 + migrations: { 1068 + currentVersion: '2.0.0', // Lower than stored version (3.0.0) 1069 + extractVersion, 1070 + onVersionMismatch: (dataVersion, currentVersion, data) => { 1071 + mismatchCalled = true 1072 + // Use the default compare function for string versions 1073 + const compareVersions = (v1: string, v2: string) => { 1074 + if (v1 === v2) return 0 1075 + return v1 < v2 ? -1 : 1 1076 + } 1077 + 1078 + if ( 1079 + dataVersion && 1080 + compareVersions(String(dataVersion), String(currentVersion)) > 0 1081 + ) { 1082 + // Data is from a newer version - trigger app refresh 1083 + shouldRefreshApp = true 1084 + 1085 + // Extract what we can from future data and downgrade gracefully 1086 + const futureData = data as FutureData 1087 + return { 1088 + count: futureData.count, 1089 + name: 'downgraded', // Add missing field with default 1090 + version: currentVersion as string, // Force to current version 1091 + } 1092 + } 1093 + 1094 + return Continue 1095 + }, 1096 + migrations: [], 1097 + }, 1098 + }) 1099 + 1100 + const data = await storage.get() 1101 + assert(mismatchCalled, 'onVersionMismatch should have been called') 1102 + assert(shouldRefreshApp, 'Should have triggered app refresh flag') 1103 + assertEquals( 1104 + data.count, 1105 + 42, 1106 + 'Should preserve compatible data from future version', 1107 + ) 1108 + assertEquals( 1109 + data.name, 1110 + 'downgraded', 1111 + 'Should add missing fields with defaults', 1112 + ) 1113 + assertEquals(data.version, '2.0.0', 'Should force to current version') 1114 + }) 1115 + 1116 + Deno.test('onVersionMismatch: handles numeric version comparison', async () => { 1117 + const testKey = uniqueKey() 1118 + type NumericV3 = { count: number; version: number } 1119 + const v3Data: NumericV3 = { count: 42, version: 3 } 1120 + localStorage.setItem(testKey, JSON.stringify(v3Data)) 1121 + 1122 + let mismatchCalled = false 1123 + 1124 + const storage = new LocalStorage<NumericV3>({ 1125 + name: testKey, 1126 + defaultValue: { count: 0, version: 2 }, 1127 + deserialize: (str) => JSON.parse(str), 1128 + serialize: (data) => JSON.stringify(data), 1129 + verify: (data) => 1130 + typeof data === 'object' && data !== null && 'count' in data && 1131 + 'version' in data, 1132 + migrations: { 1133 + currentVersion: 2, // Lower than stored version (3) 1134 + extractVersion: extractNumberVersion, 1135 + onVersionMismatch: (dataVersion, currentVersion, data) => { 1136 + mismatchCalled = true 1137 + assertEquals(dataVersion, 3) 1138 + assertEquals(currentVersion, 2) 1139 + 1140 + // Downgrade by forcing version 1141 + const stored = data as NumericV3 1142 + return { count: stored.count, version: currentVersion as number } 1143 + }, 1144 + migrations: [], 1145 + }, 1146 + }) 1147 + 1148 + const data = await storage.get() 1149 + assert(mismatchCalled, 'onVersionMismatch should have been called') 1150 + assertEquals(data.count, 42, 'Should preserve count') 1151 + assertEquals(data.version, 2, 'Should force to current numeric version') 1152 + })
+24
packages/store/utils/datetime.ts
··· 1 + let mockTime: Date | null = null 2 + 3 + export const DAY_MS = 24 * 60 * 60 * 1000 4 + 5 + export function setMockTime(date: Date | null) { 6 + mockTime = date 7 + } 8 + 9 + export function getNow(secondsInFuture: number = 0): Date { 10 + const date = mockTime ? new Date(mockTime) : new Date() 11 + date.setSeconds(date.getSeconds() + secondsInFuture) 12 + return date 13 + } 14 + 15 + export function isSameDay(d1: Date, d2: Date): boolean { 16 + return d1.getFullYear() === d2.getFullYear() && 17 + d1.getMonth() === d2.getMonth() && 18 + d1.getDate() === d2.getDate() 19 + } 20 + 21 + export function isToday(d1?: Date): boolean { 22 + if (!d1) return false 23 + return isSameDay(d1, getNow()) 24 + }
+18
packages/ui/deno.json
··· 1 + { 2 + "name": "@civility/ui", 3 + "version": "0.1.2", 4 + "exports": { 5 + ".": "./mod.ts" 6 + }, 7 + "tasks": { 8 + "build": "deno run -A ./build.ts" 9 + }, 10 + "imports": { 11 + "@std/fs": "jsr:@std/fs@^1.0.23", 12 + "@std/html": "jsr:@std/html@^1.0.5", 13 + "lit": "npm:lit@^3.3.2", 14 + "postcss": "npm:postcss@^8.5.6", 15 + "postcss-autoprefixer": "npm:autoprefixer@^10.4.27", 16 + "postcss-import": "npm:postcss-import@^16.1.1" 17 + } 18 + }
+7
packages/workers/deno.json
··· 1 + { 2 + "name": "@civility/workers", 3 + "version": "0.1.2", 4 + "exports": { 5 + ".": "./mod.ts" 6 + } 7 + }
src/_base.css packages/ui/_base.css
src/_components.css packages/ui/_components.css
src/_normalize.css packages/ui/_normalize.css
src/base/animation.css packages/ui/base/animation.css
src/base/colors.css packages/ui/base/colors.css
src/base/forms.css packages/ui/base/forms.css
src/base/landmarks.css packages/ui/base/landmarks.css
src/base/typography.css packages/ui/base/typography.css
src/civility.css packages/ui/civility.css
src/components/ui-counter.ts packages/ui/components/ui-counter.ts
src/components/ui-icon.ts packages/ui/components/ui-icon.ts
src/components/ui-pwa-install.ts packages/ui/components/ui-pwa-install.ts
src/components/ui-pwa-version.ts packages/ui/components/ui-pwa-version.ts
src/index.d.ts index.d.ts
src/mod.d.ts packages/ui/mod.d.ts
src/mod.ts packages/ui/mod.ts
src/modules/__mocks__/routing.ts packages/ui/modules/__mocks__/routing.ts
src/modules/__tests__/html.test.ts packages/ui/modules/__tests__/html.test.ts
src/modules/__tests__/layout_router.test.ts packages/ui/modules/__tests__/layout_router.test.ts
src/modules/__tests__/router.test.ts packages/ui/modules/__tests__/router.test.ts
src/modules/html.ts packages/ui/modules/html.ts
src/modules/layout_router.ts packages/ui/modules/layout_router.ts
src/modules/router.ts packages/ui/modules/router.ts
src/themes/blog.css packages/ui/themes/blog.css
src/themes/dark.css packages/ui/themes/dark.css
src/themes/default.css packages/ui/themes/default.css
src/themes/docs.css packages/ui/themes/docs.css
src/utilities.css packages/ui/utilities.css
src/workers/client.ts packages/workers/client.ts
src/workers/mod.ts packages/workers/mod.ts
src/workers/plugins/cleanup.ts packages/workers/plugins/cleanup.ts
src/workers/plugins/precache.ts packages/workers/plugins/precache.ts
src/workers/plugins/strategies.ts packages/workers/plugins/strategies.ts
src/workers/plugins/updates.ts packages/workers/plugins/updates.ts
src/workers/worker.ts packages/workers/worker.ts