@pattern-stack/codegen 0.27.2 → 0.27.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.27.3] — 2026-06-13
6
+
7
+ ### Fixed
8
+
9
+ - **Frontend lookups/resolver hydration no longer full-fetches every entity —
10
+ the LANDMINE-1 OOM guard.** `buildLookupsAsync` (store/lookups.ts) and
11
+ `hydrateResolverCache` (store/resolvers.ts) paged the COMPLETE table of *every*
12
+ entity via `api.listAll()` on the first `useData()` mount of *any* route, to
13
+ populate cross-entity FK-resolution maps. On a large content table this is
14
+ catastrophic: a swe-brain mailbox of 21,648 emails × ~33 KB `bodyHtml` pulled
15
+ ~700 MB into the renderer and the browser silently OOM-killed the tab — on
16
+ every route, including ones that never read email. Dogfood discovery from
17
+ swe-brain. Fix: full-fetch an entity only if it is a **lookup target** (some
18
+ `belongs_to` resolves it by id → it backs a FieldMeta `reference`) **or** it
19
+ has no unbounded text column; heavy, non-target entities (a `text` field, or a
20
+ `string` with no `max_length`) are seeded from current collection state
21
+ instead — nothing resolves them by id, so the current page is all any consumer
22
+ can use. `api` imports for skipped entities are dropped so none dangle. See
23
+ `lookupFullFetchNames` in `emit-store.ts`. Known edge: an entity consumed as a
24
+ lookup ONLY via hand-authored `lookups.<plural>` access (not a FieldMeta
25
+ reference) that *also* has an unbounded string column would be skipped — give
26
+ it a bounded `max_length` or declare the FK so it registers as a target.
27
+
5
28
  ## [0.27.2] — 2026-06-08
6
29
 
7
30
  ### Fixed
