@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.
- package/dist/access/engine.d.ts +8 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +6 -1
- package/dist/access/index.js.map +1 -1
- package/dist/chunk-HN7PTOSH.js +1149 -0
- package/dist/chunk-HN7PTOSH.js.map +1 -0
- package/dist/{chunk-KSI2L3E4.cjs → chunk-V2MSAUHO.js} +165 -40
- package/dist/chunk-V2MSAUHO.js.map +1 -0
- package/dist/data/archetype.d.ts.map +1 -1
- package/dist/data/bulk.d.ts +17 -0
- package/dist/data/bulk.d.ts.map +1 -0
- package/dist/data/filters.d.ts +29 -0
- package/dist/data/filters.d.ts.map +1 -0
- package/dist/data/index.d.ts +2 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/platform-api.d.ts.map +1 -1
- package/dist/data/refresh.d.ts.map +1 -1
- package/dist/data/resolve.d.ts +10 -0
- package/dist/data/resolve.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +22 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/i18n/index.d.ts +7 -0
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/shell-translations.en.d.ts +2 -0
- package/dist/i18n/shell-translations.en.d.ts.map +1 -0
- package/dist/i18n/shell-translations.pt-br.d.ts +2 -0
- package/dist/i18n/shell-translations.pt-br.d.ts.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/search/engine.d.ts.map +1 -1
- package/dist/shell-translations.pt-br-2GWRQRRP.js +875 -0
- package/dist/shell-translations.pt-br-2GWRQRRP.js.map +1 -0
- package/dist/tenant/index.d.ts +10 -0
- package/dist/tenant/index.d.ts.map +1 -1
- package/dist/types/org.d.ts +9 -3
- package/dist/types/org.d.ts.map +1 -1
- package/dist/types/plugins.d.ts +8 -0
- package/dist/types/plugins.d.ts.map +1 -1
- package/package.json +21 -29
- package/src/access/engine.ts +16 -1
- package/src/access/index.ts +1 -0
- package/src/app/render.tsx +1 -1
- package/src/data/archetype.ts +23 -0
- package/src/data/bulk.ts +35 -0
- package/src/data/filters.test.ts +70 -0
- package/src/data/filters.ts +90 -0
- package/src/data/index.ts +2 -2
- package/src/data/mock.ts +8 -2
- package/src/data/platform-api.ts +12 -3
- package/src/data/refresh.ts +11 -1
- package/src/data/resolve.ts +16 -4
- package/src/data/supabase.ts +60 -1
- package/src/data/types.ts +24 -0
- package/src/i18n/index.ts +58 -6
- package/src/i18n/shell-translations.en.ts +875 -0
- package/src/i18n/shell-translations.pt-br.ts +873 -0
- package/src/index.ts +11 -3
- package/src/search/engine.ts +8 -0
- package/src/tenant/index.ts +20 -0
- package/src/types/org.ts +9 -3
- package/src/types/plugins.ts +8 -0
- package/dist/access/index.cjs +0 -60
- package/dist/access/index.cjs.map +0 -1
- package/dist/chunk-56GH3WC2.cjs +0 -35
- package/dist/chunk-56GH3WC2.cjs.map +0 -1
- package/dist/chunk-5TKIZVVJ.cjs +0 -16
- package/dist/chunk-5TKIZVVJ.cjs.map +0 -1
- package/dist/chunk-AXTWKF5X.cjs +0 -38
- package/dist/chunk-AXTWKF5X.cjs.map +0 -1
- package/dist/chunk-CATASHPK.cjs +0 -2018
- package/dist/chunk-CATASHPK.cjs.map +0 -1
- package/dist/chunk-DZALZ6Q6.js +0 -1988
- package/dist/chunk-DZALZ6Q6.js.map +0 -1
- package/dist/chunk-KSI2L3E4.cjs.map +0 -1
- package/dist/chunk-P5OJUH25.cjs +0 -81
- package/dist/chunk-P5OJUH25.cjs.map +0 -1
- package/dist/chunk-TYPVSWLG.js +0 -718
- package/dist/chunk-TYPVSWLG.js.map +0 -1
- package/dist/chunk-UKU4RIWM.cjs +0 -32
- package/dist/chunk-UKU4RIWM.cjs.map +0 -1
- package/dist/chunk-ZOPJB5FN.cjs +0 -369
- package/dist/chunk-ZOPJB5FN.cjs.map +0 -1
- package/dist/data/cloud.d.ts +0 -5
- package/dist/data/cloud.d.ts.map +0 -1
- package/dist/data/index.cjs +0 -52
- package/dist/data/index.cjs.map +0 -1
- package/dist/entity/index.cjs +0 -28
- package/dist/entity/index.cjs.map +0 -1
- package/dist/i18n/index.cjs +0 -44
- package/dist/i18n/index.cjs.map +0 -1
- package/dist/i18n/shell-translations.d.ts +0 -2
- package/dist/i18n/shell-translations.d.ts.map +0 -1
- package/dist/index.cjs +0 -2462
- package/dist/index.cjs.map +0 -1
- package/dist/integrations/index.cjs +0 -12
- package/dist/integrations/index.cjs.map +0 -1
- package/dist/plugin/index.cjs +0 -32
- package/dist/plugin/index.cjs.map +0 -1
- package/dist/router/index.cjs +0 -16
- package/dist/router/index.cjs.map +0 -1
- package/dist/runtime/index.cjs +0 -16
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/testing/index.cjs +0 -20
- package/dist/testing/index.cjs.map +0 -1
- package/dist/types/index.cjs +0 -4
- package/dist/types/index.cjs.map +0 -1
- package/src/data/cloud.ts +0 -28
- 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
|
|
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
|
-
|
|
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
|
|
package/src/data/platform-api.ts
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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
|
|
package/src/data/refresh.ts
CHANGED
|
@@ -75,7 +75,17 @@ function flush(): void {
|
|
|
75
75
|
*/
|
|
76
76
|
export function emitDataChanged(payload: DataChangedPayload = {}): void {
|
|
77
77
|
if (payload.table) {
|
|
78
|
-
|
|
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
|
}
|
package/src/data/resolve.ts
CHANGED
|
@@ -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
|
|
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
|
-
})
|
|
102
|
+
}))
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
return
|
|
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
|
-
})
|
|
116
|
+
}))
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
if (entityDef.data?.table) {
|
package/src/data/supabase.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
74
|
+
const snapshot = React.useSyncExternalStore(
|
|
68
75
|
(listener) => {
|
|
69
76
|
_localeListeners.add(listener)
|
|
70
77
|
return () => _localeListeners.delete(listener)
|
|
71
78
|
},
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
getI18nSnapshot,
|
|
80
|
+
getI18nSnapshot,
|
|
74
81
|
)
|
|
75
|
-
return React.useMemo(() =>
|
|
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(
|
|
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
|
+
}
|