@nuxtagentic/data 0.0.0-stage → 0.1.1

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/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ Proprietary software. All rights reserved.
2
+
3
+ Use of Nuxt Agentic packages and starter materials is governed by the applicable
4
+ commercial agreement supplied with a purchase. This repository does not grant
5
+ an open-source license or permission to redistribute the packages as a competing
6
+ template or module library.
7
+
8
+ Third-party dependencies retain their respective licenses. This notice does not
9
+ replace or modify those licenses.
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # Data
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@nuxtagentic/data` provides the shared query and table infrastructure. It depends
4
+ on Identity for the active account and organization scope and has no models or
5
+ collection UI of its own.
6
+
7
+ `useWorkspaceQuery` shares equivalent list requests within a scope.
8
+ `useWorkspaceRecord` loads individual records, and `useWorkspaceRefresh` refreshes
9
+ related lists after mutations. The shared query code validates fields, filters,
10
+ sorting, and pagination against registered model views.
11
+
12
+ `runtime/shared/model-views.ts` defines the model-view contract and
13
+ `defineModelView`. Features own their actual view definitions and publish them
14
+ through their metadata contributions. Data does not import feature schemas or
15
+ the host config.
16
+
17
+ Install it through a dependent feature, or add `@nuxtagentic/data` to Nuxt's
18
+ `modules` when using the query helpers directly. See the [module map](../README.md).
package/module.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { createResolver, defineNuxtModule } from '@nuxt/kit'
2
+ import { registerWorkspaceModule } from '@nuxtagentic/kit'
3
+
4
+ export default defineNuxtModule({
5
+ meta: { name: '@nuxtagentic/data' },
6
+ moduleDependencies: {
7
+ '@nuxtagentic/identity': {},
8
+ },
9
+ setup(_options, nuxt) {
10
+ const resolver = createResolver(import.meta.url)
11
+ registerWorkspaceModule(
12
+ {
13
+ id: 'data',
14
+ runtime: resolver.resolve('./runtime'),
15
+ },
16
+ nuxt,
17
+ )
18
+ },
19
+ })
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@nuxtagentic/data",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "files": [
6
+ "module.ts",
7
+ "runtime",
8
+ "README.md",
9
+ "LICENSE.md"
10
+ ],
11
+ "exports": {
12
+ ".": "./module.ts",
13
+ "./package.json": "./package.json",
14
+ "./runtime/*.vue": "./runtime/*.vue",
15
+ "./runtime/*.css": "./runtime/*.css",
16
+ "./runtime/*.json": "./runtime/*.json",
17
+ "./runtime/*.svg": "./runtime/*.svg",
18
+ "./runtime/*": "./runtime/*.ts"
19
+ },
20
+ "dependencies": {
21
+ "@cloudconfig/core": "0.2.0-beta.2",
22
+ "@nuxt/kit": "4.6.1",
23
+ "h3": "1.15.11",
24
+ "@nuxtagentic/identity": "0.1.1",
25
+ "@nuxtagentic/kit": "0.1.1"
26
+ },
27
+ "peerDependencies": {
28
+ "nuxt": "^4.6.1",
29
+ "vue": "^3.5.43"
30
+ },
31
+ "devDependencies": {
32
+ "tsx": "^4.23.15"
33
+ },
34
+ "license": "SEE LICENSE IN LICENSE.md",
35
+ "description": "Scoped typed records and queries for Nuxt Agentic.",
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "engines": {
41
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
42
+ }
6
43
  }
@@ -0,0 +1,38 @@
1
+ import { useWorkspaceScope } from '@nuxtagentic/identity/runtime/composables/useWorkspaceScope'
2
+ import { computed, type Ref } from 'vue'
3
+ import { useAsyncData } from '#imports'
4
+ import { useWorkspaceRefresh } from './useWorkspaceRefresh'
5
+
6
+ export function useWorkspaceQuery<Row, Query>(
7
+ name: string,
8
+ query: Ref<Query>,
9
+ fetchRows: (query: Query, signal: AbortSignal) => Promise<{ data: Row[]; totalCount: number }>,
10
+ ) {
11
+ const scope = useWorkspaceScope()
12
+ const refreshAll = useWorkspaceRefresh(name)
13
+ const prefix = computed(() => `workspace:${name}:${scope.value}:list:`)
14
+ // Equivalent queries share data and in-flight requests across dashboard surfaces.
15
+ const key = computed(() => `${prefix.value}${JSON.stringify(query.value)}`)
16
+ const { data, status, pending, error, refresh } = useAsyncData(
17
+ key,
18
+ (_, { signal }) =>
19
+ scope.value ? fetchRows(query.value, signal) : Promise.resolve({ data: [], totalCount: 0 }),
20
+ {
21
+ lazy: true,
22
+ deep: false,
23
+ dedupe: 'defer',
24
+ },
25
+ )
26
+
27
+ return {
28
+ scope,
29
+ rows: computed<Row[]>(() => data.value?.data ?? []),
30
+ totalCount: computed(() => data.value?.totalCount ?? 0),
31
+ isLoading: computed(() => status.value === 'idle' || status.value === 'pending'),
32
+ status,
33
+ pending,
34
+ error,
35
+ refresh,
36
+ refreshAll,
37
+ }
38
+ }
@@ -0,0 +1,26 @@
1
+ import { useAsyncData } from '#imports'
2
+ import { computed, type Ref } from 'vue'
3
+ export interface WorkspaceRecordSource<Row extends { id: number | string }> {
4
+ id: Ref<Row['id'] | null>
5
+ /** The workspace the record belongs to; changing it refetches under a new cache key. */
6
+ scope: Ref<string>
7
+ get: (id: Row['id']) => Promise<Row>
8
+ }
9
+
10
+ /** Loads one record of a workspace model, or `null` while nothing is selected. */
11
+ export function useWorkspaceRecord<Row extends { id: number | string }>(
12
+ name: string,
13
+ { id, scope, get }: WorkspaceRecordSource<Row>,
14
+ { server = false }: { server?: boolean } = {},
15
+ ) {
16
+ return useAsyncData(
17
+ computed(() => `workspace:${name}:${scope.value}:detail:${id.value}`),
18
+ async () => {
19
+ if (id.value === null || !scope.value) {
20
+ return null
21
+ }
22
+ return get(id.value)
23
+ },
24
+ { server, default: () => null },
25
+ )
26
+ }
@@ -0,0 +1,15 @@
1
+ import { useNuxtApp, refreshNuxtData } from '#imports'
2
+ import { useWorkspaceScope } from '@nuxtagentic/identity/runtime/composables/useWorkspaceScope'
3
+
4
+ /** Refresh active queries for a resource in the current user/workspace scope. */
5
+ export function useWorkspaceRefresh(name: string) {
6
+ const scope = useWorkspaceScope()
7
+ const nuxtApp = useNuxtApp()
8
+
9
+ return () => {
10
+ const prefix = `workspace:${name}:${scope.value}:list:`
11
+ return refreshNuxtData(
12
+ Object.keys(nuxtApp.payload.data).filter((key) => key.startsWith(prefix)),
13
+ )
14
+ }
15
+ }
@@ -0,0 +1,13 @@
1
+ import { createError } from 'h3'
2
+ import { CloudConfigClientError } from '@cloudconfig/core/client'
3
+
4
+ export function throwCloudConfigError(cause: unknown): never {
5
+ if (cause instanceof CloudConfigClientError) {
6
+ throw createError({
7
+ statusCode: cause.status,
8
+ statusMessage: cause.message,
9
+ data: { issues: cause.issues },
10
+ })
11
+ }
12
+ throw cause
13
+ }
@@ -0,0 +1,30 @@
1
+ const MONTHS = [
2
+ 'january',
3
+ 'february',
4
+ 'march',
5
+ 'april',
6
+ 'may',
7
+ 'june',
8
+ 'july',
9
+ 'august',
10
+ 'september',
11
+ 'october',
12
+ 'november',
13
+ 'december',
14
+ ]
15
+ const MONTH_PATTERN = new RegExp(
16
+ `\\b(${[...MONTHS, ...MONTHS.map((month) => month.slice(0, 3)), 'sept'].join('|')})(?:\\s+(\\d{4}))?\\b`,
17
+ 'gi',
18
+ )
19
+
20
+ /** Named months cover the whole month, defaulting to the current UTC year. */
21
+ export function findNamedMonthPeriods(source: string, now = new Date()) {
22
+ return [...source.matchAll(MONTH_PATTERN)].map((match) => {
23
+ const month = MONTHS.findIndex((name) => name.startsWith(match[1]!.toLowerCase()))
24
+ const year = match[2] ?? String(now.getUTCFullYear())
25
+ const start = new Date(`${year}-${String(month + 1).padStart(2, '0')}-01T00:00:00.000Z`)
26
+ const end = new Date(start)
27
+ end.setUTCMonth(end.getUTCMonth() + 1)
28
+ return { label: match[0], start: start.toISOString(), end: end.toISOString() }
29
+ })
30
+ }
@@ -0,0 +1,26 @@
1
+ export function toDateKey(date: Date) {
2
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
3
+ }
4
+
5
+ export function toLocalDate(key: string) {
6
+ return new Date(`${key}T00:00:00`)
7
+ }
8
+
9
+ export function isValidDateKey(key: unknown): key is string {
10
+ return (
11
+ typeof key === 'string' &&
12
+ /^\d{4}-\d{2}-\d{2}$/.test(key) &&
13
+ Number.isFinite(toLocalDate(key).getTime()) &&
14
+ toDateKey(toLocalDate(key)) === key
15
+ )
16
+ }
17
+
18
+ export function addDays(date: Date, days: number) {
19
+ const next = new Date(date)
20
+ next.setDate(next.getDate() + days)
21
+ return next
22
+ }
23
+
24
+ export function getWeekStart(date: Date) {
25
+ return addDays(toLocalDate(toDateKey(date)), -((date.getDay() + 6) % 7))
26
+ }
@@ -0,0 +1,45 @@
1
+ import type { TableQuery } from '@cloudconfig/core/client'
2
+ import type { ModelView } from './model-views'
3
+ import type { FilterGroup, FilterNode } from '@cloudconfig/core'
4
+
5
+ /** Apply text search only to the properties the UI explicitly enables. */
6
+ export function withModelSearch<Key extends string>(
7
+ query: TableQuery<Key>,
8
+ view: ModelView,
9
+ ): TableQuery<Key> {
10
+ const wireKey = (key: string) =>
11
+ view.fields.find((field) => field.key === key)?.kind === 'relation' ? `${key}Id` : key
12
+ const wireFilter = (node: FilterNode): FilterNode =>
13
+ node.type === 'group'
14
+ ? { ...node, children: node.children.map(wireFilter) }
15
+ : { ...node, field: wireKey(node.field) }
16
+ const base = {
17
+ ...query,
18
+ sortBy: query.sortBy ? (wireKey(query.sortBy) as Key) : undefined,
19
+ filters: query.filters ? (wireFilter(query.filters) as FilterGroup) : undefined,
20
+ }
21
+ const search = query.search?.trim()
22
+ if (!search) {
23
+ return { ...base, search: undefined }
24
+ }
25
+ const filters: FilterGroup = {
26
+ type: 'group',
27
+ logic: 'and',
28
+ children: [
29
+ ...(base.filters ? [base.filters] : []),
30
+ {
31
+ type: 'group',
32
+ logic: 'or',
33
+ children: view.search.length
34
+ ? view.search.map((field) => ({
35
+ type: 'condition',
36
+ field: wireKey(field),
37
+ operator: 'like',
38
+ value: search,
39
+ }))
40
+ : [{ type: 'condition', field: 'id', operator: 'eq', value: -1 }],
41
+ },
42
+ ],
43
+ }
44
+ return { ...base, search: undefined, filters }
45
+ }
@@ -0,0 +1,105 @@
1
+ import { getFieldSpec, type AnyConfig, type FieldSpec, type InferSelect } from '@cloudconfig/core'
2
+
3
+ export type ModelRow = { id: number; [key: string]: unknown }
4
+ export type ModelField = FieldSpec & { key: string; label: string }
5
+
6
+ export interface ModelView<Key extends string = string> {
7
+ id: string
8
+ model: string
9
+ label: string
10
+ icon: string
11
+ aliases: readonly string[]
12
+ fields: readonly ModelField[]
13
+ columns: readonly Key[]
14
+ search: readonly Key[]
15
+ filter: readonly Key[]
16
+ sort: readonly Key[]
17
+ actions: readonly ('create' | 'update' | 'delete')[]
18
+ selectionKey: string
19
+ /** Deterministic value semantics, separate from language interpretation. */
20
+ query?: {
21
+ nullAsFalse?: readonly Key[]
22
+ units?: Partial<Record<Key, 'bytes'>>
23
+ }
24
+ /** Optional domain vocabulary for schema-derived natural-language queries. */
25
+ prompt?: {
26
+ instructions?: string
27
+ fields?: Partial<Record<Key, string>>
28
+ /** Stored values mapped to domain vocabulary, e.g. admin: "administrator". */
29
+ values?: Partial<Record<Key, Record<string, string>>>
30
+ /** Override the global slot limit only where a model needs it. */
31
+ maxConstraints?: Partial<Record<Key, number>>
32
+ }
33
+ }
34
+
35
+ type ViewOptions<Key extends string> = Pick<
36
+ ModelView<Key>,
37
+ | 'id'
38
+ | 'label'
39
+ | 'icon'
40
+ | 'aliases'
41
+ | 'columns'
42
+ | 'search'
43
+ | 'filter'
44
+ | 'sort'
45
+ | 'actions'
46
+ | 'prompt'
47
+ | 'query'
48
+ >
49
+
50
+ /** Only public wire fields can be configured; types and controls come from CloudConfig. */
51
+ export function defineModelView<
52
+ Backend extends AnyConfig,
53
+ Name extends keyof Backend['models'] & string,
54
+ >(
55
+ backend: Backend,
56
+ name: Name,
57
+ options: ViewOptions<keyof InferSelect<Backend, Name> & string>,
58
+ ): ModelView {
59
+ const model = backend.models[name]
60
+ if (!model) {
61
+ throw new Error(`Model "${name}" is not registered in this schema.`)
62
+ }
63
+ const fields: ModelField[] = Object.entries(model.fields).flatMap(([key, field]) => {
64
+ const spec = getFieldSpec(field)
65
+ return spec.serverOnly ? [] : [{ ...spec, key, label: spec.label ?? fieldLabel(key) }]
66
+ })
67
+ fields.unshift({ ...baseField('number'), key: 'id', label: 'ID' })
68
+ if (model.options.timestamps !== false) {
69
+ fields.push(
70
+ { ...baseField('datetime'), key: 'createdAt', label: 'Created' },
71
+ { ...baseField('datetime'), key: 'updatedAt', label: 'Updated' },
72
+ )
73
+ }
74
+ return { ...options, model: name, fields, selectionKey: `${options.id}-record` }
75
+ }
76
+
77
+ function baseField(kind: FieldSpec['kind']): FieldSpec {
78
+ return { kind, required: false, unique: false, hasDefault: false }
79
+ }
80
+
81
+ function fieldLabel(key: string) {
82
+ const text = key.replace(/([a-z])([A-Z])/g, '$1 $2')
83
+ return text.charAt(0).toUpperCase() + text.slice(1)
84
+ }
85
+
86
+ export function getViewColumns(view: ModelView) {
87
+ const { columns, fields } = view
88
+ return columns.map((key) => ({
89
+ key,
90
+ label: fields.find((field) => field.key === key)!.label,
91
+ }))
92
+ }
93
+
94
+ export function formatModelValue(value: unknown): string {
95
+ if (value === null || value === undefined) {
96
+ return '—'
97
+ }
98
+ if (typeof value === 'boolean') {
99
+ return value ? 'Yes' : 'No'
100
+ }
101
+ if (typeof value === 'object') {
102
+ return JSON.stringify(value)
103
+ }
104
+ return String(value)
105
+ }
@@ -0,0 +1,190 @@
1
+ import type { TableQuery } from '@cloudconfig/core/client'
2
+ import type { FilterGroup, FilterNode, FilterOperator, FilterValue } from '@cloudconfig/core'
3
+ import type { ModelView } from '../model-views'
4
+ import type { Comparison, ResourceQuery, Where } from './types'
5
+ import { toBooleanValue } from './values'
6
+
7
+ /** Translate prompt queries to the same server filters used by CloudConfig tables. */
8
+ export function toResourceTableQuery<Key extends string>(
9
+ query: ResourceQuery<Key>,
10
+ view: ModelView<Key>,
11
+ ): TableQuery<Key> {
12
+ function translate(node: Where<Key>, negate = false): FilterNode {
13
+ if (node.type === 'search') {
14
+ if (!view.search.length) {
15
+ throw new Error('Text search is not enabled for this model.')
16
+ }
17
+ return {
18
+ type: 'group',
19
+ logic: negate ? 'and' : 'or',
20
+ children: view.search.map((field) => ({
21
+ type: 'condition',
22
+ field,
23
+ operator: negate ? 'not_contains' : 'like',
24
+ value: node.value,
25
+ })),
26
+ }
27
+ }
28
+ if (node.type === 'group') {
29
+ return translate(node.expression, negate)
30
+ }
31
+ if (node.type === 'not') {
32
+ return translate(node.expression, !negate)
33
+ }
34
+ if (node.type === 'and' || node.type === 'or') {
35
+ // De Morgan: NOT (a AND b) is (NOT a) OR (NOT b), and the reverse.
36
+ const flippedLogic = node.type === 'and' ? 'or' : 'and'
37
+ const logic = negate ? flippedLogic : node.type
38
+ const children = [translate(node.left, negate), translate(node.right, negate)]
39
+ return {
40
+ type: 'group',
41
+ logic,
42
+ children: children.flatMap((child) =>
43
+ child.type === 'group' && child.logic === logic ? child.children : [child],
44
+ ),
45
+ }
46
+ }
47
+ const condition = (operator: FilterOperator, value: FilterValue): FilterNode => {
48
+ if (value === null && operator !== 'eq' && operator !== 'neq') {
49
+ throw new Error('Use IS NULL or IS NOT NULL to compare missing values.')
50
+ }
51
+ const field = view.fields.find((field) => field.key === node.column)
52
+ if (!view.filter.includes(node.column)) {
53
+ throw new Error(`Filtering ${node.column} is not enabled.`)
54
+ }
55
+ if (field?.kind === 'boolean' && value !== null) {
56
+ if (typeof value === 'number' || typeof value === 'string') {
57
+ value = toBooleanValue(value)
58
+ }
59
+ if (typeof value !== 'boolean' || !['eq', 'neq'].includes(operator)) {
60
+ throw new Error(
61
+ `${node.column} requires TRUE/FALSE, 1/0, YES/NO, ON/OFF, or NULL with = or !=.`,
62
+ )
63
+ }
64
+ }
65
+ if (
66
+ value !== null &&
67
+ field &&
68
+ ['number', 'float', 'relation'].includes(field.kind) &&
69
+ (typeof value !== 'number' || !Number.isFinite(value))
70
+ ) {
71
+ throw new Error(`${node.column} requires a number.`)
72
+ }
73
+ if (value !== null && field?.kind === 'select' && !field.options?.includes(String(value))) {
74
+ throw new Error(`Choose a configured option for ${node.column}.`)
75
+ }
76
+ if (value !== null && field && ['date', 'datetime'].includes(field.kind)) {
77
+ if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
78
+ throw new Error(`${node.column} requires a valid date.`)
79
+ }
80
+ value = field.kind === 'datetime' ? new Date(value).toISOString() : value.slice(0, 10)
81
+ }
82
+ if (
83
+ ['like', 'not_contains', 'starts_with', 'ends_with'].includes(operator) &&
84
+ field &&
85
+ !['text', 'textarea', 'email', 'url'].includes(field.kind)
86
+ ) {
87
+ throw new Error(`${node.column} does not support text matching.`)
88
+ }
89
+ return { type: 'condition', field: node.column, operator, value }
90
+ }
91
+ const negative = negate !== ('isNegated' in node && node.isNegated)
92
+ if (node.op === 'cmp') {
93
+ const operators: Record<Comparison, FilterOperator> = negate
94
+ ? {
95
+ '=': 'neq',
96
+ '!=': 'eq',
97
+ '>': 'lte',
98
+ '<': 'gte',
99
+ '>=': 'lt',
100
+ '<=': 'gt',
101
+ }
102
+ : {
103
+ '=': 'eq',
104
+ '!=': 'neq',
105
+ '>': 'gt',
106
+ '<': 'lt',
107
+ '>=': 'gte',
108
+ '<=': 'lte',
109
+ }
110
+ return condition(operators[node.comparison], node.value)
111
+ }
112
+ if (node.op === 'null') {
113
+ return condition(negative ? 'neq' : 'eq', null)
114
+ }
115
+ if (node.op === 'in') {
116
+ return {
117
+ type: 'group',
118
+ logic: negative ? 'and' : 'or',
119
+ children: node.list.map((value) => condition(negative ? 'neq' : 'eq', value)),
120
+ }
121
+ }
122
+ if (node.op === 'between') {
123
+ return {
124
+ type: 'group',
125
+ logic: negative ? 'or' : 'and',
126
+ children: [
127
+ condition(negative ? 'lt' : 'gte', node.low),
128
+ condition(negative ? 'gt' : 'lte', node.high),
129
+ ],
130
+ }
131
+ }
132
+ const pattern = node.value.includes('%') ? node.value : `%${node.value}%`
133
+ const leading = pattern.startsWith('%')
134
+ const trailing = pattern.endsWith('%')
135
+ if (negative && (!leading || !trailing)) {
136
+ throw new Error("NOT LIKE supports contains patterns such as '%text%'.")
137
+ }
138
+ if (!leading && !trailing) {
139
+ throw new Error("Use a LIKE pattern such as '%text%', 'text%', or '%text'.")
140
+ }
141
+ const value = pattern.slice(leading ? 1 : 0, trailing ? -1 : undefined)
142
+ return condition(likeOperator({ negative, leading, trailing }), value)
143
+ }
144
+ if (query.columns?.some((key) => !view.columns.includes(key))) {
145
+ throw new Error('One or more selected columns are not enabled.')
146
+ }
147
+ if (query.order && !view.sort.includes(query.order.column)) {
148
+ throw new Error(`Sorting ${query.order.column} is not enabled.`)
149
+ }
150
+ const filters: FilterGroup = {
151
+ type: 'group',
152
+ logic: 'and',
153
+ children: query.where ? [translate(query.where)] : [],
154
+ }
155
+ const defaultSort = view.sort.includes('createdAt' as Key) ? ('createdAt' as Key) : view.sort[0]
156
+ return {
157
+ page: 1,
158
+ pageSize: Math.max(1, Math.min(25, query.limit ?? 25)),
159
+ filters,
160
+ sortBy: query.order?.column ?? defaultSort,
161
+ sortDir: sortDirection(query.order?.direction, defaultSort === 'createdAt'),
162
+ }
163
+ }
164
+
165
+ /** Maps a LIKE pattern's wildcard positions to the closest filter operator. */
166
+ function likeOperator({
167
+ negative,
168
+ leading,
169
+ trailing,
170
+ }: {
171
+ negative: boolean
172
+ leading: boolean
173
+ trailing: boolean
174
+ }) {
175
+ if (negative) {
176
+ return 'not_contains'
177
+ }
178
+ if (!leading) {
179
+ return 'starts_with'
180
+ }
181
+ return trailing ? 'like' : 'ends_with'
182
+ }
183
+
184
+ /** Newest first when sorting by creation date, otherwise ascending, unless the query says. */
185
+ function sortDirection(direction: number | undefined, isCreatedAtSort: boolean) {
186
+ if (direction !== undefined) {
187
+ return direction === 1 ? 'asc' : 'desc'
188
+ }
189
+ return isCreatedAtSort ? 'desc' : 'asc'
190
+ }
@@ -0,0 +1,312 @@
1
+ import type { ModelView } from '../model-views'
2
+ import { findNamedMonthPeriods } from '../date-periods'
3
+ import type { Comparison, ParseResult, ResourceQuery, Scalar, Where } from './types'
4
+ import { toBooleanValue, createDatePeriodCondition } from './values'
5
+ import { toResourceTableQuery } from './filters'
6
+
7
+ const STOP = new Set(['and', 'or', 'order', 'sort', 'limit', 'not'])
8
+ const COMPARISONS = ['=', '==', '!=', '<>', '>', '<', '>=', '<=']
9
+
10
+ type Token = { type: 'str' | 'op' | 'word'; text: string }
11
+
12
+ function toToken(match: RegExpExecArray): Token {
13
+ const [, quoted, singleQuoted, doubleQuoted, operator, word] = match
14
+ if (quoted) {
15
+ // SQL escapes a quote inside a string by doubling it.
16
+ const text = singleQuoted?.replaceAll("''", "'") ?? doubleQuoted!.replaceAll('""', '"')
17
+ return { type: 'str', text }
18
+ }
19
+ if (operator) {
20
+ return { type: 'op', text: operator }
21
+ }
22
+ return { type: 'word', text: word! }
23
+ }
24
+
25
+ const comparisonAliases: Record<string, Comparison> = { '==': '=', '<>': '!=' }
26
+
27
+ function tokenize(source: string): Token[] {
28
+ const pattern =
29
+ /('((?:''|[^'])*)'|"((?:""|[^"])*)")|(>=|<=|!=|<>|==|=|>|<|\(|\)|,|\*)|([^\s'"()=<>!,]+)/y
30
+ const tokens: Token[] = []
31
+ let cursor = 0
32
+ while (cursor < source.length) {
33
+ if (/\s/.test(source[cursor]!)) {
34
+ cursor++
35
+ continue
36
+ }
37
+ pattern.lastIndex = cursor
38
+ const match = pattern.exec(source)
39
+ if (!match) {
40
+ throw new Error(`Unexpected "${source[cursor]}"`)
41
+ }
42
+ tokens.push(toToken(match))
43
+ cursor = pattern.lastIndex
44
+ }
45
+ return tokens
46
+ }
47
+
48
+ export function parseResourceSql<Key extends string>(
49
+ source: string,
50
+ view: ModelView<Key>,
51
+ aliases: Record<string, Key> = {},
52
+ ): ParseResult<Key> {
53
+ const columnsByName = Object.fromEntries(
54
+ view.fields.map((field) => [field.key.toLowerCase(), field.key]),
55
+ ) as Record<string, Key>
56
+ const resolveColumn = (token: Token | undefined): Key => {
57
+ const key =
58
+ token && (aliases[token.text.toLowerCase()] ?? columnsByName[token.text.toLowerCase()])
59
+ if (!key) {
60
+ throw new Error(token ? `Unknown column "${token.text}"` : 'Expected a column')
61
+ }
62
+ return key
63
+ }
64
+ const entities = new Set([view.id, view.model, ...view.aliases].map((name) => name.toLowerCase()))
65
+ let tokens: Token[]
66
+ try {
67
+ tokens = tokenize(source)
68
+ } catch (error) {
69
+ return { error: (error as Error).message }
70
+ }
71
+ const entityAt = tokens.findIndex(
72
+ (token) => token.type === 'word' && entities.has(token.text.toLowerCase()),
73
+ )
74
+ if (entityAt < 0) {
75
+ return { error: `Expected FROM ${view.id}` }
76
+ }
77
+
78
+ const keyword = (token: Token | undefined) =>
79
+ token?.type === 'word' ? token.text.toLowerCase() : null
80
+ let cursor = entityAt + 1
81
+ const peek = () => tokens[cursor]
82
+ const readToken = () => tokens[cursor++]
83
+ const isKeyword = (...words: string[]) => words.includes(keyword(peek()) ?? '')
84
+ const isOperator = (text: string) => peek()?.type === 'op' && peek()!.text === text
85
+
86
+ const readValue = (): Scalar => {
87
+ const token = readToken()
88
+ if (!token) {
89
+ throw new Error('Expected a value')
90
+ }
91
+ if (token.type === 'str') {
92
+ return token.text
93
+ }
94
+ if (token.type === 'op') {
95
+ throw new Error(`Unexpected "${token.text}"`)
96
+ }
97
+ const word = token.text.toLowerCase()
98
+ if (word === 'true') {
99
+ return true
100
+ }
101
+ if (word === 'false') {
102
+ return false
103
+ }
104
+ if (word === 'null') {
105
+ return null
106
+ }
107
+ if (!Number.isNaN(Number(token.text))) {
108
+ return Number(token.text)
109
+ }
110
+
111
+ let text = token.text
112
+ while (peek()?.type === 'word' && !STOP.has(keyword(peek())!)) {
113
+ text += ' ' + readToken()!.text
114
+ }
115
+ return text
116
+ }
117
+ const readFieldValue = (column: Key): Scalar => {
118
+ const parsed = readValue()
119
+ return view.fields.find((field) => field.key === column)?.kind === 'boolean'
120
+ ? toBooleanValue(parsed)
121
+ : parsed
122
+ }
123
+
124
+ const readCondition = (): Where<Key> => {
125
+ const token = readToken()
126
+ if (!token || token.type !== 'word') {
127
+ throw new Error(token ? `Unexpected "${token.text}"` : 'Expected a condition')
128
+ }
129
+ const column = resolveColumn(token)
130
+ let isNegated = false
131
+ const operator = peek()
132
+ if (!operator) {
133
+ throw new Error(`Expected an operator after ${token.text}`)
134
+ }
135
+ if (operator.type === 'op' && COMPARISONS.includes(operator.text)) {
136
+ readToken()
137
+ const comparison = comparisonAliases[operator.text] ?? (operator.text as Comparison)
138
+ const value = readFieldValue(column)
139
+ const kind = view.fields.find((field) => field.key === column)?.kind
140
+ if (typeof value === 'string' && (kind === 'date' || kind === 'datetime')) {
141
+ const period = findNamedMonthPeriods(value).find(
142
+ (period) => period.label.toLowerCase() === value.trim().toLowerCase(),
143
+ )
144
+ if (period) {
145
+ const boundary = (date: string) => (kind === 'date' ? date.slice(0, 10) : date)
146
+ return createDatePeriodCondition(
147
+ column,
148
+ comparison,
149
+ boundary(period.start),
150
+ boundary(period.end),
151
+ )
152
+ }
153
+ }
154
+ return { type: 'cond', column, op: 'cmp', comparison, value }
155
+ }
156
+ if (isKeyword('not')) {
157
+ readToken()
158
+ isNegated = true
159
+ }
160
+ if (isKeyword('like', 'ilike', 'contains')) {
161
+ const word = keyword(readToken())
162
+ const text = String(readValue())
163
+ return {
164
+ type: 'cond',
165
+ column,
166
+ op: 'like',
167
+ isNegated,
168
+ value: word === 'contains' ? `%${text}%` : text,
169
+ }
170
+ }
171
+ if (isKeyword('in')) {
172
+ readToken()
173
+ if (!isOperator('(')) {
174
+ throw new Error('Expected ( after IN')
175
+ }
176
+ readToken()
177
+ const list: Scalar[] = []
178
+ while (peek() && !isOperator(')')) {
179
+ if (isOperator(',')) {
180
+ readToken()
181
+ continue
182
+ }
183
+ list.push(readFieldValue(column))
184
+ }
185
+ if (!peek()) {
186
+ throw new Error('Missing )')
187
+ }
188
+ readToken()
189
+ if (!list.length) {
190
+ throw new Error('IN list is empty')
191
+ }
192
+ return { type: 'cond', column, op: 'in', isNegated, list }
193
+ }
194
+ if (isKeyword('between')) {
195
+ readToken()
196
+ const low = readValue()
197
+ if (!isKeyword('and')) {
198
+ throw new Error('Expected AND in BETWEEN')
199
+ }
200
+ readToken()
201
+ return { type: 'cond', column, op: 'between', isNegated, low, high: readValue() }
202
+ }
203
+ if (isKeyword('is')) {
204
+ readToken()
205
+ if (isKeyword('not')) {
206
+ readToken()
207
+ isNegated = true
208
+ }
209
+ if (!isKeyword('null')) {
210
+ throw new Error('Expected NULL after IS')
211
+ }
212
+ readToken()
213
+ return { type: 'cond', column, op: 'null', isNegated }
214
+ }
215
+ throw new Error(`Unknown operator "${peek()?.text ?? ''}"`)
216
+ }
217
+
218
+ const readUnary = (): Where<Key> => {
219
+ if (isKeyword('not')) {
220
+ readToken()
221
+ return { type: 'not', expression: readUnary() }
222
+ }
223
+ if (isOperator('(')) {
224
+ readToken()
225
+ const expression = readOr()
226
+ if (!isOperator(')')) {
227
+ throw new Error('Missing )')
228
+ }
229
+ readToken()
230
+ return { type: 'group', expression }
231
+ }
232
+ return readCondition()
233
+ }
234
+ const readAnd = (): Where<Key> => {
235
+ let left = readUnary()
236
+ while (isKeyword('and')) {
237
+ readToken()
238
+ left = { type: 'and', left, right: readUnary() }
239
+ }
240
+ return left
241
+ }
242
+ const readOr = (): Where<Key> => {
243
+ let left = readAnd()
244
+ while (isKeyword('or')) {
245
+ readToken()
246
+ left = { type: 'or', left, right: readAnd() }
247
+ }
248
+ return left
249
+ }
250
+
251
+ try {
252
+ let columns: Key[] | null = null
253
+ if (keyword(tokens[0]) === 'select') {
254
+ const segment = tokens.slice(1, entityAt)
255
+ if (keyword(segment.at(-1)) !== 'from') {
256
+ throw new Error(`Expected FROM before ${view.id}`)
257
+ }
258
+ const named = segment.slice(0, -1).filter((token) => token.text !== ',')
259
+ if (!named.length) {
260
+ throw new Error('Expected columns after SELECT')
261
+ }
262
+ if (!(named.length === 1 && named[0]!.text === '*')) {
263
+ columns = [...new Set(named.map(resolveColumn))]
264
+ }
265
+ } else if (entityAt > 0) {
266
+ throw new Error(`Start with SELECT or ${view.id}`)
267
+ }
268
+ if (isKeyword('where', 'with', 'whose', 'having')) {
269
+ readToken()
270
+ }
271
+ let where: Where<Key> | null = null
272
+ let order: ResourceQuery<Key>['order'] = null
273
+ let limit: number | null = null
274
+ if (peek() && !isKeyword('order', 'sort', 'limit')) {
275
+ where = readOr()
276
+ }
277
+ if (isKeyword('order', 'sort')) {
278
+ readToken()
279
+ if (isKeyword('by')) {
280
+ readToken()
281
+ }
282
+ const column = resolveColumn(readToken())
283
+ let direction: 1 | -1 = 1
284
+ if (isKeyword('asc')) {
285
+ readToken()
286
+ } else if (isKeyword('desc')) {
287
+ readToken()
288
+ direction = -1
289
+ }
290
+ order = { column, direction }
291
+ }
292
+ if (isKeyword('limit')) {
293
+ readToken()
294
+ const limitToken = readToken()
295
+ if (!limitToken || Number.isNaN(+limitToken.text)) {
296
+ throw new Error('Expected a number after LIMIT')
297
+ }
298
+ if (!Number.isFinite(+limitToken.text)) {
299
+ throw new Error('LIMIT requires a finite number.')
300
+ }
301
+ limit = Math.max(0, Math.floor(+limitToken.text))
302
+ }
303
+ if (peek()) {
304
+ throw new Error(`Unexpected "${peek()!.text}"`)
305
+ }
306
+ const query = { columns, where, order, limit }
307
+ toResourceTableQuery(query, view)
308
+ return { query }
309
+ } catch (error) {
310
+ return { error: (error as Error).message }
311
+ }
312
+ }
@@ -0,0 +1,28 @@
1
+ export interface ResourceQuery<Key extends string = string> {
2
+ columns: Key[] | null
3
+ where: Where<Key> | null
4
+ order: { column: Key; direction: 1 | -1 } | null
5
+ limit: number | null
6
+ }
7
+
8
+ export type Scalar = string | number | boolean | null
9
+ export type Comparison = '=' | '!=' | '>' | '<' | '>=' | '<='
10
+
11
+ export type Condition<Key extends string = string> = { type: 'cond'; column: Key } & (
12
+ | { op: 'cmp'; comparison: Comparison; value: Scalar }
13
+ | { op: 'like'; isNegated: boolean; value: string }
14
+ | { op: 'in'; isNegated: boolean; list: Scalar[] }
15
+ | { op: 'between'; isNegated: boolean; low: Scalar; high: Scalar }
16
+ | { op: 'null'; isNegated: boolean }
17
+ )
18
+
19
+ export type Where<Key extends string = string> =
20
+ | Condition<Key>
21
+ | { type: 'search'; value: string }
22
+ | { type: 'and'; left: Where<Key>; right: Where<Key> }
23
+ | { type: 'or'; left: Where<Key>; right: Where<Key> }
24
+ | { type: 'not'; expression: Where<Key> }
25
+ | { type: 'group'; expression: Where<Key> }
26
+
27
+ export type ParseResult<Key extends string = string> =
28
+ { query: ResourceQuery<Key> } | { error: string }
@@ -0,0 +1,41 @@
1
+ import type { Comparison, Condition, Scalar, Where } from './types'
2
+
3
+ export function toBooleanValue(value: Scalar): Scalar {
4
+ if (value === 0 || value === 1) {
5
+ return value === 1
6
+ }
7
+ if (typeof value === 'string' && /^(true|false|yes|no|on|off|1|0)$/i.test(value)) {
8
+ return /^(true|yes|on|1)$/i.test(value)
9
+ }
10
+ return value
11
+ }
12
+
13
+ /** Compare whole date periods using an inclusive start and exclusive end. */
14
+ export function createDatePeriodCondition<Key extends string>(
15
+ column: Key,
16
+ comparison: Comparison,
17
+ start: string,
18
+ end: string,
19
+ ): Where<Key> {
20
+ const compare = (comparison: Comparison, value: string): Condition<Key> => ({
21
+ type: 'cond',
22
+ column,
23
+ op: 'cmp',
24
+ comparison,
25
+ value,
26
+ })
27
+ switch (comparison) {
28
+ case '=':
29
+ return { type: 'and', left: compare('>=', start), right: compare('<', end) }
30
+ case '!=':
31
+ return { type: 'or', left: compare('<', start), right: compare('>=', end) }
32
+ case '>':
33
+ return compare('>=', end)
34
+ case '>=':
35
+ return compare('>=', start)
36
+ case '<':
37
+ return compare('<', start)
38
+ case '<=':
39
+ return compare('<', end)
40
+ }
41
+ }
@@ -0,0 +1,11 @@
1
+ export type {
2
+ ResourceQuery,
3
+ Scalar,
4
+ Comparison,
5
+ Condition,
6
+ Where,
7
+ ParseResult,
8
+ } from './query/types'
9
+ export { createDatePeriodCondition } from './query/values'
10
+ export { parseResourceSql } from './query/sql'
11
+ export { toResourceTableQuery } from './query/filters'
@@ -0,0 +1,26 @@
1
+ import type { PagedResult, TableQuery } from '@cloudconfig/core/client'
2
+
3
+ export function createEmptyPage<Row>(query: TableQuery = {}): PagedResult<Row> {
4
+ return {
5
+ data: [],
6
+ totalCount: 0,
7
+ totalPages: 1,
8
+ page: query.page ?? 1,
9
+ pageSize: query.pageSize ?? 25,
10
+ }
11
+ }
12
+
13
+ export function limitPage<Row>(result: PagedResult<Row>, limit: number | null): PagedResult<Row> {
14
+ if (limit === null) {
15
+ return result
16
+ }
17
+
18
+ const totalCount = Math.min(result.totalCount, limit)
19
+ const offset = (result.page - 1) * result.pageSize
20
+ return {
21
+ ...result,
22
+ totalCount,
23
+ totalPages: Math.max(1, Math.ceil(totalCount / result.pageSize)),
24
+ data: result.data.slice(0, Math.max(0, limit - offset)),
25
+ }
26
+ }
@@ -0,0 +1,3 @@
1
+ export const UPLOAD_LIMIT_MB = 100
2
+ export const UPLOAD_LIMIT_BYTES = UPLOAD_LIMIT_MB * 1024 * 1024
3
+ export const IMAGE_PIXEL_LIMIT = 64_000_000