@@ -5729,6 +5729,21 @@ function resolvableRels(entity, ctx) {
5729
5729
  function fkField(rel2) {
5730
5730
  return rel2.foreignKey && rel2.foreignKey.length > 0 ? rel2.foreignKey : `${rel2.target}_id`;
5731
5731
  }
5732
+ function lookupFullFetchNames(ctx) {
5733
+ const referenceTargets = /* @__PURE__ */ new Set();
5734
+ for (const e of ctx.entities) {
5735
+ for (const r of resolvableRels(e, ctx)) referenceTargets.add(r.target.name);
5736
+ }
5737
+ const out = /* @__PURE__ */ new Set();
5738
+ for (const e of ctx.entities) {
5739
+ const parsed = ctx.parsed.get(e.name);
5740
+ const hasUnboundedText = parsed ? Array.from(parsed.fields.values()).some(
5741
+ (f) => f.type === "text" || f.type === "string" && f.constraints.maxLength === void 0
5742
+ ) : false;
5743
+ if (referenceTargets.has(e.name) || !hasUnboundedText) out.add(e.name);
5744
+ }
5745
+ return out;
5746
+ }
5732
5747
  function buildStoreIndexFile(ctx) {
5733
5748
  const entities = sortEntities(ctx.entities);
5734
5749
  const hookImports = entities.map((e) => `import { ${e.camelName}Hooks } from '../entities/${e.name}';`).join("\n");
@@ -5781,8 +5796,10 @@ export type AppStore = typeof store;
5781
5796
  }
5782
5797
  function buildResolversFile(ctx) {
5783
5798
  const entities = sortEntities(ctx.entities);
5799
+ const fullFetch = lookupFullFetchNames(ctx);
5800
+ const fetched = entities.filter((e) => fullFetch.has(e.name));
5784
5801
  const collectionImports = entities.map((e) => `import { ${e.camelName}Collection } from '../collections/${e.name}';`).join("\n");
5785
- const apiImports = entities.map((e) => `import { ${e.camelName}Api } from '../api/${e.name}';`).join("\n");
5802
+ const apiImports = fetched.map((e) => `import { ${e.camelName}Api } from '../api/${e.name}';`).join("\n");
5786
5803
  const typeImports = entities.map((e) => `import type { ${e.className} } from '${ctx.config.dbEntitiesImport}/${e.name}';`).join("\n");
5787
5804
  const resolverIface = entities.map(
5788
5805
  (e) => ` ${e.camelName}: (id: string | null | undefined) => ${e.className} | undefined;`
@@ -5795,7 +5812,7 @@ function buildResolversFile(ctx) {
5795
5812
  },`
5796
5813
  ).join("\n");
5797
5814
  const hydrationCacheFields = entities.map((e) => ` ${e.camelName}: new Map<string, ${e.className}>(),`).join("\n");
5798
- const hydrationCalls = entities.map(
5815
+ const hydrationCalls = fetched.map(
5799
5816
  (e) => ` ${e.camelName}Api.listAll().then((rows) => {
5800
5817
  hydrationCache.${e.camelName} = new Map(rows.map((r) => [r.id as string, r]));
5801
5818
  }),`
@@ -5884,22 +5901,34 @@ ${refsSection}`;
5884
5901
  }
5885
5902
  function buildLookupsFile(ctx) {
5886
5903
  const entities = sortEntities(ctx.entities);
5904
+ const fullFetch = lookupFullFetchNames(ctx);
5905
+ const fetched = entities.filter((e) => fullFetch.has(e.name));
5887
5906
  const collectionImports = entities.map((e) => `import { ${e.camelName}Collection } from '../collections/${e.name}';`).join("\n");
5888
- const apiImports = entities.map((e) => `import { ${e.camelName}Api } from '../api/${e.name}';`).join("\n");
5907
+ const apiImports = fetched.map((e) => `import { ${e.camelName}Api } from '../api/${e.name}';`).join("\n");
5889
5908
  const typeImports = entities.map((e) => `import type { ${e.className} } from '${ctx.config.dbEntitiesImport}/${e.name}';`).join("\n");
5890
5909
  const lookupIface = entities.map((e) => ` ${e.plural}: Map<string, ${e.className}>;`).join("\n");
5891
- const lookupBuild = entities.map(
5892
- (e) => ` ${e.plural}: new Map(
5910
+ const collectionStateMap = (e) => ` ${e.plural}: new Map(
5893
5911
  Array.from(${e.camelName}Collection.state.values()).map((item) => [
5894
5912
  (item as ${e.className}).id as string,
5895
5913
  item as ${e.className},
5896
5914
  ]),
5897
- ),`
5898
- ).join("\n");
5899
- const lookupBuildAsyncDecls = entities.map((e) => ` ${e.camelName}Api.listAll(),`).join("\n");
5900
- const lookupBuildAsyncFields = entities.map(
5901
- (e, i) => ` ${e.plural}: new Map(rows[${i}].map((r) => [r.id as string, r as ${e.className}])),`
5902
- ).join("\n");
5915
+ ),`;
5916
+ const lookupBuild = entities.map(collectionStateMap).join("\n");
5917
+ const lookupBuildAsyncDecls = fetched.map((e) => ` ${e.camelName}Api.listAll(),`).join("\n");
5918
+ const fetchedIndex = new Map(fetched.map((e, i) => [e.name, i]));
5919
+ const lookupBuildAsyncFields = entities.map((e) => {
5920
+ const i = fetchedIndex.get(e.name);
5921
+ return i === void 0 ? collectionStateMap(e) : ` ${e.plural}: new Map(rows[${i}].map((r) => [r.id as string, r as ${e.className}])),`;
5922
+ }).join("\n");
5923
+ const lookupBuildAsyncBody = fetched.length > 0 ? ` const rows = await Promise.all([
5924
+ ${lookupBuildAsyncDecls}
5925
+ ]);
5926
+ return {
5927
+ ${lookupBuildAsyncFields}
5928
+ };` : ` await Promise.resolve();
5929
+ return {
5930
+ ${lookupBuildAsyncFields}
5931
+ };`;
5903
5932
  const body = `${collectionImports}
5904
5933
  ${apiImports}
5905
5934
  ${typeImports}
@@ -5923,12 +5952,7 @@ ${lookupBuild}
5923
5952
  * may not be on the current page.
5924
5953
  */
5925
5954
  export async function buildLookupsAsync(): Promise<EntityLookups> {
5926
- const rows = await Promise.all([
5927
- ${lookupBuildAsyncDecls}
5928
- ]);
5929
- return {
5930
- ${lookupBuildAsyncFields}
5931
- };
5955
+ ${lookupBuildAsyncBody}
5932
5956
  }
5933
5957
 
5934
5958
  /**
@@ -5938,20 +5962,40 @@ ${lookupBuildAsyncFields}
5938
5962
  */
5939
5963
  export function createLookups() {
5940
5964
  let cache: EntityLookups | null = null;
5965
+ // Run-once guard for \`hydrate()\`. The store runtime calls hydrate from a
5966
+ // per-mount useEffect with NO dedup, so without this every EntityList/useData
5967
+ // mount (x2 under React StrictMode) kicks off another full buildLookupsAsync()
5968
+ // \u2014 a listAll() of every entity. On a large table (tens of thousands of rows)
5969
+ // that slurps the whole table N times concurrently and can OOM the renderer.
5970
+ // The guard collapses all callers onto a single hydration: cache the resolved
5971
+ // set, and dedupe concurrent callers onto one in-flight promise. \`clear()\`
5972
+ // forces a refresh. NOTE: this caps the damage but does not make full-fetching
5973
+ // a huge table cheap \u2014 lazy/on-demand FK resolution is the deeper fix.
5974
+ let inFlight: Promise<EntityLookups> | null = null;
5941
5975
  return {
5942
5976
  build: (): EntityLookups => {
5943
5977
  cache = buildLookups();
5944
5978
  return cache;
5945
5979
  },
5946
5980
  hydrate: async (): Promise<EntityLookups> => {
5947
- cache = await buildLookupsAsync();
5948
- return cache;
5981
+ if (cache) return cache;
5982
+ if (inFlight) return inFlight;
5983
+ inFlight = buildLookupsAsync()
5984
+ .then((result) => {
5985
+ cache = result;
5986
+ return result;
5987
+ })
5988
+ .finally(() => {
5989
+ inFlight = null;
5990
+ });
5991
+ return inFlight;
5949
5992
  },
5950
5993
  get current(): EntityLookups | null {
5951
5994
  return cache;
5952
5995
  },
5953
5996
  clear: (): void => {
5954
5997
  cache = null;
5998
+ inFlight = null;
5955
5999
  },
5956
6000
  };
5957
6001
  }