@tristan2828/ui-foundation 4.8.0 → 4.9.1

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.
@@ -178,7 +178,12 @@ is a filled-in example while the Widgets demo is still here).
178
178
  a toast naming the row and the server's reason; the lists refetch
179
179
  after). Its specs cover a flip that saves, the keyboard, and a failed
180
180
  save putting the switch back. Copy `useSaveWidgetField` whenever the
181
- plan has a `toggle` or any quick action, and only then.
181
+ plan has a `toggle` or any quick action, and only then. It finds the
182
+ record under the entity's own detail and list keys: on a screen that
183
+ holds it in a query of its own, a plain change (`{ field: value }`) is
184
+ still sent but only shows once that query refetches, and a change
185
+ built from the record (`(record) => …`) is refused, with a console
186
+ warning naming the keys searched.
182
187
  - `src/routes/widgets/use-widgets.ts`, `use-widget-categories.ts` →
183
188
  `src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
184
189
  new gateway module)
@@ -21,6 +21,10 @@
21
21
  // or the AppError's).
22
22
  // - **The lists refetch once the last save settles**, so a filter on the
23
23
  // field drops the row once the server agrees.
24
+ // - **A record in neither cache** (the screen holds it in a query of its
25
+ // own) still saves an object change, shown once that query refetches; a
26
+ // function change has nothing to build on and is refused like any other
27
+ // refusal. Both warn in the console, naming the keys searched.
24
28
  //
25
29
  // Editing in place uses the same save with `optimistic: false` and
26
30
  // `toastOnError: false`: nothing changes on screen until the server
@@ -35,6 +39,11 @@ function recordStates(queryClient) {
35
39
  states.set(queryClient, (map = new Map()));
36
40
  return map;
37
41
  }
42
+ // A function change with no record in either cache to build it from.
43
+ const notLoaded = {
44
+ kind: 'server',
45
+ message: "It isn't loaded on this page. Reload and try again",
46
+ };
38
47
  function resolve(change, record) {
39
48
  return typeof change === 'function' ? change(record) : change;
40
49
  }
@@ -94,9 +103,19 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
94
103
  scope: { id: `record-update:${key}` },
95
104
  mutationFn: (change) => {
96
105
  const confirmed = map.get(key)?.confirmed ?? cached();
97
- if (!confirmed)
98
- return Promise.reject(new Error('The record to update is not loaded'));
99
- return update(id, resolve(change, confirmed));
106
+ if (confirmed)
107
+ return update(id, resolve(change, confirmed));
108
+ // The screen holds the record somewhere these keys don't reach (a
109
+ // query gathering a related list's records, say). An object change
110
+ // needs no current record, so it's still sent; a function change
111
+ // can't be built, and says so rather than doing nothing. Warned in
112
+ // every build: the package has no dev flag every app's bundler sets.
113
+ console.warn(`useRecordUpdate: record ${String(id)} isn't cached under ${JSON.stringify(detailKey)} ` +
114
+ `or any list under ${JSON.stringify(listsKey)}, so the change isn't shown until the server answers` +
115
+ (typeof change === 'function' ? ', and a change built from the record can\'t be sent' : ''));
116
+ if (typeof change === 'function')
117
+ return Promise.reject(notLoaded);
118
+ return update(id, change);
100
119
  },
