@fayz-ai/core 0.8.1 → 0.8.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/dist/access/denial.d.ts +21 -0
- package/dist/access/denial.d.ts.map +1 -0
- package/dist/access/engine.d.ts +63 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/index.cjs +60 -0
- package/dist/access/index.cjs.map +1 -0
- package/dist/access/index.d.ts +6 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +51 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/limits.d.ts +10 -0
- package/dist/access/limits.d.ts.map +1 -0
- package/dist/address/index.d.ts +52 -0
- package/dist/address/index.d.ts.map +1 -0
- package/dist/{chunk-QR7U2URY.cjs → chunk-CATASHPK.cjs} +345 -4
- package/dist/chunk-CATASHPK.cjs.map +1 -0
- package/dist/{chunk-WKZVSKEU.js → chunk-CU64QI4A.js} +2 -2
- package/dist/chunk-CU64QI4A.js.map +1 -0
- package/dist/{chunk-KNIVT6XZ.js → chunk-DZALZ6Q6.js} +345 -4
- package/dist/chunk-DZALZ6Q6.js.map +1 -0
- package/dist/{chunk-54SRL7AJ.cjs → chunk-KSI2L3E4.cjs} +145 -86
- package/dist/chunk-KSI2L3E4.cjs.map +1 -0
- package/dist/{chunk-ZBIWYDAR.js → chunk-TYPVSWLG.js} +144 -87
- package/dist/chunk-TYPVSWLG.js.map +1 -0
- package/dist/{chunk-2NTHNWEH.cjs → chunk-UKU4RIWM.cjs} +2 -2
- package/dist/chunk-UKU4RIWM.cjs.map +1 -0
- package/dist/{chunk-CHGX6UYR.js → chunk-YH25Y4FW.js} +6 -3
- package/dist/chunk-YH25Y4FW.js.map +1 -0
- package/dist/{chunk-5X2VX3RQ.cjs → chunk-ZOPJB5FN.cjs} +6 -3
- package/dist/chunk-ZOPJB5FN.cjs.map +1 -0
- package/dist/data/archetype.d.ts.map +1 -1
- package/dist/data/count.d.ts +34 -0
- package/dist/data/count.d.ts.map +1 -0
- package/dist/data/index.cjs +18 -10
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/refresh.d.ts +36 -0
- package/dist/data/refresh.d.ts.map +1 -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 +6 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/entity/index.cjs +6 -6
- package/dist/entity/index.js +1 -1
- package/dist/entity/registry.d.ts +9 -0
- package/dist/entity/registry.d.ts.map +1 -1
- package/dist/i18n/index.cjs +10 -10
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/shell-translations.d.ts.map +1 -1
- package/dist/index.cjs +1517 -180
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +15 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1412 -110
- package/dist/index.js.map +1 -1
- package/dist/manifest/index.d.ts +109 -3
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/serialize.d.ts +14 -0
- package/dist/manifest/serialize.d.ts.map +1 -0
- package/dist/plugin/index.cjs +7 -7
- package/dist/plugin/index.js +1 -1
- package/dist/plugin/runtime.d.ts.map +1 -1
- package/dist/search/engine.d.ts +12 -0
- package/dist/search/engine.d.ts.map +1 -0
- package/dist/search/index.d.ts +5 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/text.d.ts +37 -0
- package/dist/search/text.d.ts.map +1 -0
- package/dist/search/types.d.ts +72 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/types/billing.d.ts +20 -0
- package/dist/types/billing.d.ts.map +1 -1
- package/dist/types/crud.d.ts +47 -1
- package/dist/types/crud.d.ts.map +1 -1
- package/dist/types/entitlements.d.ts +45 -0
- package/dist/types/entitlements.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/org.d.ts +28 -0
- package/dist/types/org.d.ts.map +1 -1
- package/dist/types/plugins.d.ts +88 -0
- package/dist/types/plugins.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/access/denial.ts +33 -0
- package/src/access/engine.test.ts +48 -0
- package/src/access/engine.ts +121 -0
- package/src/access/index.ts +13 -0
- package/src/access/limits.ts +18 -0
- package/src/address/index.ts +188 -0
- package/src/data/archetype.ts +4 -2
- package/src/data/count.test.ts +152 -0
- package/src/data/count.ts +131 -0
- package/src/data/index.ts +2 -0
- package/src/data/refresh.ts +105 -0
- package/src/data/resolve.ts +7 -1
- package/src/data/supabase.ts +3 -1
- package/src/data/types.ts +6 -0
- package/src/entity/registry.ts +7 -0
- package/src/i18n/shell-translations.ts +345 -2
- package/src/index.ts +37 -4
- package/src/manifest/app-manifest.schema.json +618 -45
- package/src/manifest/index.ts +127 -3
- package/src/manifest/serialize.ts +52 -0
- package/src/plugin/runtime.ts +5 -1
- package/src/search/engine.ts +551 -0
- package/src/search/index.ts +25 -0
- package/src/search/text.ts +206 -0
- package/src/search/types.ts +75 -0
- package/src/types/billing.ts +22 -0
- package/src/types/crud.ts +48 -1
- package/src/types/entitlements.ts +45 -0
- package/src/types/index.ts +4 -3
- package/src/types/org.ts +29 -0
- package/src/types/plugins.ts +83 -0
- package/dist/chunk-2NTHNWEH.cjs.map +0 -1
- package/dist/chunk-54SRL7AJ.cjs.map +0 -1
- package/dist/chunk-5X2VX3RQ.cjs.map +0 -1
- package/dist/chunk-CHGX6UYR.js.map +0 -1
- package/dist/chunk-KNIVT6XZ.js.map +0 -1
- package/dist/chunk-QR7U2URY.cjs.map +0 -1
- package/dist/chunk-WKZVSKEU.js.map +0 -1
- package/dist/chunk-ZBIWYDAR.js.map +0 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Postal code → address lookup (SDK-level — shared by any surface that collects
|
|
3
|
+
// an address: storefront checkout, the SaaS address book, CRM, booking).
|
|
4
|
+
//
|
|
5
|
+
// Sits beside ./phone for the same reason: knowing that a Brazilian CEP is
|
|
6
|
+
// eight digits, and that '01310100' means Avenida Paulista, is not a
|
|
7
|
+
// storefront concern. It belongs wherever an address is typed.
|
|
8
|
+
//
|
|
9
|
+
// The lookup itself is behind an interface. ViaCEP is the default because it is
|
|
10
|
+
// free, keyless and CORS-enabled, but it is a third party the merchant does not
|
|
11
|
+
// control — so swapping it for an edge-function proxy (shared cache, no
|
|
12
|
+
// third-party call from the shopper's browser) must be one line, not a rewrite.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An address as a postal service knows it: everything except the parts only the
|
|
17
|
+
* resident can supply (number, complement).
|
|
18
|
+
*
|
|
19
|
+
* Field names mirror ShippingAddressInput in @fayz-ai/shop so the value crosses
|
|
20
|
+
* from lookup to checkout to the order with no translation step in between.
|
|
21
|
+
*/
|
|
22
|
+
export interface PostalAddress {
|
|
23
|
+
postalCode: string
|
|
24
|
+
street: string
|
|
25
|
+
district: string
|
|
26
|
+
city: string
|
|
27
|
+
state: string
|
|
28
|
+
country: string
|
|
29
|
+
/** IBGE municipality code when the source provides one — useful for tax later. */
|
|
30
|
+
cityCode?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a postal code is resolved. Implementations must not throw for a
|
|
34
|
+
* well-formed code that simply does not exist — that is `null`, not an error. */
|
|
35
|
+
export interface PostalLookupProvider {
|
|
36
|
+
lookup(postalCode: string): Promise<PostalAddress | null>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keep only digits. */
|
|
40
|
+
export function normalizePostalCode(value: string): string {
|
|
41
|
+
return (value || '').replace(/\D/g, '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** '01310100' | '01310-100' -> '01310-100'. Partial input formats progressively. */
|
|
45
|
+
export function formatPostalCode(value: string): string {
|
|
46
|
+
const digits = normalizePostalCode(value).slice(0, 8)
|
|
47
|
+
return digits.length > 5 ? `${digits.slice(0, 5)}-${digits.slice(5)}` : digits
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A Brazilian CEP is exactly eight digits. */
|
|
51
|
+
export function isValidPostalCode(value: string): boolean {
|
|
52
|
+
return normalizePostalCode(value).length === 8
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Cache
|
|
57
|
+
//
|
|
58
|
+
// A CEP maps to the same street for years, so the second lookup of one should
|
|
59
|
+
// never leave the browser. This also keeps a busy storefront well clear of
|
|
60
|
+
// ViaCEP's per-IP rate limit. localStorage when there is a window, an in-memory
|
|
61
|
+
// Map otherwise (SSR, tests, node) — never a hard failure over a cache miss.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const CACHE_KEY = 'fayz.postal.cache.v1'
|
|
65
|
+
const memoryCache = new Map<string, PostalAddress>()
|
|
66
|
+
|
|
67
|
+
function readCache(code: string): PostalAddress | undefined {
|
|
68
|
+
const hit = memoryCache.get(code)
|
|
69
|
+
if (hit) return hit
|
|
70
|
+
try {
|
|
71
|
+
const raw = globalThis.localStorage?.getItem(CACHE_KEY)
|
|
72
|
+
if (!raw) return undefined
|
|
73
|
+
const all = JSON.parse(raw) as Record<string, PostalAddress>
|
|
74
|
+
const stored = all[code]
|
|
75
|
+
if (stored) memoryCache.set(code, stored)
|
|
76
|
+
return stored
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeCache(code: string, address: PostalAddress): void {
|
|
83
|
+
memoryCache.set(code, address)
|
|
84
|
+
try {
|
|
85
|
+
const store = globalThis.localStorage
|
|
86
|
+
if (!store) return
|
|
87
|
+
const raw = store.getItem(CACHE_KEY)
|
|
88
|
+
const all = raw ? (JSON.parse(raw) as Record<string, PostalAddress>) : {}
|
|
89
|
+
all[code] = address
|
|
90
|
+
store.setItem(CACHE_KEY, JSON.stringify(all))
|
|
91
|
+
} catch {
|
|
92
|
+
/* quota or private mode — the in-memory copy still stands for this session */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drop the cache. Exposed for tests and for a "wrong address?" escape hatch. */
|
|
97
|
+
export function clearPostalCache(): void {
|
|
98
|
+
memoryCache.clear()
|
|
99
|
+
try {
|
|
100
|
+
globalThis.localStorage?.removeItem(CACHE_KEY)
|
|
101
|
+
} catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// ViaCEP
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
interface ViaCepResponse {
|
|
111
|
+
cep?: string
|
|
112
|
+
logradouro?: string
|
|
113
|
+
bairro?: string
|
|
114
|
+
localidade?: string
|
|
115
|
+
uf?: string
|
|
116
|
+
ibge?: string
|
|
117
|
+
erro?: boolean | string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ViaCEP (https://viacep.com.br). Free, no key, CORS-enabled.
|
|
122
|
+
*
|
|
123
|
+
* The trap: an unknown CEP does NOT come back as 404. ViaCEP answers HTTP 200
|
|
124
|
+
* with `{"erro": true}` (and, on some deploys, the string "true"), so checking
|
|
125
|
+
* response.ok alone yields an address of empty strings that silently overwrites
|
|
126
|
+
* whatever the shopper had typed. Both shapes are treated as "not found".
|
|
127
|
+
*/
|
|
128
|
+
export function createViaCepProvider(options?: { fetcher?: typeof fetch }): PostalLookupProvider {
|
|
129
|
+
const fetcher = options?.fetcher ?? globalThis.fetch
|
|
130
|
+
return {
|
|
131
|
+
async lookup(postalCode: string): Promise<PostalAddress | null> {
|
|
132
|
+
const code = normalizePostalCode(postalCode)
|
|
133
|
+
if (code.length !== 8) return null
|
|
134
|
+
|
|
135
|
+
const response = await fetcher(`https://viacep.com.br/ws/${code}/json/`)
|
|
136
|
+
if (!response.ok) throw new Error(`Consulta de CEP falhou (${response.status})`)
|
|
137
|
+
|
|
138
|
+
const data = (await response.json()) as ViaCepResponse
|
|
139
|
+
if (data.erro === true || data.erro === 'true' || !data.localidade) return null
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
postalCode: code,
|
|
143
|
+
street: data.logradouro ?? '',
|
|
144
|
+
district: data.bairro ?? '',
|
|
145
|
+
city: data.localidade,
|
|
146
|
+
state: (data.uf ?? '').toUpperCase(),
|
|
147
|
+
country: 'BR',
|
|
148
|
+
cityCode: data.ibge || undefined,
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Module-level resolver — same shape as setShopTenantResolver /
|
|
156
|
+
// setShopAccessTokenResolver, so an app overrides the source once at boot
|
|
157
|
+
// instead of threading a provider through every component that needs a CEP.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
let _provider: PostalLookupProvider | null = null
|
|
161
|
+
|
|
162
|
+
export function setPostalLookupProvider(provider: PostalLookupProvider | null): void {
|
|
163
|
+
_provider = provider
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getPostalLookupProvider(): PostalLookupProvider {
|
|
167
|
+
if (!_provider) _provider = createViaCepProvider()
|
|
168
|
+
return _provider
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a postal code to an address, through the cache.
|
|
173
|
+
*
|
|
174
|
+
* Returns null for a code that is malformed or does not exist; throws only when
|
|
175
|
+
* the lookup itself failed (offline, provider down), so a caller can tell
|
|
176
|
+
* "no such CEP" from "we could not check right now" and say the right thing.
|
|
177
|
+
*/
|
|
178
|
+
export async function lookupPostalCode(postalCode: string): Promise<PostalAddress | null> {
|
|
179
|
+
const code = normalizePostalCode(postalCode)
|
|
180
|
+
if (code.length !== 8) return null
|
|
181
|
+
|
|
182
|
+
const cached = readCache(code)
|
|
183
|
+
if (cached) return cached
|
|
184
|
+
|
|
185
|
+
const address = await getPostalLookupProvider().lookup(code)
|
|
186
|
+
if (address) writeCache(code, address)
|
|
187
|
+
return address
|
|
188
|
+
}
|
package/src/data/archetype.ts
CHANGED
|
@@ -109,9 +109,11 @@ export function createArchetypeProvider<T extends { id: string }>(
|
|
|
109
109
|
|
|
110
110
|
// Pure archetype → read public.<base> directly (filtered by kind);
|
|
111
111
|
// otherwise the v_ view (JOINs public extension with the public archetype).
|
|
112
|
+
// 'none' skips PostgREST's count aggregate entirely — see CrudQuery.countMode.
|
|
113
|
+
const countOpts = query.countMode === 'none' ? undefined : { count: 'exact' as const }
|
|
112
114
|
let q = isPure
|
|
113
|
-
? coreClient().from(ac.table).select('*',
|
|
114
|
-
: getClient().from(viewName).select('*',
|
|
115
|
+
? coreClient().from(ac.table).select('*', countOpts).eq('tenant_id', tenantId)
|
|
116
|
+
: getClient().from(viewName).select('*', countOpts).eq('tenant_id', tenantId)
|
|
115
117
|
|
|
116
118
|
if (isPure && HAS_KIND.has(config.archetype)) {
|
|
117
119
|
q = q.eq('kind', config.archetypeKind)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { countByTenant, invalidateCount } from './count'
|
|
3
|
+
import { setGlobalSupabaseClient } from './supabase'
|
|
4
|
+
import { setActiveTenantId } from '../tenant'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Mock Supabase client — a thenable query builder that records .eq/.gte calls.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
interface MockClient {
|
|
11
|
+
client: unknown
|
|
12
|
+
selectCalls: number
|
|
13
|
+
lastEq: Array<[string, unknown]>
|
|
14
|
+
lastGte: Array<[string, unknown]>
|
|
15
|
+
setCount: (n: number, error?: unknown) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeClient(initialCount = 0): MockClient {
|
|
19
|
+
const state = { count: initialCount as number, error: null as unknown }
|
|
20
|
+
const record: MockClient = {
|
|
21
|
+
client: null,
|
|
22
|
+
selectCalls: 0,
|
|
23
|
+
lastEq: [],
|
|
24
|
+
lastGte: [],
|
|
25
|
+
setCount: (n, error = null) => {
|
|
26
|
+
state.count = n
|
|
27
|
+
state.error = error
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const query: Record<string, unknown> = {
|
|
32
|
+
eq: (col: string, val: unknown) => {
|
|
33
|
+
record.lastEq.push([col, val])
|
|
34
|
+
return query
|
|
35
|
+
},
|
|
36
|
+
gte: (col: string, val: unknown) => {
|
|
37
|
+
record.lastGte.push([col, val])
|
|
38
|
+
return query
|
|
39
|
+
},
|
|
40
|
+
then: (resolve: (r: { count: number; error: unknown }) => void) =>
|
|
41
|
+
resolve({ count: state.count, error: state.error }),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
record.client = {
|
|
45
|
+
from: () => ({
|
|
46
|
+
select: (_cols: string, _opts: unknown) => {
|
|
47
|
+
record.selectCalls++
|
|
48
|
+
return query
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
}
|
|
52
|
+
return record
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('countByTenant', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
invalidateCount() // clear all cached counts
|
|
58
|
+
setGlobalSupabaseClient(null as unknown as object)
|
|
59
|
+
setActiveTenantId('tenant-1')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns 0 in mock mode (no supabase client) — never blocks', async () => {
|
|
63
|
+
setGlobalSupabaseClient(null as unknown as object)
|
|
64
|
+
expect(await countByTenant('clients')).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns 0 when no tenant is resolvable', async () => {
|
|
68
|
+
setActiveTenantId(undefined)
|
|
69
|
+
const m = makeClient(5)
|
|
70
|
+
setGlobalSupabaseClient(m.client as object)
|
|
71
|
+
expect(await countByTenant('clients')).toBe(0)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('counts tenant-scoped rows via head:true', async () => {
|
|
75
|
+
const m = makeClient(42)
|
|
76
|
+
setGlobalSupabaseClient(m.client as object)
|
|
77
|
+
expect(await countByTenant('clients')).toBe(42)
|
|
78
|
+
expect(m.lastEq).toContainEqual(['tenant_id', 'tenant-1'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('caches within the TTL (second read does not hit the DB)', async () => {
|
|
82
|
+
const m = makeClient(10)
|
|
83
|
+
setGlobalSupabaseClient(m.client as object)
|
|
84
|
+
await countByTenant('clients')
|
|
85
|
+
await countByTenant('clients')
|
|
86
|
+
expect(m.selectCalls).toBe(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fresh:true bypasses the cache', async () => {
|
|
90
|
+
const m = makeClient(10)
|
|
91
|
+
setGlobalSupabaseClient(m.client as object)
|
|
92
|
+
await countByTenant('clients')
|
|
93
|
+
await countByTenant('clients', { fresh: true })
|
|
94
|
+
expect(m.selectCalls).toBe(2)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('invalidateCount(table) drops the cached value', async () => {
|
|
98
|
+
const m = makeClient(10)
|
|
99
|
+
setGlobalSupabaseClient(m.client as object)
|
|
100
|
+
await countByTenant('clients')
|
|
101
|
+
invalidateCount('clients')
|
|
102
|
+
await countByTenant('clients')
|
|
103
|
+
expect(m.selectCalls).toBe(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('separate keys (kind/period) are cached independently', async () => {
|
|
107
|
+
const m = makeClient(3)
|
|
108
|
+
setGlobalSupabaseClient(m.client as object)
|
|
109
|
+
await countByTenant('bookings', { period: 'month' })
|
|
110
|
+
await countByTenant('bookings', { period: 'total' })
|
|
111
|
+
expect(m.selectCalls).toBe(2)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('applies kind filter and month window', async () => {
|
|
115
|
+
const m = makeClient(7)
|
|
116
|
+
setGlobalSupabaseClient(m.client as object)
|
|
117
|
+
await countByTenant('items', { kind: 'product', period: 'month' })
|
|
118
|
+
expect(m.lastEq).toContainEqual(['kind', 'product'])
|
|
119
|
+
expect(m.lastGte.some(([col]) => col === 'created_at')).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('fails open (returns 0) when the query errors', async () => {
|
|
123
|
+
const m = makeClient(0)
|
|
124
|
+
m.setCount(0, new Error('boom'))
|
|
125
|
+
setGlobalSupabaseClient(m.client as object)
|
|
126
|
+
expect(await countByTenant('clients')).toBe(0)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('fails open (returns 0) instead of hanging when the query never settles', async () => {
|
|
130
|
+
// A cold-boot query can be issued before the auth session is ready and then
|
|
131
|
+
// never resolve. The guard awaiting this must not hang the UI forever.
|
|
132
|
+
const client = {
|
|
133
|
+
from: () => ({
|
|
134
|
+
select: () => ({
|
|
135
|
+
eq: () => ({
|
|
136
|
+
eq: () => neverSettles,
|
|
137
|
+
gte: () => neverSettles,
|
|
138
|
+
then: neverSettles.then.bind(neverSettles),
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
}
|
|
143
|
+
const neverSettles = { then: () => {} } as unknown as Promise<never>
|
|
144
|
+
setGlobalSupabaseClient(client as object)
|
|
145
|
+
|
|
146
|
+
vi.useFakeTimers()
|
|
147
|
+
const pending = countByTenant('clients', { fresh: true })
|
|
148
|
+
await vi.advanceTimersByTimeAsync(6_000)
|
|
149
|
+
await expect(pending).resolves.toBe(0)
|
|
150
|
+
vi.useRealTimers()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getSupabaseClientOptional } from './supabase'
|
|
2
|
+
import { getActiveTenantId } from '../tenant'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tenant-scoped row counter — the single cheap `count(*)` helper the access
|
|
6
|
+
* engine uses to resolve quantity limits. Uses Supabase's `head:true` count so
|
|
7
|
+
* no rows are transferred. Results are cached briefly per tenant+table+filter to
|
|
8
|
+
* survive the burst of reads a page does (nav badge + gate + list), and can be
|
|
9
|
+
* force-invalidated by creates via {@link invalidateCount}.
|
|
10
|
+
*
|
|
11
|
+
* MOCK MODE NEVER BLOCKS: with no global Supabase client the count is `0`, so
|
|
12
|
+
* limits always read as "well under cap" — offline/demo apps stay fully usable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CountByTenantOptions {
|
|
16
|
+
/** Optional `kind` column filter for multi-kind tables. */
|
|
17
|
+
kind?: string
|
|
18
|
+
/** `'month'` counts rows created since the 1st of the current month; `'total'` (default) counts all. */
|
|
19
|
+
period?: 'month' | 'total'
|
|
20
|
+
/** Tenant override; defaults to the active tenant (getActiveTenantId). */
|
|
21
|
+
tenantId?: string
|
|
22
|
+
/** Skip the short-lived cache and hit the DB (used by fresh guard checks). */
|
|
23
|
+
fresh?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TTL_MS = 15_000
|
|
27
|
+
|
|
28
|
+
interface CacheEntry {
|
|
29
|
+
value: number
|
|
30
|
+
expires: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cache = new Map<string, CacheEntry>()
|
|
34
|
+
|
|
35
|
+
function cacheKey(table: string, tenantId: string, kind: string | undefined, period: string): string {
|
|
36
|
+
return `${tenantId}::${table}::${kind ?? ''}::${period}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function startOfMonthISO(): string {
|
|
40
|
+
const now = new Date()
|
|
41
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long a usage count may take before we give up on it. Failing OPEN on an
|
|
46
|
+
* error was always the policy (see below); a request that never settles is the
|
|
47
|
+
* same failure with worse symptoms — a caller awaiting this (the create guards)
|
|
48
|
+
* hangs forever, which reads to the user as a dead button. Observed on a cold
|
|
49
|
+
* boot, where the query can be issued before the auth session is ready.
|
|
50
|
+
*/
|
|
51
|
+
const COUNT_TIMEOUT_MS = 5_000
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Count rows in `table` for the given (or active) tenant.
|
|
55
|
+
* Returns 0 when no Supabase client is registered (mock mode).
|
|
56
|
+
*/
|
|
57
|
+
export async function countByTenant(table: string, options: CountByTenantOptions = {}): Promise<number> {
|
|
58
|
+
const tenantId = options.tenantId ?? getActiveTenantId()
|
|
59
|
+
if (!tenantId) return 0
|
|
60
|
+
|
|
61
|
+
const period = options.period ?? 'total'
|
|
62
|
+
const key = cacheKey(table, tenantId, options.kind, period)
|
|
63
|
+
|
|
64
|
+
if (!options.fresh) {
|
|
65
|
+
const hit = cache.get(key)
|
|
66
|
+
if (hit && hit.expires > Date.now()) return hit.value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const client = getSupabaseClientOptional() as {
|
|
70
|
+
from: (t: string) => {
|
|
71
|
+
select: (columns: string, opts: { count: 'exact'; head: true }) => {
|
|
72
|
+
eq: (col: string, val: unknown) => unknown
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} | null
|
|
76
|
+
|
|
77
|
+
// No client → mock/offline mode: never block.
|
|
78
|
+
if (!client) return 0
|
|
79
|
+
|
|
80
|
+
let query = client.from(table).select('*', { count: 'exact', head: true }).eq('tenant_id', tenantId) as {
|
|
81
|
+
eq: (col: string, val: unknown) => unknown
|
|
82
|
+
gte: (col: string, val: unknown) => unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (options.kind) {
|
|
86
|
+
query = query.eq('kind', options.kind) as typeof query
|
|
87
|
+
}
|
|
88
|
+
if (period === 'month') {
|
|
89
|
+
query = query.gte('created_at', startOfMonthISO()) as typeof query
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On error OR timeout, fail OPEN (return 0) — a counting failure must never
|
|
93
|
+
// wall a tenant out of their own product, and must never hang the UI awaiting
|
|
94
|
+
// it. Enforcement's source of truth is the DB anyway.
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
96
|
+
const timeout = new Promise<{ count: null; error: unknown; timedOut: true }>((resolve) => {
|
|
97
|
+
timer = setTimeout(() => resolve({ count: null, error: 'timeout', timedOut: true }), COUNT_TIMEOUT_MS)
|
|
98
|
+
})
|
|
99
|
+
const settled = await Promise.race([
|
|
100
|
+
query as unknown as Promise<{ count: number | null; error: unknown }>,
|
|
101
|
+
timeout,
|
|
102
|
+
])
|
|
103
|
+
if (timer) clearTimeout(timer)
|
|
104
|
+
|
|
105
|
+
const { count, error } = settled
|
|
106
|
+
const value = error ? 0 : count ?? 0
|
|
107
|
+
|
|
108
|
+
// A timed-out count is not a fact about the tenant — caching it would make one
|
|
109
|
+
// slow request suppress the guard for the whole TTL.
|
|
110
|
+
if (!(settled as { timedOut?: true }).timedOut) {
|
|
111
|
+
cache.set(key, { value, expires: Date.now() + TTL_MS })
|
|
112
|
+
}
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Drop cached counts. Called by create handlers (via invalidateLimit) so the
|
|
118
|
+
* next read reflects the row they just added.
|
|
119
|
+
*
|
|
120
|
+
* @param tenantOrKey Substring matched against cache keys (a tenant id, a table
|
|
121
|
+
* name, or any fragment). Omit to clear the entire cache.
|
|
122
|
+
*/
|
|
123
|
+
export function invalidateCount(tenantOrKey?: string): void {
|
|
124
|
+
if (!tenantOrKey) {
|
|
125
|
+
cache.clear()
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
for (const key of cache.keys()) {
|
|
129
|
+
if (key.includes(tenantOrKey)) cache.delete(key)
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/data/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { createSupabaseProvider, setGlobalSupabaseClient, getSupabaseClientOptional } from './supabase'
|
|
2
|
+
export { countByTenant, invalidateCount } from './count'
|
|
3
|
+
export type { CountByTenantOptions } from './count'
|
|
2
4
|
export { getFayzCloudClient, FAYZ_CLOUD_URL, FAYZ_CLOUD_PUBLISHABLE_KEY } from './cloud'
|
|
3
5
|
export { createFayzApiProvider } from './platform-api'
|
|
4
6
|
export { createMockProvider } from './mock'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { eventBus } from '../events'
|
|
3
|
+
import { globalCache } from '../lib/cache'
|
|
4
|
+
import { getActiveTenantId } from '../tenant'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Data refresh bus — "something wrote; whoever is showing it should reload".
|
|
8
|
+
//
|
|
9
|
+
// A write that happens OUTSIDE the list showing it (the assistant creating an
|
|
10
|
+
// appointment, a webhook, another tab) used to leave the screen stale until the
|
|
11
|
+
// user reloaded by hand. Providers already invalidate their own read cache on
|
|
12
|
+
// create/update/remove, but a mounted list holds its rows in a store — nothing
|
|
13
|
+
// told it to fetch again.
|
|
14
|
+
//
|
|
15
|
+
// This is the missing half: one broadcast, every surface that renders the
|
|
16
|
+
// affected data listens. Deliberately coarse — a table name, not a row diff.
|
|
17
|
+
// Refetching a page of rows is cheap; a stale screen after "criei o
|
|
18
|
+
// agendamento" is not.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export const DATA_CHANGED_EVENT = 'data:changed'
|
|
22
|
+
|
|
23
|
+
export interface DataChangedPayload {
|
|
24
|
+
/** Physical table the write touched, when the writer knows it. */
|
|
25
|
+
table?: string
|
|
26
|
+
/** Registered entity key (deriveEntityKey), when the writer knows it. */
|
|
27
|
+
entityKey?: string
|
|
28
|
+
/** Archetype the record belongs to (`person:client`), when known. */
|
|
29
|
+
archetype?: string
|
|
30
|
+
op?: 'create' | 'update' | 'delete' | 'unknown'
|
|
31
|
+
/** Affected record id, when known. */
|
|
32
|
+
id?: string
|
|
33
|
+
/** What caused the write — lets a surface ignore its own UI writes. */
|
|
34
|
+
source?: 'agent' | 'ui' | 'remote'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a subscriber renders. Any field left out matches everything. */
|
|
38
|
+
export interface DataChangedMatch {
|
|
39
|
+
table?: string
|
|
40
|
+
entityKey?: string
|
|
41
|
+
archetype?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A write with NO table/entityKey is a write we could not attribute (a plugin
|
|
46
|
+
* tool, an RPC). Subscribers still refresh for it: a redundant refetch costs a
|
|
47
|
+
* request, a missed one costs the user's trust in what the screen shows.
|
|
48
|
+
*/
|
|
49
|
+
export function matchesDataChange(match: DataChangedMatch, payload: DataChangedPayload): boolean {
|
|
50
|
+
const attributed = payload.table ?? payload.entityKey ?? payload.archetype
|
|
51
|
+
if (!attributed) return true
|
|
52
|
+
if (match.table && payload.table && match.table === payload.table) return true
|
|
53
|
+
if (match.entityKey && payload.entityKey && match.entityKey === payload.entityKey) return true
|
|
54
|
+
if (match.archetype && payload.archetype && match.archetype === payload.archetype) return true
|
|
55
|
+
// A subscriber that declared nothing listens to everything.
|
|
56
|
+
return !match.table && !match.entityKey && !match.archetype
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One agent turn can write twice (create + link). Coalesce so the lists behind
|
|
60
|
+
// it fetch once, after the turn settles, instead of once per call.
|
|
61
|
+
let pending: DataChangedPayload[] = []
|
|
62
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
63
|
+
|
|
64
|
+
function flush(): void {
|
|
65
|
+
const batch = pending
|
|
66
|
+
pending = []
|
|
67
|
+
flushTimer = null
|
|
68
|
+
for (const payload of batch) eventBus.emit(DATA_CHANGED_EVENT, payload)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Announce a write. Invalidates the provider read cache for the affected table
|
|
73
|
+
* (an unattributed write clears it wholesale — a stale hit would defeat the
|
|
74
|
+
* refetch it just triggered) and broadcasts to mounted surfaces.
|
|
75
|
+
*/
|
|
76
|
+
export function emitDataChanged(payload: DataChangedPayload = {}): void {
|
|
77
|
+
if (payload.table) {
|
|
78
|
+
globalCache.invalidate(`${getActiveTenantId() ?? '_'}:${payload.table}`)
|
|
79
|
+
} else {
|
|
80
|
+
globalCache.clear()
|
|
81
|
+
}
|
|
82
|
+
pending.push({ op: 'unknown', source: 'agent', ...payload })
|
|
83
|
+
if (flushTimer) return
|
|
84
|
+
flushTimer = setTimeout(flush, 120)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Subscribe for the lifetime of the component. The latest handler always runs. */
|
|
88
|
+
export function useDataChanged(
|
|
89
|
+
match: DataChangedMatch,
|
|
90
|
+
handler: (payload: DataChangedPayload) => void,
|
|
91
|
+
deps: React.DependencyList = [],
|
|
92
|
+
): void {
|
|
93
|
+
const handlerRef = React.useRef(handler)
|
|
94
|
+
handlerRef.current = handler
|
|
95
|
+
const { table, entityKey, archetype } = match
|
|
96
|
+
|
|
97
|
+
React.useEffect(
|
|
98
|
+
() =>
|
|
99
|
+
eventBus.on<DataChangedPayload>(DATA_CHANGED_EVENT, (payload) => {
|
|
100
|
+
if (matchesDataChange({ table, entityKey, archetype }, payload)) handlerRef.current(payload)
|
|
101
|
+
}),
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
[table, entityKey, archetype, ...deps],
|
|
104
|
+
)
|
|
105
|
+
}
|
package/src/data/resolve.ts
CHANGED
|
@@ -66,8 +66,14 @@ export function resolveDataProvider<T extends { id: string }>(
|
|
|
66
66
|
|
|
67
67
|
if (client && entityDef.data?.table) {
|
|
68
68
|
const tenantId = () => getActiveTenantId()
|
|
69
|
+
// Archetype entities share a physical table (e.g. people) across kinds
|
|
70
|
+
// (student/teacher/staff…). The cache key must carry the kind or the first
|
|
71
|
+
// kind's resultset is served to every sibling entity (teachers page
|
|
72
|
+
// listing students).
|
|
69
73
|
const cacheOptions = {
|
|
70
|
-
table: entityDef.data.
|
|
74
|
+
table: entityDef.data.archetypeKind
|
|
75
|
+
? `${entityDef.data.table}#${entityDef.data.archetypeKind}`
|
|
76
|
+
: entityDef.data.table,
|
|
71
77
|
tenantId,
|
|
72
78
|
ttl: entityDef.data.cacheTTL,
|
|
73
79
|
}
|
package/src/data/supabase.ts
CHANGED
|
@@ -82,8 +82,10 @@ export function createSupabaseProvider<T extends { id: string }>(
|
|
|
82
82
|
|
|
83
83
|
return {
|
|
84
84
|
async list(query: CrudQuery): Promise<CrudResult<T>> {
|
|
85
|
+
// 'none' skips PostgREST's count aggregate entirely — see CrudQuery.countMode.
|
|
86
|
+
const wantCount = query.countMode !== 'none'
|
|
85
87
|
let q = (getClient().from(table) as { select: (...args: unknown[]) => unknown })
|
|
86
|
-
.select(selectCols, { count: 'exact' }) as Record<string, unknown>
|
|
88
|
+
.select(selectCols, wantCount ? { count: 'exact' } : undefined) as Record<string, unknown>
|
|
87
89
|
|
|
88
90
|
const tenantId = resolveTenantId()
|
|
89
91
|
if (tenantId) q = (q as { eq: (col: string, val: string) => unknown }).eq(tenantIdCol, tenantId) as Record<string, unknown>
|
package/src/data/types.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface CrudQuery {
|
|
|
5
5
|
page?: number
|
|
6
6
|
pageSize?: number
|
|
7
7
|
filters?: Record<string, unknown>
|
|
8
|
+
/**
|
|
9
|
+
* Default 'exact' — a CRUD pager needs the total. It is a second aggregate
|
|
10
|
+
* over the filtered set, and on the live pool cost ~1070ms against ~270ms for
|
|
11
|
+
* the rows themselves. Callers that only want rows pass 'none' (total: 0).
|
|
12
|
+
*/
|
|
13
|
+
countMode?: 'exact' | 'none'
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
export interface CrudResult<T> {
|
package/src/entity/registry.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface RegisteredEntity {
|
|
|
8
8
|
icon?: string
|
|
9
9
|
fields: EntityDef['fields']
|
|
10
10
|
source: 'app' | 'plugin'
|
|
11
|
+
/**
|
|
12
|
+
* The definition itself, so consumers that need to *read* the entity — the AI
|
|
13
|
+
* tool executor resolving a data provider — are not limited to the display
|
|
14
|
+
* metadata. Optional: registrations made before this existed omit it.
|
|
15
|
+
*/
|
|
16
|
+
entityDef?: EntityDef
|
|
17
|
+
mockData?: Array<{ id: string }>
|
|
11
18
|
archetype?: EntityArchetype
|
|
12
19
|
pluginId?: string
|
|
13
20
|
pluginName?: string
|