wiki-formant 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -27
- package/bin/maintenance-log.mjs +198 -0
- package/bin/mark-verified.mjs +75 -0
- package/bin/prisma-models.mjs +53 -0
- package/dist/analytics.d.ts +0 -25
- package/dist/analytics.js +10 -49
- package/dist/base.css +23 -2
- package/dist/blocks.d.ts +7 -6
- package/dist/blocks.js +11 -3
- package/dist/chart.d.ts +0 -13
- package/dist/chart.js +4 -0
- package/dist/combobox.d.ts +0 -4
- package/dist/combobox.js +2 -0
- package/dist/crawlers.d.ts +0 -24
- package/dist/crawlers.js +9 -3
- package/dist/dom.d.ts +0 -5
- package/dist/dom.js +2 -0
- package/dist/editor.d.ts +0 -43
- package/dist/editor.js +7 -1
- package/dist/feed.d.ts +0 -14
- package/dist/feed.js +4 -0
- package/dist/freshness.d.ts +0 -7
- package/dist/freshness.js +3 -0
- package/dist/headers.d.ts +0 -8
- package/dist/headers.js +3 -1
- package/dist/headings.d.ts +0 -8
- package/dist/headings.js +2 -0
- package/dist/history.d.ts +0 -6
- package/dist/history.js +2 -0
- package/dist/http.d.ts +0 -11
- package/dist/http.js +5 -0
- package/dist/license.d.ts +0 -8
- package/dist/license.js +2 -0
- package/dist/link-check.d.ts +0 -21
- package/dist/link-check.js +6 -2
- package/dist/links.d.ts +0 -5
- package/dist/links.js +2 -0
- package/dist/maintenance.d.ts +61 -0
- package/dist/maintenance.js +137 -0
- package/dist/maps.d.ts +0 -6
- package/dist/maps.js +2 -0
- package/dist/markdown.d.ts +0 -2
- package/dist/markdown.js +1 -1
- package/dist/mcp.d.ts +0 -14
- package/dist/mcp.js +1 -1
- package/dist/metadata.d.ts +14 -11
- package/dist/metadata.js +33 -0
- package/dist/pagination.d.ts +0 -5
- package/dist/pagination.js +0 -4
- package/dist/passkey.js +2 -15
- package/dist/rate-limit.d.ts +5 -16
- package/dist/rate-limit.js +10 -1
- package/dist/react-server.d.ts +0 -12
- package/dist/react-server.js +2 -0
- package/dist/revisions.d.ts +0 -7
- package/dist/revisions.js +2 -0
- package/dist/rola.d.ts +74 -0
- package/dist/rola.js +97 -0
- package/dist/search.d.ts +0 -12
- package/dist/search.js +4 -0
- package/dist/sidebar.d.ts +0 -16
- package/dist/sidebar.js +2 -0
- package/dist/stats.d.ts +0 -4
- package/dist/stats.js +2 -2
- package/dist/taxonomy.d.ts +0 -4
- package/dist/taxonomy.js +2 -2
- package/dist/text.d.ts +16 -22
- package/dist/text.js +39 -5
- package/dist/tiptap.d.ts +5 -4
- package/dist/tiptap.js +2 -1
- package/dist/upload.d.ts +31 -0
- package/dist/upload.js +91 -0
- package/dist/validation.d.ts +0 -6
- package/dist/validation.js +3 -3
- package/dist/well-known.d.ts +0 -20
- package/dist/well-known.js +3 -0
- package/package.json +37 -8
- package/prisma/analytics.prisma +57 -0
- package/prisma/passkey.prisma +21 -0
package/README.md
CHANGED
|
@@ -219,6 +219,8 @@ export async function GET(request: Request) {
|
|
|
219
219
|
}
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
+
`descriptorResponse` and `descriptorHandler` serve a JSON descriptor (an agent card, a manifest, an OpenAPI document) with its own ETag and `Access-Control-Allow-Origin: *`, since every descriptor is public and browser agents read them; six of nine routes had passed the header by hand.
|
|
223
|
+
|
|
222
224
|
`corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
|
|
223
225
|
|
|
224
226
|
```ts
|
|
@@ -261,9 +263,9 @@ const nextConfig = {
|
|
|
261
263
|
|
|
262
264
|
Four configs had restated these by hand on the grounds that a config could not import an ESM-only package. It could not because no subpath had a `default` condition, and Next resolves a config's imports the way `require` does; every subpath has one now, and Node 24 loads the module there unchanged.
|
|
263
265
|
|
|
264
|
-
`contentSecurityPolicy` starts from
|
|
266
|
+
`contentSecurityPolicy` starts from the policy all four origins shared line for line, and each directive passed replaces its default rather than appending, so the call site reads as the whole list. An empty list drops a directive.
|
|
265
267
|
|
|
266
|
-
The default `frame-src` is `
|
|
268
|
+
The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the same list the sanitiser's `iframeHosts` defaults to. The sanitiser's comment had asked every config to keep the two matched by hand, and one did not: its policy lacked the bare `youtube.com` and `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning and then rendered blank. A site with its own host list passes it to the sanitiser and states the same hosts in its `frame-src`.
|
|
267
269
|
|
|
268
270
|
## Pagination and versioning
|
|
269
271
|
|
|
@@ -394,9 +396,9 @@ and a crawler obeys only its most-specific matching group, so an agent with no
|
|
|
394
396
|
group of its own falls through to `*`. Three wikis were measuring five crawlers
|
|
395
397
|
they had never addressed.
|
|
396
398
|
|
|
397
|
-
Every agent group also allows
|
|
399
|
+
Every agent group also allows the agent surface — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
|
|
398
400
|
|
|
399
|
-
`crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one each
|
|
401
|
+
`crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
|
|
400
402
|
|
|
401
403
|
`trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
|
|
402
404
|
|
|
@@ -438,6 +440,12 @@ const faq = faqPageLd(questionPairs(html), url);
|
|
|
438
440
|
{faq && <JsonLd data={faq} />}
|
|
439
441
|
```
|
|
440
442
|
|
|
443
|
+
`describe(text)` is a page's meta description from any prose, HTML, markdown or plain: tags, entities, link targets and bare URLs go, and the rest fits `DESCRIPTION_LIMIT` (160) as whole sentences where they fill half of it, else cut at a word with an ellipsis. Four sites had four rules, one cutting mid-word at 150 and one slicing at 160 with no mark. `openingText(blocks)` (`wiki-formant/text`) is what a page without an excerpt is described by: its first top-level `content` block with prose once the headings are out, since a page that opens with an infobox would otherwise be described by its metadata table.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
const description = describe(page.metadata?.excerpt || openingText(page.content));
|
|
447
|
+
```
|
|
448
|
+
|
|
441
449
|
`sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
|
|
442
450
|
|
|
443
451
|
```ts
|
|
@@ -506,7 +514,7 @@ each change on `data-action`; `base.css` only resets the list.
|
|
|
506
514
|
|
|
507
515
|
Three repos, four feeds, 47 of 64 significant lines identical — including,
|
|
508
516
|
verbatim in two of them, the comment explaining why the apostrophe escapes
|
|
509
|
-
numerically. `
|
|
517
|
+
numerically. `feedResponse` is the union.
|
|
510
518
|
|
|
511
519
|
```ts
|
|
512
520
|
export const GET = async (request: Request) => feedResponse(request, channel, await items());
|
|
@@ -554,11 +562,11 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
|
|
|
554
562
|
|
|
555
563
|
`scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once.
|
|
556
564
|
|
|
557
|
-
|
|
565
|
+
The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
|
|
558
566
|
|
|
559
567
|
## Search
|
|
560
568
|
|
|
561
|
-
`proseSql`, `
|
|
569
|
+
`proseSql`, `HEADLINE_OPTIONS` and `FTS_RANK_NORMALIZATION` are what a literal tier and a full-text tier must agree on. `searchTsvDdl(table)` is the statements that build the generated `search_tsv` column and its index — dropped and re-added in one transaction every run, because a skip-if-present script is blind to a changed expression.
|
|
562
570
|
|
|
563
571
|
## Block trees
|
|
564
572
|
|
|
@@ -574,7 +582,7 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
|
|
|
574
582
|
|
|
575
583
|
`computeRevisionDiff` defaults to the same shape, path-addressed, so a repo whose containers are the core two passes none.
|
|
576
584
|
|
|
577
|
-
The dispatch over a repo's own block union stays in that repo; the bodies come from here. `
|
|
585
|
+
The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
|
|
578
586
|
|
|
579
587
|
The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
|
|
580
588
|
|
|
@@ -620,7 +628,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
|
|
|
620
628
|
```ts
|
|
621
629
|
import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
|
|
622
630
|
|
|
623
|
-
const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and
|
|
631
|
+
const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and the same hosts in the CSP's frame-src
|
|
624
632
|
const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
|
|
625
633
|
```
|
|
626
634
|
|
|
@@ -628,27 +636,41 @@ The default list is derived from what the editor nodes in `wiki-formant/tiptap`
|
|
|
628
636
|
|
|
629
637
|
Classes pass by name only. Limiting `style` to layout and paint is worth nothing while `class` is free, because every site's own stylesheet ships `fixed inset-0 z-50`, and those draw a fake prompt over the chrome as well as `position` does.
|
|
630
638
|
|
|
639
|
+
## Image uploads
|
|
640
|
+
|
|
641
|
+
`wiki-formant/upload` is the route an editor's image upload posts to. Two wikis had written it and their limits had drifted: one took 25 MB with no cap on decoded pixels and trusted the Content-Type the client sent, the other 4 MB, 40 megapixels and the file's own leading bytes. These are the second one's defaults.
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
// src/app/api/upload/route.ts
|
|
645
|
+
export const runtime = 'nodejs';
|
|
646
|
+
export const POST = uploadHandler({
|
|
647
|
+
authorize: async () => (await currentUser())?.id ?? errors.unauthorized('Sign in to upload.'),
|
|
648
|
+
prefix: 'wiki/',
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers.
|
|
653
|
+
|
|
631
654
|
## Editor nodes
|
|
632
655
|
|
|
633
656
|
`wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. The four `@tiptap/*` packages are optional peers, so a consumer taking only the taxonomy still installs a package with no runtime dependencies.
|
|
634
657
|
|
|
635
658
|
```ts
|
|
636
659
|
const CodeBlock = createCodeBlock({
|
|
637
|
-
langs: CODE_LANGS, defaultLang: DEFAULT_LANG,
|
|
638
660
|
classNames: { button: 'lang-btn', option: 'lang-option', optionActive: 'text-accent' },
|
|
639
661
|
icons: { chevron: open => <ChevronDown className={open ? 'rotate-180' : ''} /> },
|
|
640
662
|
});
|
|
641
663
|
```
|
|
642
664
|
|
|
643
|
-
The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
|
|
665
|
+
The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant/blocks`, which two wikis had typed identically; a wiki with its own passes `langs` and `defaultLang`. The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
|
|
644
666
|
|
|
645
667
|
`createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
|
|
646
668
|
|
|
647
669
|
`createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
|
|
648
670
|
|
|
649
|
-
`
|
|
671
|
+
`toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
|
|
650
672
|
|
|
651
|
-
`useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
|
|
673
|
+
`useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
|
|
652
674
|
|
|
653
675
|
```tsx
|
|
654
676
|
const url = useUrlPrompt(editor, { resolveMapUrl });
|
|
@@ -656,7 +678,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
|
|
|
656
678
|
{url.kind && <input autoFocus value={url.value} onChange={e => url.setValue(e.target.value)} onKeyDown={url.onKeyDown} />}
|
|
657
679
|
```
|
|
658
680
|
|
|
659
|
-
A link is stored as its `href` and the `link` class, nothing else
|
|
681
|
+
A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
|
|
660
682
|
|
|
661
683
|
The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
|
|
662
684
|
|
|
@@ -672,8 +694,8 @@ address and browser under a salt that is replaced every UTC day, and the old
|
|
|
672
694
|
salt is deleted, so a visitor is told apart within a day and never followed
|
|
673
695
|
into the next. Rows are kept 400 days. The package owns the SQL and the app
|
|
674
696
|
owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
|
|
675
|
-
placeholders, and the four tables it needs
|
|
676
|
-
|
|
697
|
+
placeholders, and the four tables it needs ship as Prisma models in
|
|
698
|
+
`prisma/analytics.prisma` (see `prisma-models` below). The fourth, `ViewDay`, holds days a site counted
|
|
677
699
|
elsewhere before it counted its own; `digest` adds them to any window that
|
|
678
700
|
reaches back that far, and a site with no such history leaves it empty.
|
|
679
701
|
|
|
@@ -707,14 +729,12 @@ any other browser event. Underneath the tracker, server code calls
|
|
|
707
729
|
its zero-dependency guarantee:
|
|
708
730
|
|
|
709
731
|
```ts
|
|
710
|
-
|
|
711
|
-
// null for an empty field, so a blank search cannot fire an event
|
|
712
|
-
if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
|
|
732
|
+
after(() => recordEvent(sql, { name: 'Signup', props: { plan }, url, headers }));
|
|
713
733
|
```
|
|
714
734
|
|
|
715
735
|
`mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
|
|
716
|
-
and may be a batch; `
|
|
717
|
-
the same question aggregates as one row. Pass the request's headers and the
|
|
736
|
+
and may be a batch; `trackSearch` normalises a search box's free text so
|
|
737
|
+
the same question aggregates as one row, and drops an empty one. Pass the request's headers and the
|
|
718
738
|
event joins whoever sent it, person or agent.
|
|
719
739
|
|
|
720
740
|
`digest(sql, { days })` reads a site back as one JSON object: visitors, page
|
|
@@ -735,8 +755,8 @@ its readers asked for. The queries that return zero rows are the valuable ones
|
|
|
735
755
|
nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
|
|
736
756
|
returns the whole of it. `gate.route` is the sign-in route,
|
|
737
757
|
`gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
|
|
738
|
-
while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`)
|
|
739
|
-
models
|
|
758
|
+
while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
|
|
759
|
+
models in `prisma/passkey.prisma`, and every token is kept only as its hash.
|
|
740
760
|
`@simplewebauthn/server` is an optional peer.
|
|
741
761
|
|
|
742
762
|
There is no sign-up. While no passkey is saved, the page saves the first one
|
|
@@ -786,6 +806,22 @@ counted itself, says so, and drops the comparison with the window before. The ch
|
|
|
786
806
|
axis from zero, so `lightweight-charts` is an optional peer that a stats page
|
|
787
807
|
needs installed.
|
|
788
808
|
|
|
809
|
+
## ROLA sign-in
|
|
810
|
+
|
|
811
|
+
`wiki-formant/rola` is the part of a Radix wallet sign-in two sites had each written: a single-use challenge, the wallet's proof of it, and the HS256 token a verified address is handed. How a site keeps the session stays with it, because the two keep it differently: radix-wiki a `Session` row behind a seven-day cookie, caper a stateless token it slides forward, with a short one for agents.
|
|
812
|
+
|
|
813
|
+
```ts
|
|
814
|
+
const rola = createRola({ expectedOrigin, dAppDefinitionAddress, networkId, applicationName, challenges: prisma.challenge });
|
|
815
|
+
const { challenge } = await rola.generateChallenge();
|
|
816
|
+
const v = await rola.verifySignedChallenge(signed); // { isValid, error? }
|
|
817
|
+
|
|
818
|
+
const secret = sessionSecret(process.env.JWT_SECRET); // throws under 32 characters
|
|
819
|
+
const token = await signSession({ id: signed.address }, secret, 7 * 86400);
|
|
820
|
+
const claims = await verifySession<{ id: string }>(bearerToken(request.headers) ?? cookie, secret);
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
Where the two copies differed, this takes the stricter. A challenge is spent by one conditional delete, so two requests racing with one signature cannot both pass, and it is spent whatever the outcome; making one purges the expired. Only an account proof signs in. Verification pins HS256. `jose` and `@radixdlt/rola` are optional peers.
|
|
824
|
+
|
|
789
825
|
## Charts
|
|
790
826
|
|
|
791
827
|
`TimeChart` (`wiki-formant/chart`) is every time-series chart on the four sites:
|
|
@@ -819,11 +855,11 @@ straight between closes and a count's is curved.
|
|
|
819
855
|
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
820
856
|
`mean` for a count, so the week in progress keeps its level instead of reading
|
|
821
857
|
as a collapse. A single range draws no range buttons and shows every point.
|
|
822
|
-
`
|
|
858
|
+
`chartSteps` is exported for a chart drawn elsewhere.
|
|
823
859
|
|
|
824
860
|
`markers` are events, `{ time, label }`, each drawn as an arrow on the last
|
|
825
861
|
point at or before it, so one mid-week lands on its week and two on one point
|
|
826
|
-
share it
|
|
862
|
+
share it. `step` opens on a step where the
|
|
827
863
|
range can make it, and `onView` hears the range and step on mount and on every
|
|
828
864
|
change, for a page that keeps them in its URL:
|
|
829
865
|
|
|
@@ -837,7 +873,7 @@ the site's stylesheet gives them and the box a look.
|
|
|
837
873
|
|
|
838
874
|
## Base stylesheet
|
|
839
875
|
|
|
840
|
-
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
876
|
+
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
841
877
|
|
|
842
878
|
```css
|
|
843
879
|
@import "wiki-formant/base.css" layer(components);
|
|
@@ -852,7 +888,8 @@ and there is no root export: `import … from 'wiki-formant'` does not resolve.
|
|
|
852
888
|
barrel loads every module it names for the one symbol a route wanted, and had
|
|
853
889
|
already re-exported one module twice. The emitted `.d.ts` files are the reference.
|
|
854
890
|
There is no hand-maintained symbol list here, because the one that used to be here
|
|
855
|
-
drifted from them.
|
|
891
|
+
drifted from them. A function a module uses only itself is marked `@internal`: the
|
|
892
|
+
package's tests still reach it, and the `.d.ts` leaves it out.
|
|
856
893
|
|
|
857
894
|
`wiki-formant/tsconfig.base.json` is the compiler floor: an app's `tsconfig.json` extends it and keeps only `paths`, `include`, `exclude` and any licensed extra, since tsconfig resolves those against the file that declares them.
|
|
858
895
|
|
|
@@ -870,6 +907,8 @@ long, across every bucket that names it, rather than letting the refill trickle
|
|
|
870
907
|
one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
|
|
871
908
|
and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
|
|
872
909
|
`x-real-ip` was set, since a proxy that appends hands the client that slot.
|
|
910
|
+
`limitClient(prefix, headers, opts)` is the per-address gate, `rateLimit` over
|
|
911
|
+
`clientKey`, which a dozen call sites had written out.
|
|
873
912
|
|
|
874
913
|
## A bin
|
|
875
914
|
|
|
@@ -895,12 +934,31 @@ npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the
|
|
|
895
934
|
npx passkey-invite https://caper.network/stats
|
|
896
935
|
```
|
|
897
936
|
|
|
937
|
+
`prisma-models` copies the models the analytics and passkey SQL reads, `prisma/analytics.prisma` and `prisma/passkey.prisma`, into an app's schema folder, and `--check` exits 1 when a copy is missing or has drifted. Prisma reads a schema split across one folder's files, so the app moves `prisma/schema.prisma` into `prisma/schema/`, points `prisma.config.ts`'s `schema` at the folder, and keeps its generator, datasource and own models there beside the shipped files. A generator `output` is relative to the file that declares it, so it gains a `../`.
|
|
938
|
+
|
|
939
|
+
```sh
|
|
940
|
+
npx prisma-models # write both into prisma/schema
|
|
941
|
+
npx prisma-models --check # in the build, before prisma generate
|
|
942
|
+
```
|
|
943
|
+
|
|
898
944
|
`fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
|
|
899
945
|
|
|
900
946
|
```sh
|
|
901
947
|
npx fts-ddl pages
|
|
902
948
|
```
|
|
903
949
|
|
|
950
|
+
`mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page; a revision is written at most once a day. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
|
|
951
|
+
|
|
952
|
+
```sh
|
|
953
|
+
npx mark-verified contents/tech/radix-engine scrypto # one page
|
|
954
|
+
npx mark-verified --all ecosystem --invalidate wiki # a tag path, then the cache tag
|
|
955
|
+
npx maintenance-log read --at contents/tech/operations/wiki-maintenance-log --routine wiki-sweep --author <id>
|
|
956
|
+
npx maintenance-log backlog-add '[{"item": "…"}]' --at operations/wiki-maintenance-log --routine caper-wiki-sweep \
|
|
957
|
+
--pages wiki_pages --revisions wiki_revisions --page-key wiki_page_id --scope caper_id
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
`--scope column` keeps to rows where the column is NULL (`=value` for one value), `--table`/`--pages`/`--revisions` default to `pages` and `revisions`, and `--invalidate <tag>` runs `vercel cache invalidate` after a write.
|
|
961
|
+
|
|
904
962
|
## A reusable workflow
|
|
905
963
|
|
|
906
964
|
`.github/workflows/publish-mcp-registry.yml` republishes an origin's server to the MCP registry when the version in its `server.json` moves. Each app keeps its own trigger and calls it with its domain:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/maintenance-log.mjs – state I/O for a wiki-sweep routine's log page.
|
|
3
|
+
//
|
|
4
|
+
// The routine's memory between runs lives in one wiki page's metadata.state,
|
|
5
|
+
// and the page shows it, re-rendered and capped on every write (the rules are
|
|
6
|
+
// `src/maintenance.ts`). radix-wiki and caper each carried this script with the
|
|
7
|
+
// same rules against a different table, so the tables are arguments. Run it
|
|
8
|
+
// from the app's root, where `pg` is installed and `.env` holds DATABASE_URL.
|
|
9
|
+
//
|
|
10
|
+
// npx maintenance-log read # print the state as JSON
|
|
11
|
+
// npx maintenance-log write '<json>' # replace it (or `write -` from stdin)
|
|
12
|
+
// npx maintenance-log compact [--dry-run] # re-apply the caps to what is stored
|
|
13
|
+
// npx maintenance-log backlog-add '<json>' # append items under a row lock
|
|
14
|
+
//
|
|
15
|
+
// with the page and its tables named every time:
|
|
16
|
+
//
|
|
17
|
+
// --at <tagPath>/<slug> the log page, e.g. contents/tech/operations/wiki-maintenance-log
|
|
18
|
+
// --routine <name> named on the page and in the revision message
|
|
19
|
+
// --pages <table> default pages
|
|
20
|
+
// --revisions <table> default revisions
|
|
21
|
+
// --page-key <column> the revisions column naming the page, default page_id
|
|
22
|
+
// --scope <column>[=<value>] only rows where the column is NULL, or equals the value
|
|
23
|
+
// --author <id> revisions.author_id, default NULL
|
|
24
|
+
// --invalidate <tag> `vercel cache invalidate --tag` after a write
|
|
25
|
+
//
|
|
26
|
+
// `write` takes the whole new state: read, change, write back. A revision is
|
|
27
|
+
// written at most once a day, and on every `compact`; other writes edit the
|
|
28
|
+
// page in place, because a full copy per run is what made radix-wiki's log
|
|
29
|
+
// over half its revisions table. `backlog-add` is how another routine files an
|
|
30
|
+
// item: `write` replaces the whole state, so an append through it could lose a
|
|
31
|
+
// concurrent run's, and the row lock serialises the two. A revision's version
|
|
32
|
+
// is the page's `version` column bumped a minor where the table has one, else
|
|
33
|
+
// the latest revision's.
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { addBacklog, compact, ident, render, scopeSql } from '../dist/maintenance.js';
|
|
37
|
+
import { bump } from '../dist/versioning.js';
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
const arg = name => {
|
|
41
|
+
const i = args.indexOf(name);
|
|
42
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
43
|
+
};
|
|
44
|
+
const [mode, payload] = args.filter((a, i) => !a.startsWith('--') && !args[i - 1]?.match(/^--(?!dry-run)/));
|
|
45
|
+
const dry = args.includes('--dry-run');
|
|
46
|
+
const at = arg('--at') ?? '';
|
|
47
|
+
const routine = arg('--routine');
|
|
48
|
+
const slugAt = at.lastIndexOf('/');
|
|
49
|
+
|
|
50
|
+
if (!['read', 'write', 'compact', 'backlog-add'].includes(mode) || slugAt < 1 || !routine) {
|
|
51
|
+
console.error('usage: maintenance-log <read|write|compact|backlog-add> [json|-] --at <tagPath>/<slug> --routine <name> [--pages t] [--revisions t] [--page-key c] [--scope c[=v]] [--author id] [--invalidate tag] [--dry-run]');
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
const pages = ident(arg('--pages') ?? 'pages', '--pages');
|
|
55
|
+
const revisions = ident(arg('--revisions') ?? 'revisions', '--revisions');
|
|
56
|
+
const pageKey = ident(arg('--page-key') ?? 'page_id', '--page-key');
|
|
57
|
+
const scope = scopeSql(arg('--scope'), 3);
|
|
58
|
+
const where = `tag_path = $1 AND slug = $2 AND ${scope.sql}`;
|
|
59
|
+
const address = [at.slice(0, slugAt), at.slice(slugAt + 1), ...scope.values];
|
|
60
|
+
|
|
61
|
+
if (!process.env.DATABASE_URL) {
|
|
62
|
+
try {
|
|
63
|
+
process.loadEnvFile('.env');
|
|
64
|
+
} catch {
|
|
65
|
+
// reported below
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!process.env.DATABASE_URL) {
|
|
69
|
+
console.error('no DATABASE_URL in the environment or ./.env');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const readStdin = async () => {
|
|
74
|
+
let data = '';
|
|
75
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
76
|
+
return data;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Imported once the command is known good: `pg` is the app's, not this package's.
|
|
80
|
+
const { default: pg } = await import('pg');
|
|
81
|
+
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
82
|
+
const q = (text, values) => client.query(text, values).then(r => r.rows);
|
|
83
|
+
let wrote = false;
|
|
84
|
+
try {
|
|
85
|
+
await client.connect();
|
|
86
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
87
|
+
|
|
88
|
+
if (mode === 'read') {
|
|
89
|
+
const [page] = await q(`SELECT metadata FROM ${pages} WHERE ${where}`, address);
|
|
90
|
+
if (!page) throw new Error(`no log page at ${at}`);
|
|
91
|
+
process.stdout.write(`${JSON.stringify(page.metadata?.state ?? {}, null, 2)}\n`);
|
|
92
|
+
} else if (mode === 'backlog-add') {
|
|
93
|
+
const items = JSON.parse(payload ?? '[]');
|
|
94
|
+
if (!Array.isArray(items) || !items.length) throw new Error('backlog-add expects a non-empty JSON array of items');
|
|
95
|
+
await q('BEGIN');
|
|
96
|
+
const [page] = await q(`SELECT id, metadata FROM ${pages} WHERE ${where} FOR UPDATE`, address);
|
|
97
|
+
if (!page) throw new Error(`no log page at ${at}`);
|
|
98
|
+
const state = page.metadata?.state ?? {};
|
|
99
|
+
const added = addBacklog(state, items, today);
|
|
100
|
+
if (!added.length) {
|
|
101
|
+
await q('ROLLBACK');
|
|
102
|
+
console.log(' nothing to add (all duplicates)');
|
|
103
|
+
} else {
|
|
104
|
+
const next = compact({ ...state, backlog: [...(state.backlog ?? []), ...added] });
|
|
105
|
+
console.log(` ${dry ? '[dry] ' : ''}+${added.length} backlog item(s), ${next.backlog.length} open`);
|
|
106
|
+
for (const a of added) console.log(` ${a.item}`);
|
|
107
|
+
if (dry) await q('ROLLBACK');
|
|
108
|
+
else {
|
|
109
|
+
await q(`UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now() WHERE id = $3`, [
|
|
110
|
+
JSON.stringify(render(next, routine)),
|
|
111
|
+
JSON.stringify({ state: next, lastVerified: today }),
|
|
112
|
+
page.id,
|
|
113
|
+
]);
|
|
114
|
+
await q('COMMIT');
|
|
115
|
+
wrote = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
const [page] = await q(`SELECT * FROM ${pages} WHERE ${where}`, address);
|
|
120
|
+
if (!page) throw new Error(`no log page at ${at}`);
|
|
121
|
+
let input = page.metadata?.state ?? {};
|
|
122
|
+
if (mode === 'write') {
|
|
123
|
+
const text = payload === undefined || payload === '-' ? await readStdin() : payload;
|
|
124
|
+
if (!text.trim()) throw new Error('write needs the state as JSON, as an argument or on stdin with `write -`');
|
|
125
|
+
input = JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
const state = compact(input);
|
|
128
|
+
const content = render(state, routine);
|
|
129
|
+
|
|
130
|
+
const [last] = await q(
|
|
131
|
+
`SELECT version, created_at FROM ${revisions} WHERE ${pageKey} = $1 ORDER BY created_at DESC LIMIT 1`,
|
|
132
|
+
[page.id],
|
|
133
|
+
);
|
|
134
|
+
const revise = mode === 'compact' || !last || last.created_at.toISOString().slice(0, 10) !== today;
|
|
135
|
+
const was = 'version' in page ? page.version : last?.version;
|
|
136
|
+
const version = revise ? bump(was, 'minor') : was;
|
|
137
|
+
|
|
138
|
+
for (const key of ['backlog', 'runHistory', 'feedback']) {
|
|
139
|
+
const [before, after] = [(input[key] ?? []).length, state[key].length];
|
|
140
|
+
if (before !== after) console.log(` ${key}: ${before} → ${after}`);
|
|
141
|
+
}
|
|
142
|
+
console.log(
|
|
143
|
+
` ${dry ? '[dry] ' : ''}state ${JSON.stringify(input).length} → ${JSON.stringify(state).length} B, ` +
|
|
144
|
+
`content ${JSON.stringify(content).length} B${revise ? `, revision v${version}` : ', no revision today'}`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (!dry) {
|
|
148
|
+
await q('BEGIN');
|
|
149
|
+
await q(
|
|
150
|
+
`UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now()${'version' in page ? ', version = $4' : ''} WHERE id = $3`,
|
|
151
|
+
[JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id, ...('version' in page ? [version] : [])],
|
|
152
|
+
);
|
|
153
|
+
if (revise) {
|
|
154
|
+
// An id with no database default (a Prisma cuid) is supplied here.
|
|
155
|
+
const [{ has_default }] = await q(
|
|
156
|
+
`SELECT column_default IS NOT NULL AS has_default FROM information_schema.columns
|
|
157
|
+
WHERE table_schema = current_schema() AND table_name = $1 AND column_name = 'id'`,
|
|
158
|
+
[revisions.slice(1, -1)],
|
|
159
|
+
);
|
|
160
|
+
const row = {
|
|
161
|
+
...(has_default ? {} : { id: randomUUID() }),
|
|
162
|
+
[pageKey.slice(1, -1)]: page.id,
|
|
163
|
+
title: page.title,
|
|
164
|
+
content: JSON.stringify(content),
|
|
165
|
+
version,
|
|
166
|
+
change_type: 'minor',
|
|
167
|
+
author_id: arg('--author') ?? null,
|
|
168
|
+
message: mode === 'compact'
|
|
169
|
+
? 'Compact the log: drop closed and duplicated backlog items, cap the append-only lists'
|
|
170
|
+
: `${routine} run ${state.lastRun ?? today}`,
|
|
171
|
+
};
|
|
172
|
+
const columns = Object.keys(row);
|
|
173
|
+
await q(
|
|
174
|
+
`INSERT INTO ${revisions} (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${columns.map((_, i) => `$${i + 1}`).join(', ')})`,
|
|
175
|
+
Object.values(row),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
await q('COMMIT');
|
|
179
|
+
wrote = true;
|
|
180
|
+
console.log(`log page updated${revise ? ` → v${version}` : ''}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
185
|
+
console.error(e.message);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
} finally {
|
|
188
|
+
await client.end().catch(() => {});
|
|
189
|
+
const tag = arg('--invalidate');
|
|
190
|
+
if (wrote && tag) {
|
|
191
|
+
try {
|
|
192
|
+
execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
|
|
193
|
+
console.log(`cache: ${tag} tag invalidated`);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/mark-verified.mjs – stamp last_verified_at on pages a sweep has checked
|
|
3
|
+
// against their sources and found current, so `freshnessBanner`
|
|
4
|
+
// (`wiki-formant/freshness`) stops calling them outdated. A page that needed an
|
|
5
|
+
// edit gets its revision instead; this is for one that did not.
|
|
6
|
+
//
|
|
7
|
+
// radix-wiki and caper each carried this script against their own table. Run
|
|
8
|
+
// it from the app's root, where `pg` is installed and `.env` holds DATABASE_URL.
|
|
9
|
+
//
|
|
10
|
+
// npx mark-verified <tagPath> <slug> one page ('' is a category hub)
|
|
11
|
+
// npx mark-verified --all <tagPath> every page at or under a tag path
|
|
12
|
+
//
|
|
13
|
+
// --table <table> default pages
|
|
14
|
+
// --scope <column>[=<value>] only rows where the column is NULL, or equals the value
|
|
15
|
+
// --invalidate <tag> `vercel cache invalidate --tag` after a write
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { ident, scopeSql } from '../dist/maintenance.js';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const arg = name => {
|
|
21
|
+
const i = args.indexOf(name);
|
|
22
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
23
|
+
};
|
|
24
|
+
const all = args.includes('--all');
|
|
25
|
+
const [tagPath, slug] = args.filter((a, i) => !a.startsWith('--') && !['--table', '--scope', '--invalidate'].includes(args[i - 1]));
|
|
26
|
+
|
|
27
|
+
if (!tagPath || (!all && slug === undefined)) {
|
|
28
|
+
console.error('usage: mark-verified <tagPath> <slug> | --all <tagPath> [--table t] [--scope c[=v]] [--invalidate tag]');
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
const table = ident(arg('--table') ?? 'pages', '--table');
|
|
32
|
+
const scope = scopeSql(arg('--scope'), 3);
|
|
33
|
+
|
|
34
|
+
if (!process.env.DATABASE_URL) {
|
|
35
|
+
try {
|
|
36
|
+
process.loadEnvFile('.env');
|
|
37
|
+
} catch {
|
|
38
|
+
// reported below
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!process.env.DATABASE_URL) {
|
|
42
|
+
console.error('no DATABASE_URL in the environment or ./.env');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Imported once the command is known good: `pg` is the app's, not this package's.
|
|
47
|
+
const { default: pg } = await import('pg');
|
|
48
|
+
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
49
|
+
try {
|
|
50
|
+
await client.connect();
|
|
51
|
+
const { rowCount } = all
|
|
52
|
+
? await client.query(
|
|
53
|
+
`UPDATE ${table} SET last_verified_at = now() WHERE (tag_path = $1 OR tag_path LIKE $2) AND ${scope.sql}`,
|
|
54
|
+
[tagPath, `${tagPath}/%`, ...scope.values],
|
|
55
|
+
)
|
|
56
|
+
: await client.query(
|
|
57
|
+
`UPDATE ${table} SET last_verified_at = now() WHERE tag_path = $1 AND slug = $2 AND ${scope.sql}`,
|
|
58
|
+
[tagPath, slug, ...scope.values],
|
|
59
|
+
);
|
|
60
|
+
console.log(`Stamped last_verified_at on ${rowCount} page(s).`);
|
|
61
|
+
const tag = arg('--invalidate');
|
|
62
|
+
if (rowCount && tag) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
|
|
65
|
+
console.log(`cache: ${tag} tag invalidated`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error(e.message);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
} finally {
|
|
74
|
+
await client.end().catch(() => {});
|
|
75
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/prisma-models.mjs – copy the Prisma models wiki-formant's SQL reads into an
|
|
3
|
+
// app's schema folder, or check that the copies there have not drifted.
|
|
4
|
+
//
|
|
5
|
+
// `wiki-formant/analytics` and `wiki-formant/passkey` own their SQL and name
|
|
6
|
+
// their tables as written, so the models are the package's. Three apps carried
|
|
7
|
+
// them byte for byte, and the package kept a fourth copy as a comment.
|
|
8
|
+
//
|
|
9
|
+
// Prisma reads a schema split across the .prisma files of one folder. The app
|
|
10
|
+
// points prisma.config.ts at the folder (`schema: 'prisma/schema'`), keeps its
|
|
11
|
+
// generator, datasource and own models in prisma/schema/schema.prisma, and this
|
|
12
|
+
// writes analytics.prisma and passkey.prisma beside them. Run it from the app's
|
|
13
|
+
// root:
|
|
14
|
+
//
|
|
15
|
+
// npx prisma-models # write both into prisma/schema
|
|
16
|
+
// npx prisma-models --check # exit 1 if either is missing or differs
|
|
17
|
+
// npx prisma-models analytics # only the ones named
|
|
18
|
+
// npx prisma-models --dir db/schema # another folder
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const MODELS = ['analytics', 'passkey'];
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const check = args.includes('--check');
|
|
25
|
+
const at = args.indexOf('--dir');
|
|
26
|
+
const dir = at >= 0 ? args[at + 1] : 'prisma/schema';
|
|
27
|
+
const named = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--dir');
|
|
28
|
+
const unknown = named.filter(n => !MODELS.includes(n));
|
|
29
|
+
|
|
30
|
+
if (unknown.length || !dir) {
|
|
31
|
+
console.error(`usage: prisma-models [${MODELS.join('|')}…] [--dir <folder>] [--check]`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(dir)) {
|
|
35
|
+
console.error(`prisma-models: no ${dir}/ here. Move prisma/schema.prisma into it and point prisma.config.ts's schema at the folder.`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let drift = 0;
|
|
40
|
+
for (const name of named.length ? named : MODELS) {
|
|
41
|
+
const shipped = readFileSync(new URL(`../prisma/${name}.prisma`, import.meta.url), 'utf8');
|
|
42
|
+
const target = join(dir, `${name}.prisma`);
|
|
43
|
+
const local = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
44
|
+
if (local === shipped) continue;
|
|
45
|
+
if (check) {
|
|
46
|
+
drift++;
|
|
47
|
+
console.error(`${target} ${local === null ? 'is missing' : 'differs from wiki-formant'}: run npx prisma-models`);
|
|
48
|
+
} else {
|
|
49
|
+
writeFileSync(target, shipped);
|
|
50
|
+
console.log(`wrote ${target}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
process.exit(drift ? 1 : 0);
|