101
120
  onMutate: async (change) => {
102
121
  let state = map.get(key);
@@ -120,7 +139,8 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
120
139
  return;
121
140
  // What was sent: the change built on the record it was sent against.
122
141
  const record = state.confirmed;
123
- const sent = record && resolve(context.entry.change, record);
142
+ const { change } = context.entry;
143
+ const sent = record ? resolve(change, record) : typeof change === 'function' ? undefined : change;
124
144
  settle(state, context.entry);
125
145
  if (toastOnError)
126
146
  toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.8.0",
3
+ "version": "4.9.1",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/vite/index.d.ts CHANGED
@@ -3,12 +3,24 @@ import type { Plugin } from 'vite'
3
3
  export type BuildInfoOptions = {
4
4
  /** The app's repository, e.g. `https://github.com/me/app`. `BUILD_REPO_URL` wins over it. */
5
5
  repoUrl?: string
6
+ /**
7
+ * The Alembic versions folder, from the Vite root (`backend/migrations/versions`).
8
+ * Adds `schema`, the head revision, to `build-info.json`; no head or
9
+ * several fails the build.
10
+ */
11
+ alembic?: string
12
+ /**
13
+ * More fields for `build-info.json`, merged in. `api`, `commit`, `pr`,
14
+ * `builtAt` and `schema` are the plugin's own: returning one fails the build.
15
+ */
16
+ extra?: () => Record<string, unknown>
6
17
  }
7
18
 
8
19
  /**
9
20
  * On `vite build`, writes `dist/build-info.json` (`api`: "real" or "mock",
10
- * `commit`, `pr`, `builtAt`) and sets `import.meta.env.VITE_BUILD_COMMIT`,
11
- * `VITE_BUILD_PR` and `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`,
12
- * `BUILD_PR` and `BUILD_REPO_URL`.
21
+ * `commit`, `pr`, `builtAt`, and `schema` with `alembic`) and sets
22
+ * `import.meta.env.VITE_BUILD_COMMIT`, `VITE_BUILD_PR` and
23
+ * `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`, `BUILD_PR` and
24
+ * `BUILD_REPO_URL`.
13
25
  */
14
26
  export default function buildInfo(options?: BuildInfoOptions): Plugin
package/vite/index.js CHANGED
@@ -13,21 +13,72 @@
13
13
  // BUILD_REPO_URL (the repoUrl option when that's unset), for AppShell's
14
14
  // `version`. Unset, they're empty strings and nothing is shown.
15
15
  //
16
+ // - with `alembic` (the migrations' versions folder, from the Vite root),
17
+ // adds `schema`: the head revision the backend migrates to. Read from the
18
+ // files, so the build needs no Python. No head, or several, fails the
19
+ // build, as `alembic upgrade head` would refuse at start.
20
+ // - with `extra`, merges what it returns into the file. It can't replace the
21
+ // fields above: the backend's production check reads `api`.
22
+ //
16
23
  // Build only: in development the three are undefined. Plain JavaScript, like
17
24
  // the eslint entry: Vite loads the config before any TypeScript tooling runs.
18
25
 
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+
29
+ const OWN_FIELDS = ['api', 'commit', 'pr', 'builtAt', 'schema']
30
+
19
31
  /** @param {string | undefined} value */
20
32
  function clean(value) {
21
33
  const trimmed = value?.trim()
22
34
  return trimmed ? trimmed : null
23
35
  }
24
36
 
25
- /** @param {{ repoUrl?: string }} [options] */
37
+ // `revision = "abc"` or `revision: str = "abc"`, as Alembic writes them.
38
+ const REVISION = /^revision(?:\s*:[^=\n]+)?\s*=\s*(['"])([^'"]+)\1/m
39
+ // None, one revision, or a tuple of them (a merge), possibly over lines.
40
+ const DOWN_REVISION = /^down_revision(?:\s*:[^=\n]+)?\s*=\s*(\([^)]*\)|[^\n]*)/m
41
+
42
+ /**
43
+ * The one revision no other migration names as its down_revision.
44
+ * @param {string} dir
45
+ */
46
+ function alembicHead(dir) {
47
+ if (!fs.existsSync(dir)) throw new Error(`buildInfo: no Alembic versions folder at ${dir}.`)
48
+ const revisions = new Set()
49
+ const downs = new Set()
50
+ for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.py'))) {
51
+ const source = fs.readFileSync(path.join(dir, file), 'utf8')
52
+ const revision = REVISION.exec(source)
53
+ if (!revision) continue
54
+ revisions.add(revision[2])
55
+ const down = DOWN_REVISION.exec(source)?.[1] ?? ''
56
+ for (const [, , name] of down.matchAll(/(['"])([^'"]+)\1/g)) downs.add(name)
57
+ }
58
+ const heads = [...revisions].filter((revision) => !downs.has(revision)).sort()
59
+ if (heads.length === 1) return heads[0]
60
+ throw new Error(
61
+ heads.length === 0
62
+ ? `buildInfo: no Alembic head in ${dir} (no migrations, or they form a loop).`
63
+ : `buildInfo: several Alembic heads in ${dir} (${heads.join(', ')}); alembic upgrade head would refuse. Merge them first.`,
64
+ )
65
+ }
66
+
67
+ /**
68
+ * @param {{
69
+ * repoUrl?: string,
70
+ * alembic?: string,
71
+ * extra?: () => Record<string, unknown>,
72
+ * }} [options]
73
+ */
26
74
  export default function buildInfo(options = {}) {
27
75
  const commit = clean(process.env.BUILD_COMMIT)
28
76
  const pr = clean(process.env.BUILD_PR)
29
77
  const repoUrl = clean(process.env.BUILD_REPO_URL) ?? clean(options.repoUrl)
30
78
  let api = 'mock'
79
+ let root = process.cwd()
80
+ /** @type {Record<string, unknown>} */
81
+ let fields = {}
31
82
 
32
83
  return {
33
84
  name: 'ui-foundation:build-info',
@@ -44,12 +95,26 @@ export default function buildInfo(options = {}) {
44
95
  configResolved(config) {
45
96
  // The same test as the app's main.tsx: anything but "real" ships MSW.
46
97
  api = config.env.VITE_API === 'real' ? 'real' : 'mock'
98
+ root = config.root
99
+ },
100
+ // Read before bundling, so a bad migration chain fails the build early.
101
+ buildStart() {
102
+ fields = {}
103
+ if (options.alembic) fields.schema = alembicHead(path.resolve(root, options.alembic))
104
+ if (options.extra) {
105
+ const extra = options.extra()
106
+ const taken = Object.keys(extra).filter((key) => OWN_FIELDS.includes(key))
107
+ if (taken.length > 0) {
108
+ throw new Error(`buildInfo: extra can't set ${taken.join(', ')}; build-info.json's own fields.`)
109
+ }
110
+ Object.assign(fields, extra)
111
+ }
47
112
  },
48
113
  generateBundle() {
49
114
  this.emitFile({
50
115
  type: 'asset',
51
116
  fileName: 'build-info.json',
52
- source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString() }, null, 2)}\n`,
117
+ source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString(), ...fields }, null, 2)}\n`,
53
118
  })
54
119
  },
55
120
  }