@lucashw68/nsdb 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/GET_STARTED.md +709 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/cli/index.js +83 -0
- package/helpers/args.js +22 -0
- package/helpers/config.js +142 -0
- package/helpers/generated.js +48 -0
- package/helpers/io.js +39 -0
- package/helpers/metadata.js +19 -0
- package/helpers/names.js +16 -0
- package/helpers/relations.js +101 -0
- package/helpers/shell.js +15 -0
- package/helpers/tables.js +79 -0
- package/helpers/ts.js +37 -0
- package/module.ts +151 -0
- package/nsdb.config.example.mjs +39 -0
- package/nsdb.config.example.ts +42 -0
- package/package.json +114 -0
- package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
- package/runtime/components/NsdbForm.vue +865 -0
- package/runtime/components/NsdbList.vue +961 -0
- package/runtime/composables/useNsdbProfile.ts +119 -0
- package/runtime/composables/useNsdbSchemas.ts +176 -0
- package/runtime/composables/useSupabaseApi.ts +177 -0
- package/runtime/composables/useSupabaseApiStorage.ts +337 -0
- package/runtime/composables/useSupabaseModels.ts +412 -0
- package/runtime/query.ts +126 -0
- package/runtime/stores/createDbStore.ts +439 -0
- package/runtime/stores/createSingletonDbStore.ts +67 -0
- package/runtime/utils/dataFreshness.ts +47 -0
- package/runtime/utils/storage.ts +41 -0
- package/scripts/clear.js +64 -0
- package/scripts/generate-composables.js +100 -0
- package/scripts/generate-enums.js +106 -0
- package/scripts/generate-metadata.js +165 -0
- package/scripts/generate-models.js +164 -0
- package/scripts/generate-schemas.js +443 -0
- package/scripts/generate-stores.js +90 -0
- package/scripts/generate-types.js +196 -0
- package/scripts/init.js +225 -0
- package/templates/model.template.ts +48 -0
- package/templates/schema.template.ts +13 -0
- package/templates/useNsdbModel.template.ts +9 -0
- package/types/config.ts +50 -0
- package/types/entities.ts +66 -0
- package/types/index.ts +14 -0
- package/types/list.ts +78 -0
- package/types/model.ts +57 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { useSupabaseApi } from './useSupabaseApi'
|
|
2
|
+
import { useSupabaseClient, useSupabaseUser } from '#imports'
|
|
3
|
+
import type { ModelHandle, ModelMutationTarget, ModelQuery } from '@lucashw68/nsdb/types/model'
|
|
4
|
+
import type { OrderDirection } from '@lucashw68/nsdb/types/list'
|
|
5
|
+
import type { Ref } from 'vue'
|
|
6
|
+
import { computed, isRef, ref, watch } from 'vue'
|
|
7
|
+
import { isComplexCollectionQuery, sortCollection } from '../utils/dataFreshness'
|
|
8
|
+
|
|
9
|
+
type MutationPayload = Record<string, unknown> | Record<string, unknown>[]
|
|
10
|
+
|
|
11
|
+
export interface StoreLike<T, TInsert = Partial<T>, TUpdate = Partial<T>> {
|
|
12
|
+
items: Ref<T[]> | T[]
|
|
13
|
+
totalCount?: Ref<number | null> | number | null
|
|
14
|
+
loading?: Ref<boolean> | boolean
|
|
15
|
+
error?: Ref<unknown> | unknown
|
|
16
|
+
stale?: Ref<boolean> | boolean
|
|
17
|
+
getById: (id: string | number) => T | null
|
|
18
|
+
create: (payload: TInsert) => Promise<T | null>
|
|
19
|
+
update: (id: string | number, payload: TUpdate) => Promise<T | null>
|
|
20
|
+
remove: (id: string | number) => void | Promise<void>
|
|
21
|
+
fetchFromSupabase: (query?: any) => Promise<T[]>
|
|
22
|
+
subscribe?: () => void
|
|
23
|
+
unsubscribe?: () => void | Promise<void>
|
|
24
|
+
refresh?: (query?: any) => Promise<T[]>
|
|
25
|
+
invalidate?: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Options<T, TInsert, TUpdate, TPrimaryKey extends Extract<keyof T, string>> =
|
|
29
|
+
| boolean
|
|
30
|
+
| { store?: boolean; storeCreator?: () => StoreLike<T, TInsert, TUpdate>; primaryKey?: TPrimaryKey }
|
|
31
|
+
|
|
32
|
+
export type { ModelHandle, ModelQuery } from '@lucashw68/nsdb/types/model'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Résultat normalisé pour l'order.
|
|
36
|
+
*/
|
|
37
|
+
type NormalizedOrder = {
|
|
38
|
+
orderBy: string
|
|
39
|
+
orderDirection: OrderDirection
|
|
40
|
+
orderForeignTable?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeStoreItems<T>(store: { items: Ref<T[]> | T[] }): Ref<T[]> {
|
|
44
|
+
if (isRef(store.items)) return store.items
|
|
45
|
+
|
|
46
|
+
return computed({
|
|
47
|
+
get: () => store.items as T[],
|
|
48
|
+
set: (value) => {
|
|
49
|
+
;(store as any).items = value
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeStoreTotalCount(store: { totalCount?: Ref<number | null> | number | null }): Ref<number | null> {
|
|
55
|
+
if (isRef(store.totalCount)) return store.totalCount
|
|
56
|
+
|
|
57
|
+
return computed({
|
|
58
|
+
get: () => typeof store.totalCount === 'number' ? store.totalCount : null,
|
|
59
|
+
set: (value) => {
|
|
60
|
+
;(store as any).totalCount = value
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeStoreRef<T>(store: Record<string, any>, key: string, fallback: T): Ref<T> {
|
|
66
|
+
if (isRef(store[key])) return store[key] as Ref<T>
|
|
67
|
+
return computed({
|
|
68
|
+
get: () => (store[key] ?? fallback) as T,
|
|
69
|
+
set: value => { store[key] = value },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseOrderPath(path: string): { foreignTable?: string; column: string } {
|
|
74
|
+
if (!path.includes('.')) return { column: path }
|
|
75
|
+
|
|
76
|
+
const parts = path.split('.').filter(Boolean)
|
|
77
|
+
if (parts.length === 2) {
|
|
78
|
+
const foreignTable = parts[0]
|
|
79
|
+
const column = parts[1]
|
|
80
|
+
if (!foreignTable || !column) return { column: path }
|
|
81
|
+
return { foreignTable, column }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// cas non supporté proprement (ex: a.b.c)
|
|
85
|
+
return { column: path }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeOrder(
|
|
89
|
+
rawOrderBy: ModelQuery<string, string>['orderBy'],
|
|
90
|
+
rawOrderDirection?: OrderDirection,
|
|
91
|
+
rawOrderForeignTable?: string,
|
|
92
|
+
defaultOrderBy: string = 'id',
|
|
93
|
+
): NormalizedOrder {
|
|
94
|
+
let orderBy = defaultOrderBy
|
|
95
|
+
let orderDirection: OrderDirection = rawOrderDirection ?? 'asc'
|
|
96
|
+
let orderForeignTable: string | undefined = rawOrderForeignTable
|
|
97
|
+
|
|
98
|
+
if (typeof rawOrderBy === 'string') {
|
|
99
|
+
const parsed = parseOrderPath(rawOrderBy)
|
|
100
|
+
orderBy = parsed.column
|
|
101
|
+
orderForeignTable = parsed.foreignTable ?? rawOrderForeignTable
|
|
102
|
+
return { orderBy, orderDirection, orderForeignTable }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (rawOrderBy && typeof rawOrderBy === 'object') {
|
|
106
|
+
const [rawColumn, direction] = Object.entries(rawOrderBy)[0] as [
|
|
107
|
+
string,
|
|
108
|
+
OrderDirection | undefined
|
|
109
|
+
]
|
|
110
|
+
const parsed = parseOrderPath(rawColumn)
|
|
111
|
+
|
|
112
|
+
orderBy = parsed.column
|
|
113
|
+
orderForeignTable = parsed.foreignTable ?? rawOrderForeignTable
|
|
114
|
+
orderDirection = direction ?? rawOrderDirection ?? 'asc'
|
|
115
|
+
|
|
116
|
+
return { orderBy, orderDirection, orderForeignTable }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { orderBy, orderDirection, orderForeignTable }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Unified CRUD abstraction over a Supabase table.
|
|
124
|
+
*/
|
|
125
|
+
export function useSupabaseModel<
|
|
126
|
+
TRow,
|
|
127
|
+
TInsert = Partial<TRow>,
|
|
128
|
+
TUpdate = Partial<TRow>,
|
|
129
|
+
TPrimaryKey extends Extract<keyof TRow, string> = Extract<keyof TRow, string>,
|
|
130
|
+
>(
|
|
131
|
+
modelName: string,
|
|
132
|
+
opts: Options<TRow, TInsert, TUpdate, TPrimaryKey> = false
|
|
133
|
+
): ModelHandle<TRow, TInsert, TUpdate, TPrimaryKey> {
|
|
134
|
+
const useStore = typeof opts === 'boolean' ? opts : !!opts.store
|
|
135
|
+
const storeCreator =
|
|
136
|
+
typeof opts === 'object' && opts.store ? opts.storeCreator : undefined
|
|
137
|
+
const primaryKey = typeof opts === 'object' ? opts.primaryKey ?? 'id' : 'id'
|
|
138
|
+
type RowQuery = ModelQuery<string, Extract<keyof TRow, string>>
|
|
139
|
+
type MutationTarget = ModelMutationTarget<TRow, TPrimaryKey>
|
|
140
|
+
|
|
141
|
+
const resolveMutationTarget = (target: MutationTarget, operation: 'update' | 'remove'): string | number => {
|
|
142
|
+
const value = typeof target === 'object' && target !== null
|
|
143
|
+
? (target as Record<string, unknown>)[primaryKey]
|
|
144
|
+
: target
|
|
145
|
+
|
|
146
|
+
if (value === null || value === undefined) {
|
|
147
|
+
throw new Error(`Cannot ${operation} "${modelName}": target is missing primary key "${primaryKey}".`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return value as string | number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ############################################################
|
|
154
|
+
// # STORE MODE (Pinia / offline)
|
|
155
|
+
// ############################################################
|
|
156
|
+
if (useStore) {
|
|
157
|
+
if (!storeCreator) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`❌ useSupabaseModel("${modelName}", { store: true }) requires a storeCreator`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const store = storeCreator()
|
|
164
|
+
const noOp = () => {}
|
|
165
|
+
const storeItems = normalizeStoreItems(store)
|
|
166
|
+
const totalCount = normalizeStoreTotalCount(store)
|
|
167
|
+
const loading = normalizeStoreRef<boolean>(store as any, 'loading', false)
|
|
168
|
+
const error = normalizeStoreRef<unknown>(store as any, 'error', null)
|
|
169
|
+
const stale = normalizeStoreRef<boolean>(store as any, 'stale', true)
|
|
170
|
+
|
|
171
|
+
const getById = async (id: string | number) =>
|
|
172
|
+
(store.getById(id) as TRow | null) ?? null
|
|
173
|
+
const create = async (payload: TInsert): Promise<TRow> => {
|
|
174
|
+
const created = await store.create(payload)
|
|
175
|
+
if (created === null || created === undefined) {
|
|
176
|
+
throw new Error(`Cannot create "${modelName}": Supabase returned no row.`)
|
|
177
|
+
}
|
|
178
|
+
return created
|
|
179
|
+
}
|
|
180
|
+
const update = (target: MutationTarget, payload: TUpdate) =>
|
|
181
|
+
store.update(resolveMutationTarget(target, 'update'), payload)
|
|
182
|
+
const remove = async (target: MutationTarget) => {
|
|
183
|
+
await store.remove(resolveMutationTarget(target, 'remove'))
|
|
184
|
+
}
|
|
185
|
+
const fetch = async (query?: RowQuery) =>
|
|
186
|
+
(await store.fetchFromSupabase(query)) as TRow[]
|
|
187
|
+
const refresh = async (query?: RowQuery) => store.refresh
|
|
188
|
+
? await store.refresh(query) as TRow[]
|
|
189
|
+
: await store.fetchFromSupabase(query) as TRow[]
|
|
190
|
+
const invalidate = store.invalidate ?? noOp
|
|
191
|
+
const subscribe = store.subscribe ?? noOp
|
|
192
|
+
const unsubscribe = store.unsubscribe ?? noOp
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
items: storeItems,
|
|
196
|
+
totalCount,
|
|
197
|
+
loading,
|
|
198
|
+
error,
|
|
199
|
+
stale,
|
|
200
|
+
getById,
|
|
201
|
+
create,
|
|
202
|
+
update,
|
|
203
|
+
remove,
|
|
204
|
+
fetch,
|
|
205
|
+
refresh,
|
|
206
|
+
invalidate,
|
|
207
|
+
subscribe,
|
|
208
|
+
unsubscribe,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ############################################################
|
|
213
|
+
// # API MODE (stateless)
|
|
214
|
+
// ############################################################
|
|
215
|
+
const api = useSupabaseApi()
|
|
216
|
+
const supabase = useSupabaseClient()
|
|
217
|
+
const supabaseUser = useSupabaseUser()
|
|
218
|
+
const items = ref<TRow[]>([])
|
|
219
|
+
const typedItems = items as unknown as Ref<TRow[]>
|
|
220
|
+
const totalCount = ref<number | null>(null)
|
|
221
|
+
const loading = ref(false)
|
|
222
|
+
const error = ref<unknown>(null)
|
|
223
|
+
const stale = ref(true)
|
|
224
|
+
let fetchSequence = 0
|
|
225
|
+
let collectionRevision = 0
|
|
226
|
+
let currentQuery: RowQuery = {}
|
|
227
|
+
let subscription: ReturnType<typeof supabase.channel> | null = null
|
|
228
|
+
let subscriptionOwnerId: string | null = null
|
|
229
|
+
const normalizedCurrentQuery = () => {
|
|
230
|
+
const order = normalizeOrder(currentQuery.orderBy, currentQuery.orderDirection, currentQuery.orderForeignTable, primaryKey)
|
|
231
|
+
return { ...currentQuery, ...order }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const itemKey = (item: TRow) => (item as Record<string, unknown>)[primaryKey]
|
|
235
|
+
const addOrUpdate = (item: TRow) => {
|
|
236
|
+
const id = itemKey(item)
|
|
237
|
+
const index = typedItems.value.findIndex(candidate => itemKey(candidate) === id)
|
|
238
|
+
if (index < 0) typedItems.value = [item, ...typedItems.value]
|
|
239
|
+
else {
|
|
240
|
+
const next = [...typedItems.value]
|
|
241
|
+
next[index] = item
|
|
242
|
+
typedItems.value = next
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const invalidate = () => {
|
|
246
|
+
collectionRevision++
|
|
247
|
+
stale.value = true
|
|
248
|
+
}
|
|
249
|
+
const mutationSucceeded = () => {
|
|
250
|
+
invalidate()
|
|
251
|
+
if (isComplexCollectionQuery(normalizedCurrentQuery())) totalCount.value = null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const getById = async (id: string | number, select: string = '*') => {
|
|
255
|
+
const response = await api.getById<TRow>(modelName, id, { key: primaryKey, select })
|
|
256
|
+
if (!response.success) throw response.error
|
|
257
|
+
return (response.data ?? null) as TRow | null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const create = async (payload: TInsert) => {
|
|
261
|
+
const response = await api.create<TRow>(modelName, payload as MutationPayload)
|
|
262
|
+
if (!response.success) throw response.error
|
|
263
|
+
const created = (response.data ?? null) as TRow | null
|
|
264
|
+
if (created === null || created === undefined) {
|
|
265
|
+
throw new Error(`Cannot create "${modelName}": Supabase returned no row.`)
|
|
266
|
+
}
|
|
267
|
+
mutationSucceeded()
|
|
268
|
+
const activeQuery = normalizedCurrentQuery()
|
|
269
|
+
if (!isComplexCollectionQuery(activeQuery)) {
|
|
270
|
+
addOrUpdate(created)
|
|
271
|
+
typedItems.value = sortCollection(typedItems.value as Record<string, any>[], activeQuery).slice(0, activeQuery.limit ?? 100) as TRow[]
|
|
272
|
+
if (totalCount.value != null) totalCount.value++
|
|
273
|
+
}
|
|
274
|
+
return created
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const update = async (target: MutationTarget, payload: TUpdate) => {
|
|
278
|
+
const id = resolveMutationTarget(target, 'update')
|
|
279
|
+
const response = await api.update<TRow>(modelName, id, payload as MutationPayload, { key: primaryKey })
|
|
280
|
+
if (!response.success) throw response.error
|
|
281
|
+
const updated = (Array.isArray(response.data) ? response.data[0] : response.data) as TRow | null
|
|
282
|
+
if (updated) {
|
|
283
|
+
mutationSucceeded()
|
|
284
|
+
const activeQuery = normalizedCurrentQuery()
|
|
285
|
+
if (!isComplexCollectionQuery(activeQuery)) {
|
|
286
|
+
addOrUpdate(updated)
|
|
287
|
+
typedItems.value = sortCollection(typedItems.value as Record<string, any>[], activeQuery) as TRow[]
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return updated ?? null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const remove = async (target: MutationTarget) => {
|
|
294
|
+
const id = resolveMutationTarget(target, 'remove')
|
|
295
|
+
const response = await api.remove(modelName, id, { key: primaryKey })
|
|
296
|
+
if (!response.success) throw response.error
|
|
297
|
+
const existed = typedItems.value.some(item => itemKey(item) === id)
|
|
298
|
+
mutationSucceeded()
|
|
299
|
+
typedItems.value = typedItems.value.filter(item => itemKey(item) !== id)
|
|
300
|
+
if (!isComplexCollectionQuery(normalizedCurrentQuery()) && existed && totalCount.value != null) totalCount.value--
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const fetch = async (query: RowQuery = {}) => {
|
|
304
|
+
const requestId = ++fetchSequence
|
|
305
|
+
const startingRevision = collectionRevision
|
|
306
|
+
currentQuery = query
|
|
307
|
+
loading.value = true
|
|
308
|
+
error.value = null
|
|
309
|
+
const {
|
|
310
|
+
select = '*',
|
|
311
|
+
where,
|
|
312
|
+
limit = 100,
|
|
313
|
+
offset = 0,
|
|
314
|
+
search,
|
|
315
|
+
searchColumns,
|
|
316
|
+
} = query
|
|
317
|
+
|
|
318
|
+
const { orderBy, orderDirection, orderForeignTable } = normalizeOrder(
|
|
319
|
+
query.orderBy,
|
|
320
|
+
query.orderDirection,
|
|
321
|
+
query.orderForeignTable,
|
|
322
|
+
primaryKey,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
let data: TRow[] = []
|
|
326
|
+
let count: number | null = null
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const response = await api.all<TRow>(modelName, {
|
|
330
|
+
select,
|
|
331
|
+
where,
|
|
332
|
+
orderBy,
|
|
333
|
+
orderDirection,
|
|
334
|
+
orderForeignTable,
|
|
335
|
+
limit,
|
|
336
|
+
offset,
|
|
337
|
+
search,
|
|
338
|
+
searchColumns,
|
|
339
|
+
})
|
|
340
|
+
if (!response.success) throw response.error
|
|
341
|
+
|
|
342
|
+
data = (response.data ?? []) as TRow[]
|
|
343
|
+
count = response.count ?? null
|
|
344
|
+
|
|
345
|
+
// Latest request wins: rapid search/filter changes must not let an older
|
|
346
|
+
// response overwrite the state already produced by a newer query.
|
|
347
|
+
if (requestId === fetchSequence && startingRevision === collectionRevision) {
|
|
348
|
+
typedItems.value = Array.isArray(data) ? data : []
|
|
349
|
+
totalCount.value = count ?? null
|
|
350
|
+
stale.value = false
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (Array.isArray(data) ? data : []) as TRow[]
|
|
354
|
+
} catch (fetchError) {
|
|
355
|
+
error.value = fetchError
|
|
356
|
+
stale.value = true
|
|
357
|
+
throw fetchError
|
|
358
|
+
} finally {
|
|
359
|
+
if (requestId === fetchSequence) loading.value = false
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const refresh = (query: RowQuery = currentQuery) => fetch(query)
|
|
364
|
+
|
|
365
|
+
const subscribe = () => {
|
|
366
|
+
if (typeof window === 'undefined' || subscription) return
|
|
367
|
+
subscriptionOwnerId = supabaseUser.value?.id ?? null
|
|
368
|
+
subscription = supabase.channel(`public:${modelName}`)
|
|
369
|
+
.on('postgres_changes', { event: '*', schema: 'public', table: modelName }, (payload: any) => {
|
|
370
|
+
if (subscriptionOwnerId !== (supabaseUser.value?.id ?? null)) return
|
|
371
|
+
invalidate()
|
|
372
|
+
if (isComplexCollectionQuery(normalizedCurrentQuery())) return
|
|
373
|
+
if (payload.eventType === 'DELETE') typedItems.value = typedItems.value.filter(item => itemKey(item) !== payload.old?.[primaryKey])
|
|
374
|
+
else if (payload.new) {
|
|
375
|
+
addOrUpdate(payload.new as TRow)
|
|
376
|
+
typedItems.value = sortCollection(typedItems.value as Record<string, any>[], normalizedCurrentQuery()) as TRow[]
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
.subscribe()
|
|
380
|
+
}
|
|
381
|
+
const unsubscribe = async () => {
|
|
382
|
+
const active = subscription
|
|
383
|
+
subscription = null
|
|
384
|
+
subscriptionOwnerId = null
|
|
385
|
+
if (!active) return
|
|
386
|
+
if (typeof (supabase as any).removeChannel === 'function') await (supabase as any).removeChannel(active)
|
|
387
|
+
else await active.unsubscribe()
|
|
388
|
+
}
|
|
389
|
+
watch(() => supabaseUser.value?.id ?? null, () => {
|
|
390
|
+
void unsubscribe()
|
|
391
|
+
typedItems.value = []
|
|
392
|
+
totalCount.value = null
|
|
393
|
+
invalidate()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
items: typedItems,
|
|
398
|
+
totalCount,
|
|
399
|
+
loading,
|
|
400
|
+
error,
|
|
401
|
+
stale,
|
|
402
|
+
getById,
|
|
403
|
+
create,
|
|
404
|
+
update,
|
|
405
|
+
remove,
|
|
406
|
+
fetch,
|
|
407
|
+
refresh,
|
|
408
|
+
invalidate,
|
|
409
|
+
subscribe,
|
|
410
|
+
unsubscribe,
|
|
411
|
+
}
|
|
412
|
+
}
|
package/runtime/query.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ListOptions, WhereClause, WhereOperator } from '../types/list'
|
|
2
|
+
|
|
3
|
+
export type QueryBuilderLike = {
|
|
4
|
+
[key: string]: unknown
|
|
5
|
+
or: (expression: string) => QueryBuilderLike
|
|
6
|
+
order: (column: string, options: Record<string, unknown>) => QueryBuilderLike
|
|
7
|
+
range: (from: number, to: number) => QueryBuilderLike
|
|
8
|
+
eq: (column: string, value: unknown) => QueryBuilderLike
|
|
9
|
+
neq: (column: string, value: unknown) => QueryBuilderLike
|
|
10
|
+
gt: (column: string, value: unknown) => QueryBuilderLike
|
|
11
|
+
gte: (column: string, value: unknown) => QueryBuilderLike
|
|
12
|
+
lt: (column: string, value: unknown) => QueryBuilderLike
|
|
13
|
+
lte: (column: string, value: unknown) => QueryBuilderLike
|
|
14
|
+
ilike: (column: string, value: unknown) => QueryBuilderLike
|
|
15
|
+
in: (column: string, value: readonly unknown[]) => QueryBuilderLike
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function escapePostgrestSearchTerm(value: string) {
|
|
19
|
+
return value
|
|
20
|
+
.replace(/[\\%_]/g, match => `\\${match}`)
|
|
21
|
+
.replace(/[(),]/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function applySearch<T extends QueryBuilderLike>(query: T, options: ListOptions): T {
|
|
26
|
+
const search = options.search?.trim()
|
|
27
|
+
if (!search) return query
|
|
28
|
+
|
|
29
|
+
const columns = [...new Set((options.searchColumns ?? []).map(column => column.trim()).filter(Boolean))]
|
|
30
|
+
if (columns.length === 0) return query
|
|
31
|
+
|
|
32
|
+
const term = escapePostgrestSearchTerm(search)
|
|
33
|
+
if (!term) return query
|
|
34
|
+
|
|
35
|
+
const expression = columns.map(column => `${column}.ilike.%${term}%`).join(',')
|
|
36
|
+
return query.or(expression) as T
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizePagination(options: Pick<ListOptions, 'limit' | 'offset'>) {
|
|
40
|
+
const limit = options.limit ?? 100
|
|
41
|
+
const offset = options.offset ?? 0
|
|
42
|
+
|
|
43
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
44
|
+
throw new RangeError('[nsdb] limit must be a positive safe integer.')
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
47
|
+
throw new RangeError('[nsdb] offset must be a non-negative safe integer.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { limit, offset }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyListOptions<T extends QueryBuilderLike>(query: T, options: ListOptions): T {
|
|
54
|
+
const orderBy = options.orderBy
|
|
55
|
+
const orderDirection = options.orderDirection ?? 'asc'
|
|
56
|
+
const { limit, offset } = normalizePagination(options)
|
|
57
|
+
let finalQuery: QueryBuilderLike = query
|
|
58
|
+
|
|
59
|
+
if (orderBy && options.orderForeignTable) {
|
|
60
|
+
finalQuery = finalQuery.order(orderBy, {
|
|
61
|
+
ascending: orderDirection === 'asc',
|
|
62
|
+
referencedTable: options.orderForeignTable,
|
|
63
|
+
})
|
|
64
|
+
} else if (orderBy) {
|
|
65
|
+
finalQuery = finalQuery.order(orderBy, { ascending: orderDirection === 'asc' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return finalQuery.range(offset, offset + limit - 1) as T
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applySingleFilter<T extends QueryBuilderLike>(
|
|
72
|
+
query: T,
|
|
73
|
+
column: string,
|
|
74
|
+
filter: WhereOperator,
|
|
75
|
+
): T {
|
|
76
|
+
switch (filter.op) {
|
|
77
|
+
case 'eq': return query.eq(column, filter.value) as T
|
|
78
|
+
case 'neq': return query.neq(column, filter.value) as T
|
|
79
|
+
case 'gt': return query.gt(column, filter.value) as T
|
|
80
|
+
case 'gte': return query.gte(column, filter.value) as T
|
|
81
|
+
case 'lt': return query.lt(column, filter.value) as T
|
|
82
|
+
case 'lte': return query.lte(column, filter.value) as T
|
|
83
|
+
case 'ilike': return query.ilike(column, filter.value) as T
|
|
84
|
+
case 'in': {
|
|
85
|
+
if (!Array.isArray(filter.value)) {
|
|
86
|
+
throw new TypeError(`[nsdb] The "in" filter for "${column}" requires an array value.`)
|
|
87
|
+
}
|
|
88
|
+
return query.in(column, filter.value) as T
|
|
89
|
+
}
|
|
90
|
+
default: throw new TypeError(`[nsdb] Unsupported filter operator for "${column}".`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function applyWhereFilters<T extends QueryBuilderLike>(query: T, where?: WhereClause): T {
|
|
95
|
+
if (!where || Object.keys(where).length === 0) return query
|
|
96
|
+
|
|
97
|
+
let finalQuery: QueryBuilderLike = query
|
|
98
|
+
for (const [column, rawValue] of Object.entries(where)) {
|
|
99
|
+
if (Array.isArray(rawValue)) {
|
|
100
|
+
const containsOperators = rawValue.some(
|
|
101
|
+
part => part != null && typeof part === 'object' && 'op' in part,
|
|
102
|
+
)
|
|
103
|
+
if (!containsOperators) {
|
|
104
|
+
finalQuery = finalQuery.in(column, rawValue)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const part of rawValue) {
|
|
109
|
+
if (!part || typeof part !== 'object' || !('op' in part)) {
|
|
110
|
+
throw new TypeError(`[nsdb] Filter array for "${column}" cannot mix values and operators.`)
|
|
111
|
+
}
|
|
112
|
+
finalQuery = applySingleFilter(finalQuery, column, part as WhereOperator)
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (rawValue && typeof rawValue === 'object' && 'op' in rawValue) {
|
|
118
|
+
finalQuery = applySingleFilter(finalQuery, column, rawValue as WhereOperator)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
finalQuery = finalQuery.eq(column, rawValue)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return finalQuery as T
|
|
126
|
+
}
|