@voltro/i18n 0.11.1 → 0.11.2
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 +40 -0
- package/dist/index.d.ts +49 -8
- package/dist/index.js +11 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,44 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.11.2] — 2026-07-24
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **@voltro/i18n** — Two escapes for adopting typed messages (`createTypedMessages`, #16) app-wide (#19):
|
|
47
|
+
|
|
48
|
+
- **`t.dynamic(runtimeKey, values?)`** — a first-class escape for a genuinely runtime-computed key, on both `useT` and the `useTFn()` result. It takes a plain string with NO forced ICU args, so it doesn't fight the strict literal-key surface. Until now the natural escape — casting a computed key to the catalog key union — made things WORSE: that union spans placeholder-bearing keys, so the call then demanded a spurious 2nd ICU arg. `t.dynamic` is the documented, discoverable alternative. - **`LooseTFunction`** — the widened `(id: string, values?) => string` signature to type a `t` pass-through across a package boundary that can't import the app catalog, instead of falling back to `(...args: any[]) => string`. A strict `TypedTFunction` is deliberately NOT assignable to it (a narrowed key param can't satisfy a wider one — that would erase the checking); pass `t.dynamic` at the boundary, which IS a `LooseTFunction`.
|
|
49
|
+
|
|
50
|
+
Additive: `TypedTFunction<C>` gains a `.dynamic` member (the callable surface is unchanged, so `Parameters<TypedTFunction<C>>[0]` and existing typed call sites still resolve). `createTypedMessages` attaches `.dynamic` in place on the two translate functions — no new per-render closure, so a captured `t`'s identity stays stable.
|
|
51
|
+
- **@voltro/testing, @voltro/database** — `fixtureRow(table, overrides)` (`@voltro/testing`) completes a partial test row so it satisfies the 0.11.1 required-column insert validation — WITHOUT disabling the check. It fills every NOT-NULL, no-default, non-auto-stamped column the payload omits with a schema-typed placeholder (a `oneOf` column takes its first allowed value; a `unique` column gets a distinct value per call so two fixtures don't collide; `timestamp`/`date` get a fixed epoch), then merges your overrides on top (an explicit value always wins). It leaves out exactly what a caller may omit — nullable, defaulted, and framework auto-stamped columns (id / tenant / audit) — and refuses to guess a structured type (`json` / `bytes` / `vector` / `array` / `interval` / `raw`), throwing a message that names the column and says to pass it explicitly.
|
|
52
|
+
|
|
53
|
+
The motivating case: 0.11.1 made the in-memory/test store reject the same partial inserts real Postgres always would (correct — it surfaced a latent prod bug), which turned lean fixtures (`insert(users, { id })`, an omitted required FK) into `TableValidationFailed`. The wrong fix is a `validateInserts: false` knob — it re-hides that bug class, and a test store laxer than production is a fake testing itself. `fixtureRow` is the right one: it makes the fixture COMPLETE.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
await ctx.store.insert('journal_entries', fixtureRow(journalEntries, {
|
|
57
|
+
tenantId, amount: '100.00', // the columns THIS test cares about
|
|
58
|
+
})) // entryNumber, postedAt, … auto-filled + unique
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It is a runtime filler for the loose `store.insert(name, row)` path (what fixtures use). For COMPILE-time payload typing, use `insertRow` / `upsertRow` from `@voltro/database`. The auto-stamped column set it skips is now exported as `AUTO_FILLED_COLUMNS` from `@voltro/database` — the same list `InferInsertRow` derives its optional columns from, single-sourced so the two can't drift.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- **@voltro/cli** — `voltro build` now emits **directly-executable** boot bundles for BOTH app kinds: the web start bundle (`.framework/dist-web/startBundle/startEntry.js`) and the api serve bundle (`.framework/dist-api/serveBundle/serveEntry.js`) each carry a main-guard that boots the app when run as `node <entry>.js`, and stays inert when imported (the `voltro start` / `voltro serve` dev fast paths are unchanged). Production containers can now use `CMD ["node", "…/startEntry.js"]` (or `serveEntry.js`) instead of `pnpm voltro start` / `pnpm voltro serve` — no pnpm process, no `@voltro/cli` bin at runtime — which is what makes `voltro prune-runtime` safe to enable on both: with the self-contained bundle as the real entrypoint, the @vercel/nft trace roots there and legitimately drops `@voltro/cli` and the whole inlined framework tree (a static site's `node_modules` collapses to ~0; a memory api's 146 MB → 11 MB). `prune-runtime` now also roots the trace at the serve bundle. The serve entry chdir's to the app root BEFORE its app-module registry keys are computed from cwd, preserving relocation-safety. Existing `pnpm voltro start` / `pnpm voltro serve` entrypoints keep working. The standalone Dockerfiles gain a build-time boot smoke that fails the build unless the pruned tree reaches ready.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **@voltro/i18n** — `createTypedMessages` (#16) no longer extracts phantom required vars from a nested plural/select message (#19). For `'{count, plural, one {# day} other {# days total duration}}'`, the type-level `ICUVars` parse was reading a branch's TEXT (`"# days total duration"`) as a bogus required arg name, so `useT('key', { count })` failed to typecheck even though it renders perfectly — and a real var nested inside a branch was dropped. `ICUArgName` now resolves to `never` for any candidate that isn't a valid ICU identifier (`^[A-Za-z0-9_]+$`), so branch text — which contains spaces / `#` / `—` — is never mistaken for a var. Only the top-level arg (`count`) is required, matching what the message actually needs.
|
|
70
|
+
|
|
71
|
+
Scope note: a REAL var nested inside a plural branch (`other {# — {discipline}}`) is still not collected, so it reads as not-required rather than wrongly-required — the safe direction. Apps that pluralise in JS over simple `{count}` messages (the Voltro idiom) were already fully typed and are unaffected.
|
|
72
|
+
- **@voltro/database, @voltro/runtime** — `InferInsertRow` (and thus `insertRow` / `upsertRow`, #15) no longer requires a non-nullable DB-generated (`generatedAs`) column (#20). A stored/virtual generated column declared without `.nullable()` and without a default was typed **required**, but MariaDB/Postgres REJECT an explicit value for a generated column — so the type forced the caller to pass a value the database refuses at runtime. `.generatedAs()` now marks the column optional-for-insert exactly like a `.default()` column (the DB supplies it), so it may be omitted; the whole payload guard on every real column stays intact.
|
|
73
|
+
|
|
74
|
+
Two runtime halves complete it, so the loose `store.insert(name, row)` path agrees: the required-column validation (`missingRequiredColumns`) skips generated columns — omitting one is correct, never a missing-column error — and the store write path now STRIPS any value a caller supplied for a generated column before the INSERT reaches the dialect (tracked on the schema registry as `generatedColumns`), so a value from an untyped insert can't blow up on MariaDB. A generated column is never caller-supplied; the framework and the DB own it end to end.
|
|
75
|
+
|
|
76
|
+
The `.generatedAs()` return type narrows from `this` to `ColumnBuilder<…, true>` (the HasDefault flag) — a purely more-permissive refinement: it only makes the column omittable, so no existing code stops compiling.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
42
80
|
## [0.11.1] — 2026-07-23
|
|
43
81
|
|
|
44
82
|
### Added
|
|
@@ -71,6 +109,8 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
71
109
|
- **@voltro/database, @voltro/runtime** — `store.insert` / `upsert` / `insertIgnore` now raise a clear, typed `TableValidationFailed` naming the column when the payload omits one that is NOT NULL, has no default, and isn't auto-stamped — instead of a raw dialect `SqlError: Failed to execute statement` (`Field '…' doesn't have a default value`) surfaced only on the INSERT path (so it lay dormant until the first row with no existing cache entry). An upsert / insertIgnore whose payload is missing one of its own `conflictColumns` is likewise named at the call (an absent conflict key can't match its target). The check runs AFTER stamping, so auto-id / tenant / audit columns never trip it, and skips nullable, defaulted, and id (`idScheme`) columns — exactly the ones a caller may legitimately omit.
|
|
72
110
|
|
|
73
111
|
Two pure helpers back it — `missingRequiredColumns(table, row)` and `missingConflictColumns(conflictColumns, row)` (exported from `@voltro/database`). This is the runtime half of the "handler data silently disagrees with the schema" class; a compile-time payload type needs the column DSL to track `hasDefault` at the type level, which is a separate change.
|
|
112
|
+
|
|
113
|
+
**Migration impact — behaviour-breaking for lenient test fixtures.** The in-memory/test store now rejects the same partial inserts a real Postgres always would, so it stops being laxer than production — which is the point (it surfaced at least one latent prod bug where a NOT-NULL `text().unique()` column was written without a value). But a fixture that inserted a partial row (`{ id }` parents, an omitted required FK) and passed against the old lenient memory store now throws `TableValidationFailed`. There is no code-level codemod — the fix is fixture DATA: fill the required columns. Use the new `fixtureRow(table, overrides)` helper in `@voltro/testing`, which fills every NOT-NULL-no-default column with a schema-typed placeholder and merges your overrides on top, so a fixture complies without disabling the check. There is deliberately no opt-out to turn the validation off: a test store that accepts rows production rejects is a fake testing itself.
|
|
74
114
|
- **@voltro/database, @voltro/plugin-ai-flows, @voltro/plugin-audit, @voltro/plugin-deactivation, @voltro/plugin-soft-delete** — Compile-time payload typing for writes (#15) — the type-level half that the runtime `TableValidationFailed` guard flagged as a separate change. `insertRow` / `upsertRow` take the TABLE OBJECT (not a string name), so the payload is checked against `InferInsertRow<T>`: every column is required EXCEPT nullable ones, columns with a default, and the framework-filled id/tenant/audit columns. A missing NOT-NULL-no-default column — the exact `lastRefreshedAt` / `teamId` omission from the report — is now a COMPILE error at the call, not a runtime SqlError only on the INSERT path; `upsertRow`'s `conflictColumns` are constrained to the table's own columns too.
|
|
75
115
|
|
|
76
116
|
import { insertRow } from '@voltro/database' await insertRow(ctx.store, roadmapEpicStats, { teamId, lastRefreshedAt: new Date() })
|
package/dist/index.d.ts
CHANGED
|
@@ -33,10 +33,11 @@ export declare type CatalogModule = MessageCatalog | {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Bind the typed message API to a catalog's literal types.
|
|
37
|
-
* refinement: the runtime is
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Bind the typed message API to a catalog's literal types. Almost pure type
|
|
37
|
+
* refinement: the runtime is `useT` / `useTFn` / `<T>` with a `.dynamic` escape
|
|
38
|
+
* attached to the two translate functions (a runtime-computed-key sink) — no
|
|
39
|
+
* other behaviour change and no per-call cost. See the module header for the
|
|
40
|
+
* call-once pattern and scope.
|
|
40
41
|
*/
|
|
41
42
|
export declare const createTypedMessages: <C extends MessageCatalog>() => TypedMessages<C>;
|
|
42
43
|
|
|
@@ -92,8 +93,12 @@ declare interface I18nProviderProps {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/** The declared NAME of an ICU argument: the identifier before the first comma
|
|
95
|
-
* (`{count, number}` → `count`), or the whole body for a bare `{name}
|
|
96
|
-
|
|
96
|
+
* (`{count, number}` → `count`), or the whole body for a bare `{name}` — but
|
|
97
|
+
* only when it is a real identifier, else `never` (see `ValidICUName`). */
|
|
98
|
+
declare type ICUArgName<Body extends string> = Body extends `${infer Name},${string}` ? ValidICUName<Trim<Name>> : ValidICUName<Trim<Body>>;
|
|
99
|
+
|
|
100
|
+
/** The characters a real ICU argument name is made of — `[A-Za-z0-9_]`. */
|
|
101
|
+
declare type ICUIdentChar = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '_';
|
|
97
102
|
|
|
98
103
|
/**
|
|
99
104
|
* The union of ICU placeholder names in a message template literal — `never`
|
|
@@ -103,6 +108,11 @@ declare type ICUArgName<Body extends string> = Body extends `${infer Name},${str
|
|
|
103
108
|
*/
|
|
104
109
|
export declare type ICUVars<S extends string> = string extends S ? never : S extends `${string}{${infer Body}}${infer Rest}` ? ICUArgName<Body> | ICUVars<Rest> : never;
|
|
105
110
|
|
|
111
|
+
/** `true` iff every character of `S` is an ICU identifier char (and `S` is
|
|
112
|
+
* non-empty). A candidate with a space, `#`, `—`, `{`, etc. fails — that is how
|
|
113
|
+
* a plural/select BRANCH's text (`"# days total duration"`) is rejected. */
|
|
114
|
+
declare type IsICUIdentifier<S extends string> = S extends '' ? false : S extends `${infer C}${infer Rest}` ? C extends ICUIdentChar ? Rest extends '' ? true : IsICUIdentifier<Rest> : false : false;
|
|
115
|
+
|
|
106
116
|
export declare interface LazyCatalogs<L extends string = string> {
|
|
107
117
|
/** Locales this app declares, in declaration order. */
|
|
108
118
|
readonly locales: ReadonlyArray<L>;
|
|
@@ -142,6 +152,21 @@ declare interface LazyI18nProviderProps {
|
|
|
142
152
|
readonly children: ReactNode;
|
|
143
153
|
}
|
|
144
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A widened translate signature: a plain-string key with an optional loose
|
|
157
|
+
* values bag. It is what a cross-package / catalog-less boundary should type its
|
|
158
|
+
* `t` pass-through param as, instead of falling back to `(...args: any[]) => string`.
|
|
159
|
+
*
|
|
160
|
+
* A `TypedTFunction` is NOT directly assignable to this — its key param is
|
|
161
|
+
* NARROWED to the catalog's literal keys, and a narrower parameter is not
|
|
162
|
+
* assignable to a wider one (contravariance). That is deliberate: silently
|
|
163
|
+
* letting the strict `t` satisfy a `(key: string) => string` sink would erase the
|
|
164
|
+
* checking the strict surface exists for. Pass `t.dynamic` at such a boundary —
|
|
165
|
+
* it IS a `LooseTFunction` — or, inside code that CAN import the catalog, type the
|
|
166
|
+
* param `TypedTFunction<C>` and keep it strict.
|
|
167
|
+
*/
|
|
168
|
+
export declare type LooseTFunction = (id: string, values?: Readonly<Record<string, MessageValue>>) => string;
|
|
169
|
+
|
|
145
170
|
/**
|
|
146
171
|
* Build a children-to-tree wrapper that mounts `<I18nProvider>` with
|
|
147
172
|
* the supplied locale + catalog. The framework's SSG pipeline calls
|
|
@@ -231,8 +256,18 @@ export declare interface TypedMessages<C extends MessageCatalog> {
|
|
|
231
256
|
readonly T: (props: TypedTProps<C>) => ReactNode;
|
|
232
257
|
}
|
|
233
258
|
|
|
234
|
-
/** The typed key + params surface of a bound catalog
|
|
235
|
-
|
|
259
|
+
/** The typed key + params surface of a bound catalog, plus a `dynamic` escape for
|
|
260
|
+
* a runtime-computed key. */
|
|
261
|
+
export declare type TypedTFunction<C extends MessageCatalog> = (<K extends keyof C & string>(id: K, ...args: MessageArgs<C[K]>) => string) & {
|
|
262
|
+
/**
|
|
263
|
+
* Escape hatch for a genuinely runtime-computed key — `t.dynamic(`p.${x}`)`.
|
|
264
|
+
* Takes a plain string with NO forced ICU args, so it does not fight the strict
|
|
265
|
+
* literal-key surface. This is the documented, discoverable alternative to
|
|
266
|
+
* casting a computed key to the full `keyof C` union (which would wrongly demand
|
|
267
|
+
* a 2nd ICU arg, because that union spans placeholder-bearing keys).
|
|
268
|
+
*/
|
|
269
|
+
readonly dynamic: LooseTFunction;
|
|
270
|
+
};
|
|
236
271
|
|
|
237
272
|
/** `<T>` prop shape: a catalog-typed `id` with react-intl's own (loose) values —
|
|
238
273
|
* `<T>` supports rich-text tag renderers that `{var}` extraction can't model. */
|
|
@@ -308,4 +343,10 @@ export declare const useT: (id: string, values?: Record<string, string | number
|
|
|
308
343
|
*/
|
|
309
344
|
export declare const useTFn: () => (id: string, values?: Record<string, string | number | boolean | Date | null | undefined>) => string;
|
|
310
345
|
|
|
346
|
+
/** A validated ICU arg name, or `never` when the candidate isn't a real
|
|
347
|
+
* identifier. This is the guard that keeps the type-level parse from emitting a
|
|
348
|
+
* plural/select branch's TEXT as a phantom required var — an ICU arg name can't
|
|
349
|
+
* contain spaces/`#`/`—`, so anything that does is dropped. */
|
|
350
|
+
declare type ValidICUName<S extends string> = IsICUIdentifier<S> extends true ? S : never;
|
|
351
|
+
|
|
311
352
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -121,18 +121,21 @@ var l = ({ locale: e, messages: n, defaultLocale: i, silenceMissingTranslations:
|
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
throw Error(o);
|
|
124
|
-
}, A = () =>
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
}, A = (e) => {
|
|
125
|
+
let t = e;
|
|
126
|
+
return t.dynamic === void 0 && (t.dynamic = (t, n) => e(t, n)), t;
|
|
127
|
+
}, j = () => ({
|
|
128
|
+
useT: A((e, t) => d(e, t)),
|
|
129
|
+
useTFn: (() => A(m())),
|
|
127
130
|
T: u
|
|
128
|
-
}),
|
|
131
|
+
}), M = (e) => "default" in e && typeof e.default == "object" ? e.default : e, N = (e, t) => {
|
|
129
132
|
let n = /* @__PURE__ */ new Map(), r = /* @__PURE__ */ new Map(), i = (n) => n && n in e ? n : t, a = (t) => {
|
|
130
133
|
let a = i(t), o = n.get(a);
|
|
131
134
|
if (o) return Promise.resolve(o);
|
|
132
135
|
let s = r.get(a);
|
|
133
136
|
if (s) return s;
|
|
134
137
|
let c = e[a], l = c().then((e) => {
|
|
135
|
-
let t =
|
|
138
|
+
let t = M(e);
|
|
136
139
|
return n.set(a, t), r.delete(a), t;
|
|
137
140
|
}).catch((e) => {
|
|
138
141
|
throw r.delete(a), e;
|
|
@@ -148,7 +151,7 @@ var l = ({ locale: e, messages: n, defaultLocale: i, silenceMissingTranslations:
|
|
|
148
151
|
a(e).catch(() => {});
|
|
149
152
|
}
|
|
150
153
|
};
|
|
151
|
-
},
|
|
154
|
+
}, P = ({ catalogs: e, locale: t, fallback: n = null, silenceMissingTranslations: i, children: a }) => {
|
|
152
155
|
let [s, u] = c(() => e.peek(t));
|
|
153
156
|
return o(() => {
|
|
154
157
|
let n = e.peek(t);
|
|
@@ -169,7 +172,7 @@ var l = ({ locale: e, messages: n, defaultLocale: i, silenceMissingTranslations:
|
|
|
169
172
|
...i === void 0 ? {} : { silenceMissingTranslations: i },
|
|
170
173
|
children: a
|
|
171
174
|
}) : n;
|
|
172
|
-
},
|
|
175
|
+
}, F = (e, t, n) => (r) => i(l, {
|
|
173
176
|
locale: e,
|
|
174
177
|
messages: t,
|
|
175
178
|
defaultLocale: n,
|
|
@@ -177,4 +180,4 @@ var l = ({ locale: e, messages: n, defaultLocale: i, silenceMissingTranslations:
|
|
|
177
180
|
children: r
|
|
178
181
|
});
|
|
179
182
|
//#endregion
|
|
180
|
-
export { l as I18nProvider,
|
|
183
|
+
export { l as I18nProvider, P as LazyI18nProvider, u as T, k as assertCatalogParity, j as createTypedMessages, w as defineCatalog, N as defineCatalogs, T as defineLocale, F as makeSsgWrap, E as pickCatalog, h as plural, S as useFormatCurrency, v as useFormatDate, x as useFormatNumber, C as useFormatters, f as useLocale, p as useMessages, g as usePlural, b as useRelativeTime, d as useT, m as useTFn };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/i18n",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Voltro's i18n layer — thin opinionated wrap over react-intl. Provides the framework's locale-resolution conventions (voltro:lang cookie + Accept-Language fallback), a typed message-catalog helper, and a slim provider component. Power users can import directly from react-intl for advanced APIs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|