@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.
Files changed (123) hide show
  1. package/dist/access/denial.d.ts +21 -0
  2. package/dist/access/denial.d.ts.map +1 -0
  3. package/dist/access/engine.d.ts +63 -0
  4. package/dist/access/engine.d.ts.map +1 -0
  5. package/dist/access/index.cjs +60 -0
  6. package/dist/access/index.cjs.map +1 -0
  7. package/dist/access/index.d.ts +6 -0
  8. package/dist/access/index.d.ts.map +1 -0
  9. package/dist/access/index.js +51 -0
  10. package/dist/access/index.js.map +1 -0
  11. package/dist/access/limits.d.ts +10 -0
  12. package/dist/access/limits.d.ts.map +1 -0
  13. package/dist/address/index.d.ts +52 -0
  14. package/dist/address/index.d.ts.map +1 -0
  15. package/dist/{chunk-QR7U2URY.cjs → chunk-CATASHPK.cjs} +345 -4
  16. package/dist/chunk-CATASHPK.cjs.map +1 -0
  17. package/dist/{chunk-WKZVSKEU.js → chunk-CU64QI4A.js} +2 -2
  18. package/dist/chunk-CU64QI4A.js.map +1 -0
  19. package/dist/{chunk-KNIVT6XZ.js → chunk-DZALZ6Q6.js} +345 -4
  20. package/dist/chunk-DZALZ6Q6.js.map +1 -0
  21. package/dist/{chunk-54SRL7AJ.cjs → chunk-KSI2L3E4.cjs} +145 -86
  22. package/dist/chunk-KSI2L3E4.cjs.map +1 -0
  23. package/dist/{chunk-ZBIWYDAR.js → chunk-TYPVSWLG.js} +144 -87
  24. package/dist/chunk-TYPVSWLG.js.map +1 -0
  25. package/dist/{chunk-2NTHNWEH.cjs → chunk-UKU4RIWM.cjs} +2 -2
  26. package/dist/chunk-UKU4RIWM.cjs.map +1 -0
  27. package/dist/{chunk-CHGX6UYR.js → chunk-YH25Y4FW.js} +6 -3
  28. package/dist/chunk-YH25Y4FW.js.map +1 -0
  29. package/dist/{chunk-5X2VX3RQ.cjs → chunk-ZOPJB5FN.cjs} +6 -3
  30. package/dist/chunk-ZOPJB5FN.cjs.map +1 -0
  31. package/dist/data/archetype.d.ts.map +1 -1
  32. package/dist/data/count.d.ts +34 -0
  33. package/dist/data/count.d.ts.map +1 -0
  34. package/dist/data/index.cjs +18 -10
  35. package/dist/data/index.d.ts +2 -0
  36. package/dist/data/index.d.ts.map +1 -1
  37. package/dist/data/index.js +1 -1
  38. package/dist/data/refresh.d.ts +36 -0
  39. package/dist/data/refresh.d.ts.map +1 -0
  40. package/dist/data/resolve.d.ts.map +1 -1
  41. package/dist/data/supabase.d.ts.map +1 -1
  42. package/dist/data/types.d.ts +6 -0
  43. package/dist/data/types.d.ts.map +1 -1
  44. package/dist/entity/index.cjs +6 -6
  45. package/dist/entity/index.js +1 -1
  46. package/dist/entity/registry.d.ts +9 -0
  47. package/dist/entity/registry.d.ts.map +1 -1
  48. package/dist/i18n/index.cjs +10 -10
  49. package/dist/i18n/index.js +1 -1
  50. package/dist/i18n/shell-translations.d.ts.map +1 -1
  51. package/dist/index.cjs +1517 -180
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +15 -5
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1412 -110
  56. package/dist/index.js.map +1 -1
  57. package/dist/manifest/index.d.ts +109 -3
  58. package/dist/manifest/index.d.ts.map +1 -1
  59. package/dist/manifest/serialize.d.ts +14 -0
  60. package/dist/manifest/serialize.d.ts.map +1 -0
  61. package/dist/plugin/index.cjs +7 -7
  62. package/dist/plugin/index.js +1 -1
  63. package/dist/plugin/runtime.d.ts.map +1 -1
  64. package/dist/search/engine.d.ts +12 -0
  65. package/dist/search/engine.d.ts.map +1 -0
  66. package/dist/search/index.d.ts +5 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/text.d.ts +37 -0
  69. package/dist/search/text.d.ts.map +1 -0
  70. package/dist/search/types.d.ts +72 -0
  71. package/dist/search/types.d.ts.map +1 -0
  72. package/dist/types/billing.d.ts +20 -0
  73. package/dist/types/billing.d.ts.map +1 -1
  74. package/dist/types/crud.d.ts +47 -1
  75. package/dist/types/crud.d.ts.map +1 -1
  76. package/dist/types/entitlements.d.ts +45 -0
  77. package/dist/types/entitlements.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +4 -3
  79. package/dist/types/index.d.ts.map +1 -1
  80. package/dist/types/org.d.ts +28 -0
  81. package/dist/types/org.d.ts.map +1 -1
  82. package/dist/types/plugins.d.ts +88 -0
  83. package/dist/types/plugins.d.ts.map +1 -1
  84. package/package.json +14 -4
  85. package/src/access/denial.ts +33 -0
  86. package/src/access/engine.test.ts +48 -0
  87. package/src/access/engine.ts +121 -0
  88. package/src/access/index.ts +13 -0
  89. package/src/access/limits.ts +18 -0
  90. package/src/address/index.ts +188 -0
  91. package/src/data/archetype.ts +4 -2
  92. package/src/data/count.test.ts +152 -0
  93. package/src/data/count.ts +131 -0
  94. package/src/data/index.ts +2 -0
  95. package/src/data/refresh.ts +105 -0
  96. package/src/data/resolve.ts +7 -1
  97. package/src/data/supabase.ts +3 -1
  98. package/src/data/types.ts +6 -0
  99. package/src/entity/registry.ts +7 -0
  100. package/src/i18n/shell-translations.ts +345 -2
  101. package/src/index.ts +37 -4
  102. package/src/manifest/app-manifest.schema.json +618 -45
  103. package/src/manifest/index.ts +127 -3
  104. package/src/manifest/serialize.ts +52 -0
  105. package/src/plugin/runtime.ts +5 -1
  106. package/src/search/engine.ts +551 -0
  107. package/src/search/index.ts +25 -0
  108. package/src/search/text.ts +206 -0
  109. package/src/search/types.ts +75 -0
  110. package/src/types/billing.ts +22 -0
  111. package/src/types/crud.ts +48 -1
  112. package/src/types/entitlements.ts +45 -0
  113. package/src/types/index.ts +4 -3
  114. package/src/types/org.ts +29 -0
  115. package/src/types/plugins.ts +83 -0
  116. package/dist/chunk-2NTHNWEH.cjs.map +0 -1
  117. package/dist/chunk-54SRL7AJ.cjs.map +0 -1
  118. package/dist/chunk-5X2VX3RQ.cjs.map +0 -1
  119. package/dist/chunk-CHGX6UYR.js.map +0 -1
  120. package/dist/chunk-KNIVT6XZ.js.map +0 -1
  121. package/dist/chunk-QR7U2URY.cjs.map +0 -1
  122. package/dist/chunk-WKZVSKEU.js.map +0 -1
  123. 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
+ }
@@ -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('*', { count: 'exact' }).eq('tenant_id', tenantId)
114
- : getClient().from(viewName).select('*', { count: 'exact' }).eq('tenant_id', tenantId)
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
+ }
@@ -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.table,
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
  }
@@ -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> {
@@ -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