@fayz-ai/core 0.8.3 → 0.8.5

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.
Files changed (116) hide show
  1. package/dist/access/engine.d.ts +8 -1
  2. package/dist/access/engine.d.ts.map +1 -1
  3. package/dist/access/index.d.ts +1 -1
  4. package/dist/access/index.d.ts.map +1 -1
  5. package/dist/access/index.js +6 -1
  6. package/dist/access/index.js.map +1 -1
  7. package/dist/chunk-HN7PTOSH.js +1149 -0
  8. package/dist/chunk-HN7PTOSH.js.map +1 -0
  9. package/dist/{chunk-KSI2L3E4.cjs → chunk-V2MSAUHO.js} +165 -40
  10. package/dist/chunk-V2MSAUHO.js.map +1 -0
  11. package/dist/data/archetype.d.ts.map +1 -1
  12. package/dist/data/bulk.d.ts +17 -0
  13. package/dist/data/bulk.d.ts.map +1 -0
  14. package/dist/data/filters.d.ts +29 -0
  15. package/dist/data/filters.d.ts.map +1 -0
  16. package/dist/data/index.d.ts +2 -2
  17. package/dist/data/index.d.ts.map +1 -1
  18. package/dist/data/index.js +1 -1
  19. package/dist/data/mock.d.ts.map +1 -1
  20. package/dist/data/platform-api.d.ts.map +1 -1
  21. package/dist/data/refresh.d.ts.map +1 -1
  22. package/dist/data/resolve.d.ts +10 -0
  23. package/dist/data/resolve.d.ts.map +1 -1
  24. package/dist/data/supabase.d.ts.map +1 -1
  25. package/dist/data/types.d.ts +22 -0
  26. package/dist/data/types.d.ts.map +1 -1
  27. package/dist/i18n/index.d.ts +7 -0
  28. package/dist/i18n/index.d.ts.map +1 -1
  29. package/dist/i18n/index.js +1 -1
  30. package/dist/i18n/shell-translations.en.d.ts +2 -0
  31. package/dist/i18n/shell-translations.en.d.ts.map +1 -0
  32. package/dist/i18n/shell-translations.pt-br.d.ts +2 -0
  33. package/dist/i18n/shell-translations.pt-br.d.ts.map +1 -0
  34. package/dist/index.d.ts +6 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/search/engine.d.ts.map +1 -1
  39. package/dist/shell-translations.pt-br-2GWRQRRP.js +875 -0
  40. package/dist/shell-translations.pt-br-2GWRQRRP.js.map +1 -0
  41. package/dist/tenant/index.d.ts +10 -0
  42. package/dist/tenant/index.d.ts.map +1 -1
  43. package/dist/types/org.d.ts +9 -3
  44. package/dist/types/org.d.ts.map +1 -1
  45. package/dist/types/plugins.d.ts +8 -0
  46. package/dist/types/plugins.d.ts.map +1 -1
  47. package/package.json +21 -29
  48. package/src/access/engine.ts +16 -1
  49. package/src/access/index.ts +1 -0
  50. package/src/app/render.tsx +1 -1
  51. package/src/data/archetype.ts +23 -0
  52. package/src/data/bulk.ts +35 -0
  53. package/src/data/filters.test.ts +70 -0
  54. package/src/data/filters.ts +90 -0
  55. package/src/data/index.ts +2 -2
  56. package/src/data/mock.ts +8 -2
  57. package/src/data/platform-api.ts +12 -3
  58. package/src/data/refresh.ts +11 -1
  59. package/src/data/resolve.ts +16 -4
  60. package/src/data/supabase.ts +60 -1
  61. package/src/data/types.ts +24 -0
  62. package/src/i18n/index.ts +58 -6
  63. package/src/i18n/shell-translations.en.ts +875 -0
  64. package/src/i18n/shell-translations.pt-br.ts +873 -0
  65. package/src/index.ts +11 -3
  66. package/src/search/engine.ts +8 -0
  67. package/src/tenant/index.ts +20 -0
  68. package/src/types/org.ts +9 -3
  69. package/src/types/plugins.ts +8 -0
  70. package/dist/access/index.cjs +0 -60
  71. package/dist/access/index.cjs.map +0 -1
  72. package/dist/chunk-56GH3WC2.cjs +0 -35
  73. package/dist/chunk-56GH3WC2.cjs.map +0 -1
  74. package/dist/chunk-5TKIZVVJ.cjs +0 -16
  75. package/dist/chunk-5TKIZVVJ.cjs.map +0 -1
  76. package/dist/chunk-AXTWKF5X.cjs +0 -38
  77. package/dist/chunk-AXTWKF5X.cjs.map +0 -1
  78. package/dist/chunk-CATASHPK.cjs +0 -2018
  79. package/dist/chunk-CATASHPK.cjs.map +0 -1
  80. package/dist/chunk-DZALZ6Q6.js +0 -1988
  81. package/dist/chunk-DZALZ6Q6.js.map +0 -1
  82. package/dist/chunk-KSI2L3E4.cjs.map +0 -1
  83. package/dist/chunk-P5OJUH25.cjs +0 -81
  84. package/dist/chunk-P5OJUH25.cjs.map +0 -1
  85. package/dist/chunk-TYPVSWLG.js +0 -718
  86. package/dist/chunk-TYPVSWLG.js.map +0 -1
  87. package/dist/chunk-UKU4RIWM.cjs +0 -32
  88. package/dist/chunk-UKU4RIWM.cjs.map +0 -1
  89. package/dist/chunk-ZOPJB5FN.cjs +0 -369
  90. package/dist/chunk-ZOPJB5FN.cjs.map +0 -1
  91. package/dist/data/cloud.d.ts +0 -5
  92. package/dist/data/cloud.d.ts.map +0 -1
  93. package/dist/data/index.cjs +0 -52
  94. package/dist/data/index.cjs.map +0 -1
  95. package/dist/entity/index.cjs +0 -28
  96. package/dist/entity/index.cjs.map +0 -1
  97. package/dist/i18n/index.cjs +0 -44
  98. package/dist/i18n/index.cjs.map +0 -1
  99. package/dist/i18n/shell-translations.d.ts +0 -2
  100. package/dist/i18n/shell-translations.d.ts.map +0 -1
  101. package/dist/index.cjs +0 -2462
  102. package/dist/index.cjs.map +0 -1
  103. package/dist/integrations/index.cjs +0 -12
  104. package/dist/integrations/index.cjs.map +0 -1
  105. package/dist/plugin/index.cjs +0 -32
  106. package/dist/plugin/index.cjs.map +0 -1
  107. package/dist/router/index.cjs +0 -16
  108. package/dist/router/index.cjs.map +0 -1
  109. package/dist/runtime/index.cjs +0 -16
  110. package/dist/runtime/index.cjs.map +0 -1
  111. package/dist/testing/index.cjs +0 -20
  112. package/dist/testing/index.cjs.map +0 -1
  113. package/dist/types/index.cjs +0 -4
  114. package/dist/types/index.cjs.map +0 -1
  115. package/src/data/cloud.ts +0 -28
  116. package/src/i18n/shell-translations.ts +0 -1748
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseRangeFilter, isFilterObject, matchesRange, applyRangeToQuery } from './filters'
3
+
4
+ describe('parseRangeFilter', () => {
5
+ it('reads the {from,to} dialect', () => {
6
+ expect(parseRangeFilter({ from: 1, to: 5 })).toEqual({ gte: 1, lte: 5 })
7
+ })
8
+
9
+ it('reads the Mongo dialect the models actually emit', () => {
10
+ // The exact filter that answered "não há compromissos hoje" over a booked day.
11
+ expect(
12
+ parseRangeFilter({ $gte: '2026-07-27T00:00:00Z', $lt: '2026-07-28T00:00:00Z' }),
13
+ ).toEqual({ gte: '2026-07-27T00:00:00Z', lt: '2026-07-28T00:00:00Z' })
14
+ })
15
+
16
+ it('reads bare comparison keys', () => {
17
+ expect(parseRangeFilter({ gte: 10, lt: 20 })).toEqual({ gte: 10, lt: 20 })
18
+ })
19
+
20
+ it('returns null for plain values, so they stay equality filters', () => {
21
+ expect(parseRangeFilter('open')).toBeNull()
22
+ expect(parseRangeFilter(3)).toBeNull()
23
+ expect(parseRangeFilter(null)).toBeNull()
24
+ })
25
+
26
+ it('returns null for an object with no recognised bound — never a silent match-nothing', () => {
27
+ expect(parseRangeFilter({ equals: 'open' })).toBeNull()
28
+ expect(isFilterObject({ equals: 'open' })).toBe(true)
29
+ })
30
+ })
31
+
32
+ describe('matchesRange', () => {
33
+ const day = { gte: '2026-07-27T00:00:00Z', lt: '2026-07-28T00:00:00Z' }
34
+
35
+ it('keeps a timestamp inside the window', () => {
36
+ expect(matchesRange('2026-07-27T18:00:00+00:00', day)).toBe(true)
37
+ })
38
+
39
+ it('drops the bounds it should', () => {
40
+ expect(matchesRange('2026-07-28T00:00:00Z', day)).toBe(false)
41
+ expect(matchesRange('2026-07-26T23:59:59Z', day)).toBe(false)
42
+ })
43
+
44
+ it('honours inclusive upper bounds', () => {
45
+ expect(matchesRange(5, { gte: 1, lte: 5 })).toBe(true)
46
+ expect(matchesRange(5, { gte: 1, lt: 5 })).toBe(false)
47
+ })
48
+
49
+ it('rejects values that are not comparable instead of letting them through', () => {
50
+ expect(matchesRange(null, day)).toBe(false)
51
+ expect(matchesRange('não é data', day)).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe('applyRangeToQuery', () => {
56
+ it('emits one PostgREST call per bound', () => {
57
+ const calls: Array<[string, string, unknown]> = []
58
+ const builder = {
59
+ gte(c: string, v: unknown) { calls.push(['gte', c, v]); return this },
60
+ gt(c: string, v: unknown) { calls.push(['gt', c, v]); return this },
61
+ lte(c: string, v: unknown) { calls.push(['lte', c, v]); return this },
62
+ lt(c: string, v: unknown) { calls.push(['lt', c, v]); return this },
63
+ }
64
+ applyRangeToQuery(builder, 'starts_at', { gte: 'a', lt: 'b' })
65
+ expect(calls).toEqual([
66
+ ['gte', 'starts_at', 'a'],
67
+ ['lt', 'starts_at', 'b'],
68
+ ])
69
+ })
70
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ONE filter grammar, shared by the providers and by the agent's data tools.
3
+ *
4
+ * Models express ranges in whatever dialect they were trained on — `{from,to}`,
5
+ * `{gte,lte}`, Mongo's `{$gte,$lt}`. When only some of those are recognised the
6
+ * unrecognised one falls through to an equality compare against
7
+ * "[object Object]", which matches nothing and reads as "there are no
8
+ * appointments today" instead of "I did not understand that filter".
9
+ */
10
+
11
+ export interface RangeFilter {
12
+ gte?: unknown
13
+ gt?: unknown
14
+ lte?: unknown
15
+ lt?: unknown
16
+ }
17
+
18
+ const LOWER_INCLUSIVE = ['from', 'gte', 'start', 'after', 'since', 'min']
19
+ const LOWER_EXCLUSIVE = ['gt']
20
+ const UPPER_INCLUSIVE = ['to', 'lte', 'end', 'until', 'before', 'max']
21
+ const UPPER_EXCLUSIVE = ['lt']
22
+
23
+ function pick(source: Record<string, unknown>, names: string[]): unknown {
24
+ for (const name of names) {
25
+ if (source[name] !== undefined && source[name] !== null) return source[name]
26
+ const dollar = `$${name}`
27
+ if (source[dollar] !== undefined && source[dollar] !== null) return source[dollar]
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ /**
33
+ * Parse a filter value into a range, or return null when it is a plain
34
+ * (equality) value. An object that looks like a filter but carries no
35
+ * recognised bound returns `null` too — callers treat that as "invalid",
36
+ * never as "matches nothing".
37
+ */
38
+ export function parseRangeFilter(value: unknown): RangeFilter | null {
39
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
40
+ const source = value as Record<string, unknown>
41
+ const range: RangeFilter = {}
42
+ const gte = pick(source, LOWER_INCLUSIVE)
43
+ const gt = pick(source, LOWER_EXCLUSIVE)
44
+ const lte = pick(source, UPPER_INCLUSIVE)
45
+ const lt = pick(source, UPPER_EXCLUSIVE)
46
+ if (gte !== undefined) range.gte = gte
47
+ if (gt !== undefined) range.gt = gt
48
+ if (lte !== undefined) range.lte = lte
49
+ if (lt !== undefined) range.lt = lt
50
+ return Object.keys(range).length ? range : null
51
+ }
52
+
53
+ /** True for any object value — including ones no bound could be read from. */
54
+ export function isFilterObject(value: unknown): boolean {
55
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
56
+ }
57
+
58
+ function comparable(value: unknown): number {
59
+ if (typeof value === 'number') return value
60
+ if (value instanceof Date) return value.getTime()
61
+ const ts = Date.parse(String(value))
62
+ if (!Number.isNaN(ts)) return ts
63
+ return Number(value)
64
+ }
65
+
66
+ /** Client-side counterpart of the range a provider would push to SQL. */
67
+ export function matchesRange(raw: unknown, range: RangeFilter): boolean {
68
+ const value = comparable(raw)
69
+ if (Number.isNaN(value)) return false
70
+ if (range.gte !== undefined && value < comparable(range.gte)) return false
71
+ if (range.gt !== undefined && value <= comparable(range.gt)) return false
72
+ if (range.lte !== undefined && value > comparable(range.lte)) return false
73
+ if (range.lt !== undefined && value >= comparable(range.lt)) return false
74
+ return true
75
+ }
76
+
77
+ /** Apply a range to a PostgREST query builder. */
78
+ export function applyRangeToQuery<Q>(
79
+ query: Q,
80
+ column: string,
81
+ range: RangeFilter,
82
+ ): Q {
83
+ let q = query as Record<string, (c: string, v: unknown) => unknown>
84
+ for (const op of ['gte', 'gt', 'lte', 'lt'] as const) {
85
+ const bound = range[op]
86
+ if (bound === undefined) continue
87
+ q = q[op](column, bound) as Record<string, (c: string, v: unknown) => unknown>
88
+ }
89
+ return q as Q
90
+ }
package/src/data/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export { createSupabaseProvider, setGlobalSupabaseClient, getSupabaseClientOptional } from './supabase'
2
2
  export { countByTenant, invalidateCount } from './count'
3
3
  export type { CountByTenantOptions } from './count'
4
- export { getFayzCloudClient, FAYZ_CLOUD_URL, FAYZ_CLOUD_PUBLISHABLE_KEY } from './cloud'
5
4
  export { createFayzApiProvider } from './platform-api'
6
5
  export { createMockProvider } from './mock'
6
+ export { bulkCreate } from './bulk'
7
7
  export { resolveDataProvider } from './resolve'
8
- export type { DataProvider, CrudQuery, CrudResult } from './types'
8
+ export type { DataProvider, CrudQuery, CrudResult, BulkWriteOptions, BulkWriteResult } from './types'
9
9
  export type { SupabaseProviderConfig } from './supabase'
10
10
  export type { FayzApiProviderConfig } from './platform-api'
11
11
  export type { ResolveDataProviderOptions } from './resolve'
package/src/data/mock.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DataProvider, CrudQuery, CrudResult } from './types'
2
2
  import type { EntityDef } from '../types/crud'
3
+ import { matchesRange, parseRangeFilter } from './filters'
3
4
 
4
5
  export function createMockProvider<T extends { id: string; [key: string]: unknown }>(
5
6
  entityDefOrSearchKeys: EntityDef<T> | string[],
@@ -28,11 +29,16 @@ export function createMockProvider<T extends { id: string; [key: string]: unknow
28
29
  )
29
30
  }
30
31
 
31
- // Faceted / dynamic filters (equality match per key).
32
+ // Faceted / dynamic filters same grammar the SQL providers speak, so a
33
+ // date window behaves identically on mock data instead of silently
34
+ // matching nothing (a range object is never `===` anything).
32
35
  if (query.filters) {
33
36
  for (const [key, val] of Object.entries(query.filters)) {
34
37
  if (val == null || val === '') continue
35
- filtered = filtered.filter((item) => item[key] === val)
38
+ const range = parseRangeFilter(val)
39
+ filtered = range
40
+ ? filtered.filter((item) => matchesRange(item[key], range))
41
+ : filtered.filter((item) => item[key] === val)
36
42
  }
37
43
  }
38
44
 
@@ -1,4 +1,5 @@
1
1
  import type { CrudQuery, CrudResult, DataProvider } from './types'
2
+ import { parseRangeFilter } from './filters'
2
3
 
3
4
  type FayzApiFilterOperator = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'like' | 'ilike' | 'is_null' | 'is_not_null'
4
5
 
@@ -85,11 +86,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
85
86
 
86
87
  function normalizeFilters(filters?: Record<string, unknown>): FayzApiTableFilter[] {
87
88
  if (!filters) return []
88
- return Object.entries(filters).map(([column, raw]) => {
89
+ return Object.entries(filters).flatMap(([column, raw]): FayzApiTableFilter[] => {
89
90
  if (isRecord(raw) && isFilterOperator(raw.operator)) {
90
- return { column, operator: raw.operator, value: raw.value }
91
+ return [{ column, operator: raw.operator, value: raw.value }]
91
92
  }
92
- return { column, operator: 'eq', value: raw }
93
+ // A range is TWO bounds on one column sending it as a single `eq` filter
94
+ // compared the column against an object and matched nothing.
95
+ const range = parseRangeFilter(raw)
96
+ if (range) {
97
+ return (['gte', 'gt', 'lte', 'lt'] as const)
98
+ .filter((op) => range[op] !== undefined)
99
+ .map((op) => ({ column, operator: op, value: range[op] }))
100
+ }
101
+ return [{ column, operator: 'eq', value: raw }]
93
102
  })
94
103
  }
95
104
 
@@ -75,7 +75,17 @@ function flush(): void {
75
75
  */
76
76
  export function emitDataChanged(payload: DataChangedPayload = {}): void {
77
77
  if (payload.table) {
78
- globalCache.invalidate(`${getActiveTenantId() ?? '_'}:${payload.table}`)
78
+ const tenant = getActiveTenantId() ?? '_'
79
+ // The base table and its read-model view are the SAME data under two cache
80
+ // namespaces (appointments / v_appointments). Invalidating only the one the
81
+ // writer names leaves the other serving a snapshot that predates the write
82
+ // — which is how the assistant kept reporting an empty agenda after a
83
+ // booking was created on the agenda screen.
84
+ const sibling = payload.table.startsWith('v_')
85
+ ? payload.table.slice(2)
86
+ : `v_${payload.table}`
87
+ globalCache.invalidate(`${tenant}:${payload.table}`)
88
+ globalCache.invalidate(`${tenant}:${sibling}`)
79
89
  } else {
80
90
  globalCache.clear()
81
91
  }
@@ -19,6 +19,16 @@ import { getActiveTenantId } from '../tenant'
19
19
  export interface ResolveDataProviderOptions {
20
20
  backend?: BackendRef
21
21
  fayzApi?: Omit<FayzApiProviderConfig, 'entityKey' | 'table'>
22
+ /**
23
+ * Read through the shared TTL cache (default true).
24
+ *
25
+ * Callers that must observe writes made by ANOTHER surface pass false: the
26
+ * cache is only invalidated by writes that go through it, so a record created
27
+ * on the agenda screen stays invisible to the assistant for the rest of the
28
+ * TTL — which is how "o que tem na agenda hoje?" answered "nada" over a
29
+ * booking made seconds earlier.
30
+ */
31
+ cache?: boolean
22
32
  customProviders?: Record<string, <T extends { id: string }>(entityDef: EntityDef<T>, mockData?: T[]) => DataProvider<T>>
23
33
  }
24
34
 
@@ -77,9 +87,11 @@ export function resolveDataProvider<T extends { id: string }>(
77
87
  tenantId,
78
88
  ttl: entityDef.data.cacheTTL,
79
89
  }
90
+ const cached = <P extends DataProvider<T>>(p: P): DataProvider<T> =>
91
+ options.cache === false ? p : withCache(p, cacheOptions)
80
92
 
81
93
  if (entityDef.data.archetype && entityDef.data.archetypeKind && !entityDef.data.schema) {
82
- return withCache(createArchetypeProvider<T>({
94
+ return cached(createArchetypeProvider<T>({
83
95
  archetype: entityDef.data.archetype,
84
96
  archetypeKind: entityDef.data.archetypeKind,
85
97
  projectTable: entityDef.data.table,
@@ -87,10 +99,10 @@ export function resolveDataProvider<T extends { id: string }>(
87
99
  searchColumns: entityDef.data.searchColumns ?? entityDef.fields
88
100
  .filter((field) => field.searchable)
89
101
  .map((field) => field.key),
90
- }), cacheOptions)
102
+ }))
91
103
  }
92
104
 
93
- return withCache(createSupabaseProvider<T>(entityDef.data.table, {
105
+ return cached(createSupabaseProvider<T>(entityDef.data.table, {
94
106
  schema: entityDef.data.schema,
95
107
  tenantId: entityDef.data.tenantScoped === false ? undefined : tenantId,
96
108
  tenantIdColumn: entityDef.data.tenantIdColumn,
@@ -101,7 +113,7 @@ export function resolveDataProvider<T extends { id: string }>(
101
113
  columnMap: entityDef.data.columnMap,
102
114
  filters: entityDef.data.filters,
103
115
  defaults: entityDef.data.defaults,
104
- }), cacheOptions)
116
+ }))
105
117
  }
106
118
 
107
119
  if (entityDef.data?.table) {
@@ -1,4 +1,5 @@
1
1
  import type { DataProvider, CrudQuery, CrudResult } from './types'
2
+ import { applyRangeToQuery, isFilterObject, parseRangeFilter } from './filters'
2
3
 
3
4
  export interface SupabaseProviderConfig {
4
5
  schema?: string
@@ -96,11 +97,24 @@ export function createSupabaseProvider<T extends { id: string }>(
96
97
  }
97
98
  }
98
99
 
99
- // Faceted / dynamic filters from the query (equality, mapped to columns).
100
+ // Faceted / dynamic filters from the query, mapped to columns. Equality
101
+ // by default; a range object ({from,to} / {gte,lt} / {$gte,$lt}) becomes
102
+ // real SQL bounds instead of an equality compare against "[object Object]".
100
103
  if (query.filters) {
101
104
  for (const [key, val] of Object.entries(query.filters)) {
102
105
  if (val == null || val === '') continue
103
106
  const col = columnMap?.[key] ?? camelToSnake(key)
107
+ const range = parseRangeFilter(val)
108
+ if (range) {
109
+ q = applyRangeToQuery(q, col, range)
110
+ continue
111
+ }
112
+ if (isFilterObject(val)) {
113
+ throw new Error(
114
+ `[@fayz-ai/core] Filter "${key}" is an object with no recognised bound. ` +
115
+ 'Use {from,to} / {gte,lte,gt,lt} for ranges, or a plain value for equality.',
116
+ )
117
+ }
104
118
  q = (q as { eq: (c: string, v: unknown) => unknown }).eq(col, val) as Record<string, unknown>
105
119
  }
106
120
  }
@@ -151,6 +165,51 @@ export function createSupabaseProvider<T extends { id: string }>(
151
165
  return mapRow<T>(created!, columnMap)
152
166
  },
153
167
 
168
+ async createMany(rows, options) {
169
+ const tenantId = resolveTenantId()
170
+ const size = Math.max(1, options?.chunkSize ?? 200)
171
+ const prepared = rows.map((r) => {
172
+ const row = mapEntity(r as Record<string, unknown>, columnMap)
173
+ if (tenantId) row[tenantIdCol] = tenantId
174
+ if (config?.defaults) {
175
+ for (const [col, val] of Object.entries(config.defaults)) {
176
+ if (row[col] === undefined) row[col] = val
177
+ }
178
+ }
179
+ return row
180
+ })
181
+
182
+ let created = 0
183
+ const failed: Array<{ index: number; message: string }> = []
184
+ for (let start = 0; start < prepared.length; start += size) {
185
+ const chunk = prepared.slice(start, start + size)
186
+ const { error } = (await (getClient().from(table) as {
187
+ insert: (r: unknown) => Promise<{ error: unknown }>
188
+ }).insert(chunk)) as { error: unknown }
189
+ if (!error) {
190
+ created += chunk.length
191
+ } else {
192
+ // One bad row rejects its whole chunk. Retrying it row by row keeps
193
+ // the other 199 — an import that loses 200 records over one malformed
194
+ // phone number is worse than no import.
195
+ const message = error instanceof Error ? error.message : String(error)
196
+ for (let i = 0; i < chunk.length; i++) {
197
+ const { error: rowError } = (await (getClient().from(table) as {
198
+ insert: (r: unknown) => Promise<{ error: unknown }>
199
+ }).insert(chunk[i])) as { error: unknown }
200
+ if (rowError) {
201
+ failed.push({
202
+ index: start + i,
203
+ message: rowError instanceof Error ? rowError.message : String(rowError ?? message),
204
+ })
205
+ } else created += 1
206
+ }
207
+ }
208
+ options?.onProgress?.(Math.min(start + chunk.length, prepared.length), prepared.length)
209
+ }
210
+ return { created, failed }
211
+ },
212
+
154
213
  async update(id, data) {
155
214
  const row = mapEntity(data as Record<string, unknown>, columnMap)
156
215
  const { data: updated, error } = (await (getClient().from(table) as {
package/src/data/types.ts CHANGED
@@ -18,9 +18,33 @@ export interface CrudResult<T> {
18
18
  total: number
19
19
  }
20
20
 
21
+ /** Outcome of a bulk write: what landed, and what did not and why. */
22
+ export interface BulkWriteResult {
23
+ created: number
24
+ failed: Array<{ index: number; message: string }>
25
+ }
26
+
27
+ export interface BulkWriteOptions {
28
+ /** Rows per round-trip. Larger is fewer trips; too large trips statement timeouts. */
29
+ chunkSize?: number
30
+ onProgress?: (done: number, total: number) => void
31
+ }
32
+
21
33
  export interface DataProvider<T extends { id: string } = { id: string }> {
22
34
  list(query: CrudQuery): Promise<CrudResult<T>>
23
35
  create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt' | 'tenantId'>): Promise<T>
36
+ /**
37
+ * Write many rows in as few round-trips as the backend allows.
38
+ *
39
+ * Optional: `bulkCreate` falls back to looping `create` for providers that do
40
+ * not implement it, so a caller never has to branch. Importing a 900-row
41
+ * client list one INSERT at a time is 900 round-trips — the difference
42
+ * between a progress bar and a hung tab.
43
+ */
44
+ createMany?(
45
+ rows: Array<Omit<T, 'id' | 'createdAt' | 'updatedAt' | 'tenantId'>>,
46
+ options?: BulkWriteOptions,
47
+ ): Promise<BulkWriteResult>
24
48
  update(id: string, data: Partial<T>): Promise<T>
25
49
  remove(id: string): Promise<void>
26
50
  }
package/src/i18n/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react'
2
- import { shellTranslations } from './shell-translations'
2
+ import { shellTranslationsEn } from './shell-translations.en'
3
3
 
4
4
  export interface I18nConfig {
5
5
  defaultLocale: string
@@ -63,16 +63,26 @@ export function setLocaleStore(store: LocaleStore): void {
63
63
  _localeStore = store
64
64
  }
65
65
 
66
+ // Bumped when lazily-loaded translations register so subscribed components
67
+ // re-render even though the locale string itself did not change.
68
+ let _i18nVersion = 0
69
+ function getI18nSnapshot(): string {
70
+ return `${_localeStore.locale} ${_i18nVersion}`
71
+ }
72
+
66
73
  function useLocaleStore(): LocaleStore {
67
- const locale = React.useSyncExternalStore(
74
+ const snapshot = React.useSyncExternalStore(
68
75
  (listener) => {
69
76
  _localeListeners.add(listener)
70
77
  return () => _localeListeners.delete(listener)
71
78
  },
72
- getCurrentLocale,
73
- getCurrentLocale,
79
+ getI18nSnapshot,
80
+ getI18nSnapshot,
74
81
  )
75
- return React.useMemo(() => ({ locale, setLocale: setCurrentLocale }), [locale])
82
+ return React.useMemo(() => {
83
+ const locale = snapshot.slice(0, snapshot.indexOf(' '))
84
+ return { locale, setLocale: setCurrentLocale }
85
+ }, [snapshot])
76
86
  }
77
87
 
78
88
  export function getCurrentLocale(): string {
@@ -82,6 +92,9 @@ export function getCurrentLocale(): string {
82
92
  export function setCurrentLocale(locale: string): void {
83
93
  if (_localeStore.locale === locale) return
84
94
  _localeStore.setLocale(locale)
95
+ // Fire-and-forget: if the locale's shell translations are lazily split, load
96
+ // and register them; keys fall back to 'en' until the chunk arrives.
97
+ void preloadLocale(locale)
85
98
  _localeListeners.forEach((listener) => listener())
86
99
  }
87
100
 
@@ -157,6 +170,7 @@ export const coreTranslations: Record<string, Record<string, string>> = {
157
170
  'settings.company': 'Company',
158
171
  'settings.branding': 'Branding',
159
172
  'settings.security': 'Security',
173
+ 'settings.sites': 'Sites',
160
174
  'settings.team': 'Team',
161
175
  'settings.billing': 'Billing',
162
176
  'settings.locations': 'Locations',
@@ -235,6 +249,7 @@ export const coreTranslations: Record<string, Record<string, string>> = {
235
249
  'settings.company': 'Empresa',
236
250
  'settings.branding': 'Identidade Visual',
237
251
  'settings.security': 'Segurança',
252
+ 'settings.sites': 'Sites',
238
253
  'settings.team': 'Equipe',
239
254
  'settings.billing': 'Faturamento',
240
255
  'settings.locations': 'Localizações',
@@ -273,4 +288,41 @@ export function mergeTranslations(
273
288
  // the native CRUD engine, framework UI and de-bridged plugins resolve their
274
289
  // keys even under a host shell that does not mount @fayz-ai/core's I18nProvider.
275
290
  registerTranslations(coreTranslations)
276
- registerTranslations(shellTranslations)
291
+ registerTranslations({ en: shellTranslationsEn })
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Lazy shell locales. Only 'en' (the fallback) ships eagerly; other locales
295
+ // load on demand when the app switches to them (setCurrentLocale) or when the
296
+ // app awaits preloadLocale before rendering. Keys resolve through the 'en'
297
+ // fallback until the locale chunk arrives, then listeners re-render.
298
+ // ---------------------------------------------------------------------------
299
+
300
+ const _lazyShellLocales: Record<string, () => Promise<Record<string, string>>> = {
301
+ 'pt-BR': () => import('./shell-translations.pt-br').then((m) => m.shellTranslationsPtBr),
302
+ }
303
+ const _loadedShellLocales = new Set<string>(['en'])
304
+ const _pendingShellLocales = new Map<string, Promise<void>>()
305
+
306
+ /**
307
+ * Ensure a lazily-split shell locale is loaded and registered. Resolves
308
+ * immediately for unknown/already-loaded locales. setCurrentLocale calls this
309
+ * fire-and-forget; apps that must avoid an English flash on first paint can
310
+ * `await preloadLocale('pt-BR')` before rendering.
311
+ */
312
+ export function preloadLocale(locale: string): Promise<void> {
313
+ if (_loadedShellLocales.has(locale)) return Promise.resolve()
314
+ const loader = _lazyShellLocales[locale]
315
+ if (!loader) return Promise.resolve()
316
+ let pending = _pendingShellLocales.get(locale)
317
+ if (!pending) {
318
+ pending = loader().then((translations) => {
319
+ _loadedShellLocales.add(locale)
320
+ _pendingShellLocales.delete(locale)
321
+ registerTranslations({ [locale]: translations })
322
+ _i18nVersion++
323
+ _localeListeners.forEach((listener) => listener())
324
+ })
325
+ _pendingShellLocales.set(locale, pending)
326
+ }
327
+ return pending
328
+ }