@pylonsync/functions 0.22.59 → 0.22.61

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.
@@ -10,6 +10,7 @@
10
10
  import { withMetaSlot } from "./ssr-page-tree";
11
11
  import { moduleKey, serverBundle } from "./server-bundle";
12
12
  import {
13
+ modulepreloadFiles,
13
14
  routeAssetFiles,
14
15
  routeScriptFiles,
15
16
  type RouteAssetFiles,
@@ -494,11 +495,11 @@ export function finalizeHeaders(
494
495
  * (static) or `export async function generateMetadata(props)` (dynamic,
495
496
  * e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
496
497
  *
497
- * React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
498
- * `title` overrides a layout's static `<title>` (both render; the browser
499
- * uses the last, which is the page's). React does NOT dedupe arbitrary
500
- * `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
501
- * not both, to avoid duplicate tags.
498
+ * A layout can export the same; the page's values win (`mergeMetadata`).
499
+ * The resulting <title>/<meta>/<link> are written into <head> by the server,
500
+ * whatever the layouts render. A tag from the metadata replaces a tag the
501
+ * root layout writes in its own <head> for the same title, `name`,
502
+ * `property` or canonical link (`dropReplacedHeadTags`).
502
503
  */
503
504
  /** A single OpenGraph image (for `openGraph.images` — multiple images). */
504
505
  export interface OgImage {
@@ -586,9 +587,9 @@ export interface SsrMetadata {
586
587
 
587
588
  /**
588
589
  * Build a React fragment of <title>/<meta>/<link> from a page's metadata.
589
- * React 19 auto-hoists these into <head> wherever they render, and the
590
- * host's </head> splice preserves them. React escapes all text/attrs, so
591
- * there's no manual XSS handling. Returns null when there's nothing to emit.
590
+ * The server renders it to HTML on its own and splices it in before
591
+ * </head>. React escapes all text/attrs, so there's no manual XSS
592
+ * handling. Returns null when there's nothing to emit.
592
593
  */
593
594
  export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
594
595
  if (!m) return null;
@@ -732,6 +733,136 @@ export function renderJsonLdHead(m: SsrMetadata | undefined): string {
732
733
  return html;
733
734
  }
734
735
 
736
+ /**
737
+ * The metadata a route renders with: each layout's, root first, then the
738
+ * page's. A later value replaces an earlier one. `openGraph`, `twitter`,
739
+ * `icons` and `alternates` merge field by field. A new `openGraph.image` or
740
+ * `images` drops the earlier image fields, and a new `twitter.image` drops
741
+ * the earlier `twitter.imageAlt`: they describe the other image.
742
+ */
743
+ export function mergeMetadata(
744
+ parent: SsrMetadata | undefined,
745
+ child: SsrMetadata | undefined,
746
+ ): SsrMetadata | undefined {
747
+ if (!parent) return child;
748
+ if (!child) return parent;
749
+ const out: SsrMetadata = { ...parent, ...child };
750
+ if (parent.openGraph && child.openGraph) {
751
+ const base: NonNullable<SsrMetadata["openGraph"]> = { ...parent.openGraph };
752
+ if (child.openGraph.image !== undefined || child.openGraph.images !== undefined) {
753
+ delete base.image;
754
+ delete base.imageSecureUrl;
755
+ delete base.imageType;
756
+ delete base.imageWidth;
757
+ delete base.imageHeight;
758
+ delete base.imageAlt;
759
+ delete base.images;
760
+ }
761
+ out.openGraph = { ...base, ...child.openGraph };
762
+ }
763
+ if (parent.twitter && child.twitter) {
764
+ const base: NonNullable<SsrMetadata["twitter"]> = { ...parent.twitter };
765
+ if (child.twitter.image !== undefined) delete base.imageAlt;
766
+ out.twitter = { ...base, ...child.twitter };
767
+ }
768
+ if (parent.icons && child.icons) out.icons = { ...parent.icons, ...child.icons };
769
+ if (parent.alternates && child.alternates) {
770
+ out.alternates = { ...parent.alternates, ...child.alternates };
771
+ }
772
+ return out;
773
+ }
774
+
775
+ /**
776
+ * The merged `metadata` / `generateMetadata` of a route's layouts, root
777
+ * first (see `mergeMetadata`). A layout module that fails to import is
778
+ * skipped here: the layout render reports that failure.
779
+ */
780
+ async function resolveLayoutMetadata(
781
+ cwd: string,
782
+ layouts: string[] | undefined,
783
+ props: any,
784
+ ): Promise<SsrMetadata | undefined> {
785
+ let merged: SsrMetadata | undefined;
786
+ for (const layoutPath of layouts ?? []) {
787
+ let lMod: any;
788
+ try {
789
+ lMod = await importModule(cwd, layoutPath);
790
+ } catch {
791
+ continue;
792
+ }
793
+ const own: SsrMetadata | undefined =
794
+ typeof lMod.generateMetadata === "function"
795
+ ? await lMod.generateMetadata(props)
796
+ : lMod.metadata;
797
+ merged = mergeMetadata(merged, own);
798
+ }
799
+ return merged;
800
+ }
801
+
802
+ // One tag in a document head: a <script>/<style> block (matched so tags
803
+ // written inside it are skipped), a <title> element, or a <meta>/<link> tag.
804
+ // Quoted attribute values may hold `>`.
805
+ const HEAD_TAG_RE =
806
+ /<(script|style)\b[\s\S]*?<\/\1\s*>|<title\b[^>]*>[\s\S]*?<\/title\s*>|<(?:meta|link)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
807
+ const HEAD_ATTR_RE = /\s([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
808
+
809
+ /** What a head tag sets: `title`, `name:<name>`, `property:<property>` or
810
+ * `canonical`. Null for a tag that page metadata never replaces. */
811
+ function headTagKey(tag: string): string | null {
812
+ const start = tag.slice(0, 6).toLowerCase();
813
+ if (start.startsWith("<title")) return "title";
814
+ const isMeta = start.startsWith("<meta");
815
+ if (!isMeta && !start.startsWith("<link")) return null;
816
+ const attrs: Record<string, string> = {};
817
+ for (const m of tag.matchAll(HEAD_ATTR_RE)) {
818
+ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
819
+ }
820
+ if (!isMeta) return attrs.rel?.toLowerCase() === "canonical" ? "canonical" : null;
821
+ if (attrs.property) return `property:${attrs.property.toLowerCase()}`;
822
+ if (attrs.name) return `name:${attrs.name.toLowerCase()}`;
823
+ return null;
824
+ }
825
+
826
+ /** The keys (see `headTagKey`) that the tags in `html` set. */
827
+ export function headTagKeys(html: string): Set<string> {
828
+ const keys = new Set<string>();
829
+ for (const m of html.matchAll(HEAD_TAG_RE)) {
830
+ const key = headTagKey(m[0]);
831
+ if (key) keys.add(key);
832
+ }
833
+ return keys;
834
+ }
835
+
836
+ /**
837
+ * Remove from `head` (the document up to `</head>`) the tags that the page
838
+ * metadata replaces: a <title>, a <meta> with the same `name` or `property`,
839
+ * and a canonical link. A new `og:image` or `twitter:image` also removes the
840
+ * old image's `og:image:*` or `twitter:image:*` tags. So a layout can write
841
+ * default tags in its own <head>, and a page's metadata replaces them.
842
+ */
843
+ export function dropReplacedHeadTags(head: string, replacing: Set<string>): string {
844
+ if (replacing.size === 0) return head;
845
+ const replaced = (key: string): boolean => {
846
+ if (replacing.has(key)) return true;
847
+ for (const image of ["property:og:image", "name:twitter:image"]) {
848
+ if (key.startsWith(`${image}:`) && replacing.has(image)) return true;
849
+ }
850
+ return false;
851
+ };
852
+ return head.replace(HEAD_TAG_RE, (tag) => {
853
+ const key = headTagKey(tag);
854
+ return key && replaced(key) ? "" : tag;
855
+ });
856
+ }
857
+
858
+ /** The first <title> element in `html`, or "". */
859
+ export function firstTitleTag(html: string): string {
860
+ for (const m of html.matchAll(HEAD_TAG_RE)) {
861
+ if (headTagKey(m[0]) === "title") return m[0];
862
+ }
863
+ return "";
864
+ }
865
+
735
866
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
736
867
 
737
868
  /**
@@ -1508,12 +1639,16 @@ export async function streamWithHeadInjection(
1508
1639
  headBlob: string,
1509
1640
  sendChunk: (text: string) => void,
1510
1641
  bodyBlob = "",
1642
+ rewriteHead?: (head: string) => string,
1511
1643
  ): Promise<void> {
1512
1644
  // Injections in document order, each consumed the first time its marker is
1513
1645
  // seen. Once the list empties the reader is a straight pass-through, which
1514
- // is every chunk after the <head> on a normal page.
1515
- const pending: Array<{ marker: string; blob: string }> = [];
1516
- if (headBlob.length > 0) pending.push({ marker: "</head>", blob: headBlob });
1646
+ // is every chunk after the <head> on a normal page. With `rewriteHead`, the
1647
+ // document up to `</head>` is held and passed through it before it is sent.
1648
+ const pending: Array<{ marker: string; blob: string; rewrite?: (text: string) => string }> = [];
1649
+ if (headBlob.length > 0 || rewriteHead) {
1650
+ pending.push({ marker: "</head>", blob: headBlob, rewrite: rewriteHead });
1651
+ }
1517
1652
  if (bodyBlob.length > 0) pending.push({ marker: "</body>", blob: bodyBlob });
1518
1653
  let carry = "";
1519
1654
  const decoder = new TextDecoder("utf-8");
@@ -1524,7 +1659,8 @@ export async function streamWithHeadInjection(
1524
1659
  const next = pending[0];
1525
1660
  const idx = buf.indexOf(next.marker);
1526
1661
  if (idx < 0) break;
1527
- sendChunk(buf.slice(0, idx));
1662
+ const before = buf.slice(0, idx);
1663
+ sendChunk(next.rewrite ? next.rewrite(before) : before);
1528
1664
  sendChunk(next.blob);
1529
1665
  sendChunk(next.marker);
1530
1666
  buf = buf.slice(idx + next.marker.length);
@@ -1534,6 +1670,10 @@ export async function streamWithHeadInjection(
1534
1670
  if (buf) sendChunk(buf);
1535
1671
  return;
1536
1672
  }
1673
+ if (pending[0].rewrite) {
1674
+ carry = buf;
1675
+ return;
1676
+ }
1537
1677
  // Withhold len(marker) − 1 so a marker straddling a chunk boundary is
1538
1678
  // still matched once the next read arrives.
1539
1679
  const keep = pending[0].marker.length - 1;
@@ -2435,7 +2575,8 @@ async function renderBoundaryToClient(
2435
2575
  },
2436
2576
  // Design render: no hydration tail, inlined CSS, `<base href>`.
2437
2577
  design?: { headers: Record<string, string | undefined> },
2438
- // Page-metadata head HTML that is not React-rendered (JSON-LD).
2578
+ // The boundary's metadata as head HTML (see `renderMetadata`). It
2579
+ // replaces the layout's own tags for the same keys.
2439
2580
  metaHeadHtml = "",
2440
2581
  ): Promise<void> {
2441
2582
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
@@ -2471,7 +2612,7 @@ async function renderBoundaryToClient(
2471
2612
  for (const css of manifestRoute.css) {
2472
2613
  headBlob += await cssHeadTag(css, publicPrefix);
2473
2614
  }
2474
- for (const chunk of manifestRoute.imports) {
2615
+ for (const chunk of modulepreloadFiles(manifestRoute)) {
2475
2616
  headBlob += `<link rel="modulepreload"${co} href="${publicPrefix}${chunk}">`;
2476
2617
  }
2477
2618
  } else {
@@ -2491,11 +2632,15 @@ async function renderBoundaryToClient(
2491
2632
  data: Buffer.from(text, "utf8").toString("base64"),
2492
2633
  });
2493
2634
  };
2635
+ const replacedHeadTags = headTagKeys(metaHeadHtml);
2494
2636
  await streamWithHeadInjection(
2495
2637
  stream.getReader(),
2496
2638
  headBlob,
2497
2639
  sendChunk,
2498
2640
  design ? "" : buildCloudBadgeChunk(),
2641
+ replacedHeadTags.size > 0
2642
+ ? (head) => dropReplacedHeadTags(head, replacedHeadTags)
2643
+ : undefined,
2499
2644
  );
2500
2645
  if (tail && manifestRoute && !design) {
2501
2646
  const tailHtml = buildHydrationTail({
@@ -2562,21 +2707,24 @@ async function tryRenderBoundary(
2562
2707
  };
2563
2708
  compProps = { ...props, error: errorForClient, reset: () => {} };
2564
2709
  }
2565
- // A boundary's own `metadata` / `generateMetadata`, same as a page's.
2566
- // Without this a not-found.tsx rendered no <title> at all — and because
2567
- // the framework's built-in 404 body supplies one, taking over the boundary
2568
- // silently LOST the title rather than replacing it, leaving the tab
2569
- // showing the raw URL. The fragment goes first so React hoists its
2570
- // <title>/<meta> into the <head> a layout renders.
2710
+ // A boundary's own `metadata` / `generateMetadata`, same as a page's,
2711
+ // over its layouts' metadata. Without this a not-found.tsx rendered no
2712
+ // <title> at all — and because the framework's built-in 404 body
2713
+ // supplies one, taking over the boundary silently LOST the title rather
2714
+ // than replacing it, leaving the tab showing the raw URL.
2571
2715
  let boundaryMeta: SsrMetadata | undefined = mod.metadata;
2572
- if (typeof mod.generateMetadata === "function") {
2573
- try {
2716
+ try {
2717
+ if (typeof mod.generateMetadata === "function") {
2574
2718
  boundaryMeta = await mod.generateMetadata(compProps);
2575
- } catch {
2576
- // A boundary is already the failure path; don't fail it again over
2577
- // its title.
2578
- boundaryMeta = mod.metadata;
2579
2719
  }
2720
+ boundaryMeta = mergeMetadata(
2721
+ await resolveLayoutMetadata(cwd, boundaryLayouts, compProps),
2722
+ boundaryMeta,
2723
+ );
2724
+ } catch {
2725
+ // A boundary is already the failure path; don't fail it again over
2726
+ // its title.
2727
+ boundaryMeta = mod.metadata;
2580
2728
  }
2581
2729
  // The same file conventions a page gets, resolved against the BOUNDARY's
2582
2730
  // own directory: a colocated opengraph-image / icon next to not-found.tsx
@@ -2592,13 +2740,16 @@ async function tryRenderBoundary(
2592
2740
  true,
2593
2741
  );
2594
2742
  boundaryMeta = applyAutoIcons(rel, boundaryMeta);
2595
- const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
2743
+ // In <head>, outside the tree, as for a page (see handleRenderRoute).
2744
+ const boundaryMetaHtml =
2745
+ (await renderElementToString(renderToReadableStream, renderMetadata(React, boundaryMeta))) +
2746
+ renderJsonLdHead(boundaryMeta);
2596
2747
  // The same metadata slot a page gets: the browser hydrates a boundary
2597
2748
  // with the page's tree shape (ssr-page-tree.ts).
2598
2749
  let tree = withMetaSlot(
2599
2750
  React.createElement,
2600
2751
  React.Fragment,
2601
- boundaryMetaFragment,
2752
+ null,
2602
2753
  React.createElement(Comp, compProps),
2603
2754
  );
2604
2755
  tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
@@ -2621,7 +2772,7 @@ async function tryRenderBoundary(
2621
2772
  errorForClient,
2622
2773
  },
2623
2774
  opts.design,
2624
- renderJsonLdHead(boundaryMeta),
2775
+ boundaryMetaHtml,
2625
2776
  );
2626
2777
  return true;
2627
2778
  } catch (e) {
@@ -3573,13 +3724,15 @@ export async function handleRenderRoute(
3573
3724
  : null;
3574
3725
 
3575
3726
  // SEO metadata: static `export const metadata` or dynamic
3576
- // `export async function generateMetadata(props)`. Awaited before the
3577
- // first byte, so keep it to cheap derivations (params → title); heavy
3578
- // data belongs in the page body behind <Suspense>.
3727
+ // `export async function generateMetadata(props)`, from each layout and
3728
+ // then the page (page values win). Awaited before the first byte, so keep
3729
+ // it to cheap derivations (params → title); heavy data belongs in the
3730
+ // page body behind <Suspense>.
3579
3731
  let metadata: SsrMetadata | undefined = mod.metadata;
3580
3732
  if (typeof mod.generateMetadata === "function") {
3581
3733
  metadata = await mod.generateMetadata(props);
3582
3734
  }
3735
+ metadata = mergeMetadata(await resolveLayoutMetadata(cwd, msg.layouts, props), metadata);
3583
3736
  // File conventions: auto-wire <meta og:image>/<twitter:image> from a
3584
3737
  // colocated opengraph-image.* / twitter-image.*, and <link rel="icon">
3585
3738
  // from icon.* / apple-icon.* / favicon.ico — unless the page set them.
@@ -3594,7 +3747,14 @@ export async function handleRenderRoute(
3594
3747
  componentIsBoundary,
3595
3748
  );
3596
3749
  metadata = applyAutoIcons(msg.component, metadata);
3597
- const metaFragment = renderMetadata(React, metadata);
3750
+ // The metadata goes into <head> as HTML, outside the React tree. Inside
3751
+ // the tree it rendered only when every layout rendered its children: a
3752
+ // layout that shows a sign-in or loading screen on the server (an auth
3753
+ // gate decided in the browser) dropped the page's tags.
3754
+ const metaHeadHtml =
3755
+ (await renderElementToString(renderToReadableStream, renderMetadata(React, metadata))) +
3756
+ renderJsonLdHead(metadata);
3757
+ const replacedHeadTags = headTagKeys(metaHeadHtml);
3598
3758
 
3599
3759
  // Client-side navigation: the browser already has the runtime, the layouts
3600
3760
  // and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
@@ -3660,10 +3820,9 @@ export async function handleRenderRoute(
3660
3820
  // root: start with the page component as `tree`, then for each
3661
3821
  // layout (innermost first) wrap it as the new tree. Result is
3662
3822
  // the outermost layout containing all nested layouts down to
3663
- // the page. The metadata is the FIRST child so React hoists its
3664
- // <title>/<meta> into the <head> a layout renders; the browser renders
3665
- // the same slot empty (ssr-page-tree.ts), so useId values match.
3666
- let tree: any = withMetaSlot(React.createElement, React.Fragment, metaFragment, pageLeaf);
3823
+ // the page. The page sits in the same metadata slot as in the browser
3824
+ // (ssr-page-tree.ts), empty on both sides, so useId values match.
3825
+ let tree: any = withMetaSlot(React.createElement, React.Fragment, null, pageLeaf);
3667
3826
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
3668
3827
  const element = tree;
3669
3828
  // Tracks whether the HTTP head (response_start) has actually gone out. A
@@ -3984,7 +4143,7 @@ export async function handleRenderRoute(
3984
4143
  // 404 page is unstyled. Hydration stays disabled (handled below).
3985
4144
  headBlob += await collectBoundaryHeadBlob();
3986
4145
  }
3987
- headBlob += renderJsonLdHead(metadata);
4146
+ headBlob += metaHeadHtml;
3988
4147
 
3989
4148
  // The host can dispatch a boundary module (`app/not-found` / `app/error`)
3990
4149
  // by name for an unmatched-URL 404. Boundaries render server-only — no
@@ -4008,18 +4167,31 @@ export async function handleRenderRoute(
4008
4167
  // Drain React's output and drop it. The render is what resolves the
4009
4168
  // serverData reads that fill `ssrData`; the markup it produces is the
4010
4169
  // part the client rebuilds for itself.
4170
+ // The head is kept for the layout's own <title>.
4011
4171
  const reader = stream.getReader();
4172
+ const decoder = new TextDecoder("utf-8");
4173
+ let head = "";
4174
+ let headDone = false;
4012
4175
  for (;;) {
4013
- const { done } = await reader.read();
4176
+ const { done, value } = await reader.read();
4014
4177
  if (done) break;
4178
+ if (headDone || !value) continue;
4179
+ head += decoder.decode(value, { stream: true });
4180
+ const end = head.indexOf("</head>");
4181
+ if (end >= 0) {
4182
+ head = head.slice(0, end);
4183
+ headDone = true;
4184
+ }
4015
4185
  }
4016
4186
  // A minimal document, so the client's existing parse path works
4017
4187
  // unchanged: it reads document.title, copies [data-pylon-meta], and
4018
- // pulls __PYLON_DATA__ — all of which this carries.
4188
+ // pulls __PYLON_DATA__ — all of which this carries. A page with no
4189
+ // title of its own gets the layout's, as on a full page load.
4190
+ const layoutTitle = replacedHeadTags.has("title") || !headDone ? "" : firstTitleTag(head);
4019
4191
  sendChunk(
4020
4192
  '<!DOCTYPE html><html><head><meta charset="utf-8">' +
4021
- (await renderElementToString(renderToReadableStream, metaFragment)) +
4022
- renderJsonLdHead(metadata) +
4193
+ layoutTitle +
4194
+ metaHeadHtml +
4023
4195
  "</head><body>",
4024
4196
  );
4025
4197
  } else {
@@ -4031,6 +4203,9 @@ export async function handleRenderRoute(
4031
4203
  headBlob,
4032
4204
  sendChunk,
4033
4205
  designRender ? "" : buildCloudBadgeChunk(),
4206
+ replacedHeadTags.size > 0
4207
+ ? (head) => dropReplacedHeadTags(head, replacedHeadTags)
4208
+ : undefined,
4034
4209
  );
4035
4210
  }
4036
4211
 
@@ -0,0 +1,35 @@
1
+ // The route's modulepreload set is its whole static import graph, including
2
+ // chunk-to-chunk imports (a chunk imports its siblings as "./x.js").
3
+
4
+ import { afterEach, expect, test } from "bun:test";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { staticImportGraph } from "./ssr-client-bundler";
9
+
10
+ let dir = "";
11
+ afterEach(() => {
12
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
13
+ });
14
+
15
+ test("follows chunk-to-chunk imports, not dynamic ones", () => {
16
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-graph-"));
17
+ fs.mkdirSync(path.join(dir, "chunks"));
18
+ const write = (f: string, body: string) => fs.writeFileSync(path.join(dir, f), body);
19
+ write("client-entry-home-aaaa1111.js", `import{a}from"./chunks/a-11111111.js";import"./chunks/b-22222222.js";a();`);
20
+ write("chunks/a-11111111.js", `import{c as d}from"./c-33333333.js";export const a=()=>d();`);
21
+ write("chunks/b-22222222.js", `import"./c-33333333.js";`);
22
+ write("chunks/c-33333333.js", `import"./d-44444444.js";export const c=1;const later=()=>import("./lazy-55555555.js");`);
23
+ write("chunks/d-44444444.js", `export const d=1;`);
24
+ write("chunks/lazy-55555555.js", `export const lazy=1;`);
25
+ const chunks = new Set(
26
+ ["a-11111111", "b-22222222", "c-33333333", "d-44444444", "lazy-55555555"].map((c) => `chunks/${c}.js`),
27
+ );
28
+ const graph = staticImportGraph(fs, path, dir, path.join(dir, "client-entry-home-aaaa1111.js"), chunks);
29
+ expect([...graph]).toEqual([
30
+ "chunks/a-11111111.js",
31
+ "chunks/b-22222222.js",
32
+ "chunks/c-33333333.js",
33
+ "chunks/d-44444444.js",
34
+ ]);
35
+ });
package/src/types.ts CHANGED
@@ -284,6 +284,26 @@ export interface DbWriter extends DbReader {
284
284
  data: Record<string, unknown>
285
285
  ): Promise<boolean>;
286
286
 
287
+ /**
288
+ * Insert many rows of one entity. Returns their IDs, in order. Same
289
+ * result as calling `insert` per row, with fewer database round trips:
290
+ * use it for imports and other loops that write many rows. When a row
291
+ * fails, the call throws; let the error end the mutation so it rolls
292
+ * back.
293
+ */
294
+ insertMany(entity: string, rows: Record<string, unknown>[]): Promise<string[]>;
295
+
296
+ /**
297
+ * Update many rows of one entity. Returns, per row and in order, true if
298
+ * the row existed. Same result as calling `update` per row, with fewer
299
+ * database round trips. When a row fails, the call throws; let the error
300
+ * end the mutation so it rolls back.
301
+ */
302
+ updateMany(
303
+ entity: string,
304
+ rows: { id: string; data: Record<string, unknown> }[]
305
+ ): Promise<boolean[]>;
306
+
287
307
  /** Delete a row by ID. Returns true if the row existed. */
288
308
  delete(entity: string, id: string): Promise<boolean>;
289
309