@teleporthq/teleport-plugin-next-data-source 0.44.0 → 0.44.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/__tests__/_helpers/first-page-runtime.ts +184 -0
- package/__tests__/_helpers/pagination-widget-fixture.ts +67 -1
- package/__tests__/browser-row-policy.test.ts +202 -2
- package/__tests__/data-source-request-locale.test.ts +4 -3
- package/__tests__/fetchers.test.ts +6 -2
- package/__tests__/infinite-scroll.test.ts +210 -5
- package/__tests__/page-data-visitor-read.test.ts +196 -0
- package/__tests__/plain-list-sort.test.ts +1 -1
- package/__tests__/plain-list-starting-sort.test.ts +323 -0
- package/__tests__/refetch-loading-state.test.ts +158 -2
- package/__tests__/starting-sort-first-page.test.ts +285 -0
- package/dist/cjs/cache/ast.d.ts +2 -0
- package/dist/cjs/cache/ast.d.ts.map +1 -1
- package/dist/cjs/cache/ast.js +5 -2
- package/dist/cjs/cache/ast.js.map +1 -1
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +10 -2
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +1 -1
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.js +5 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +14 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/infinite-scroll.d.ts +64 -2
- package/dist/cjs/infinite-scroll.d.ts.map +1 -1
- package/dist/cjs/infinite-scroll.js +102 -3
- package/dist/cjs/infinite-scroll.js.map +1 -1
- package/dist/cjs/loading-state.d.ts +23 -2
- package/dist/cjs/loading-state.d.ts.map +1 -1
- package/dist/cjs/loading-state.js +50 -15
- package/dist/cjs/loading-state.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +200 -64
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/sort-utils.d.ts +53 -1
- package/dist/cjs/sort-utils.d.ts.map +1 -1
- package/dist/cjs/sort-utils.js +105 -13
- package/dist/cjs/sort-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils.d.ts +3 -2
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +48 -18
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/cache/ast.d.ts +2 -0
- package/dist/esm/cache/ast.d.ts.map +1 -1
- package/dist/esm/cache/ast.js +3 -1
- package/dist/esm/cache/ast.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +10 -2
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +1 -1
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.js +6 -2
- package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +14 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/infinite-scroll.d.ts +64 -2
- package/dist/esm/infinite-scroll.d.ts.map +1 -1
- package/dist/esm/infinite-scroll.js +97 -2
- package/dist/esm/infinite-scroll.js.map +1 -1
- package/dist/esm/loading-state.d.ts +23 -2
- package/dist/esm/loading-state.d.ts.map +1 -1
- package/dist/esm/loading-state.js +50 -15
- package/dist/esm/loading-state.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +204 -68
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/sort-utils.d.ts +53 -1
- package/dist/esm/sort-utils.d.ts.map +1 -1
- package/dist/esm/sort-utils.js +100 -12
- package/dist/esm/sort-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils.d.ts +3 -2
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +49 -19
- package/dist/esm/utils.js.map +1 -1
- package/package.json +4 -4
- package/src/cache/ast.ts +4 -1
- package/src/data-source-fetchers.ts +33 -14
- package/src/fetchers/teleport.ts +8 -2
- package/src/fetchers/utils/browser-row-policy.ts +79 -5
- package/src/index.ts +8 -2
- package/src/infinite-scroll.ts +182 -4
- package/src/loading-state.ts +85 -13
- package/src/pagination-plugin.ts +369 -83
- package/src/sort-utils.ts +164 -21
- package/src/utils.ts +55 -18
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* tslint:disable:function-constructor max-classes-per-file no-any */
|
|
2
|
+
import * as types from '@babel/types'
|
|
3
|
+
import generator from '@babel/generator'
|
|
4
|
+
import { generateDataSourceFetcherWithCore } from '../../src/data-source-fetchers'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs what a generated page asks its data module for: the arguments its
|
|
8
|
+
* `getStaticProps` hands `fetchData`, the params a provider computes in the
|
|
9
|
+
* browser, and the products route both reach — so a test can check that the
|
|
10
|
+
* build and the browser order one row set the same way.
|
|
11
|
+
*
|
|
12
|
+
* `__tests__/_helpers` is excluded from the jest test match, so this file is a
|
|
13
|
+
* fixture rather than a suite.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const isFetchDataCall = (node: types.Node): node is types.CallExpression =>
|
|
17
|
+
node.type === 'CallExpression' &&
|
|
18
|
+
node.callee.type === 'MemberExpression' &&
|
|
19
|
+
node.callee.property.type === 'Identifier' &&
|
|
20
|
+
node.callee.property.name === 'fetchData' &&
|
|
21
|
+
node.arguments[0]?.type === 'ObjectExpression'
|
|
22
|
+
|
|
23
|
+
// The `Promise.all` entry whose result lands in `propKey`.
|
|
24
|
+
const prefetchOf = (content: types.Node, propKey: string): types.Node | undefined => {
|
|
25
|
+
let entry: types.Node | undefined
|
|
26
|
+
types.traverseFast(content, (node) => {
|
|
27
|
+
if (
|
|
28
|
+
!entry &&
|
|
29
|
+
node.type === 'VariableDeclarator' &&
|
|
30
|
+
node.id.type === 'ArrayPattern' &&
|
|
31
|
+
node.init?.type === 'AwaitExpression' &&
|
|
32
|
+
node.init.argument.type === 'CallExpression' &&
|
|
33
|
+
node.init.argument.arguments[0]?.type === 'ArrayExpression'
|
|
34
|
+
) {
|
|
35
|
+
const index = node.id.elements.findIndex(
|
|
36
|
+
(element) => element?.type === 'Identifier' && element.name === propKey
|
|
37
|
+
)
|
|
38
|
+
if (index !== -1) {
|
|
39
|
+
entry = (node.init.argument.arguments[0] as types.ArrayExpression).elements[index] as
|
|
40
|
+
| types.Node
|
|
41
|
+
| undefined
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return entry
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What getStaticProps hands fetchData — `{ page: 1, perPage: 20, …, sorts }`,
|
|
50
|
+
* then who it reads for — for the prefetch filling `propKey`, or the first one.
|
|
51
|
+
*/
|
|
52
|
+
export const buildTimeArguments = (
|
|
53
|
+
staticPropsChunk: any,
|
|
54
|
+
propKey?: string
|
|
55
|
+
): Array<Record<string, unknown>> => {
|
|
56
|
+
const scope = propKey ? prefetchOf(staticPropsChunk.content, propKey) : staticPropsChunk.content
|
|
57
|
+
let call: types.CallExpression | undefined
|
|
58
|
+
types.traverseFast(scope as types.Node, (node) => {
|
|
59
|
+
if (!call && isFetchDataCall(node)) {
|
|
60
|
+
call = node
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
const args = call!.arguments.map((arg) => generator(arg as types.Node).code)
|
|
64
|
+
return new Function(`return [${args.join(', ')}]`)()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The `nth` memoized `params` a provider of the component sends.
|
|
68
|
+
const browserParamsBody = (chunk: any, nth: number): types.Expression => {
|
|
69
|
+
const bodies: types.Expression[] = []
|
|
70
|
+
types.traverseFast(chunk.content, (node) => {
|
|
71
|
+
if (
|
|
72
|
+
node.type === 'JSXAttribute' &&
|
|
73
|
+
node.name.name === 'params' &&
|
|
74
|
+
node.value?.type === 'JSXExpressionContainer' &&
|
|
75
|
+
node.value.expression.type === 'CallExpression'
|
|
76
|
+
) {
|
|
77
|
+
const arrow = node.value.expression.arguments[0]
|
|
78
|
+
if (arrow?.type === 'ArrowFunctionExpression') {
|
|
79
|
+
bodies.push(arrow.body as types.Expression)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
return bodies[nth]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The provider's params as the browser computes them for the given state, as they go on the wire. */
|
|
87
|
+
export const browserParams = (
|
|
88
|
+
chunk: any,
|
|
89
|
+
state: Record<string, unknown>,
|
|
90
|
+
nth: number = 0
|
|
91
|
+
): Record<string, string> => {
|
|
92
|
+
const names = Object.keys(state)
|
|
93
|
+
const params = new Function(
|
|
94
|
+
...names,
|
|
95
|
+
`return (${generator(browserParamsBody(chunk, nth)).code})`
|
|
96
|
+
)(...names.map((name) => state[name]))
|
|
97
|
+
// What `new URLSearchParams(params)` puts on the wire.
|
|
98
|
+
return Object.fromEntries(new URLSearchParams(params))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The products route as the project ships it, over a database that records
|
|
103
|
+
* the statements it is sent.
|
|
104
|
+
*/
|
|
105
|
+
export const bootProductsRoute = () => {
|
|
106
|
+
const dataSource = {
|
|
107
|
+
id: 'ds-1',
|
|
108
|
+
name: 'db',
|
|
109
|
+
type: 'teleport',
|
|
110
|
+
config: {
|
|
111
|
+
host: 'teleporthq.secrets.TELEPORT_DB_HOST',
|
|
112
|
+
port: 5432,
|
|
113
|
+
user: 'teleporthq.secrets.TELEPORT_DB_USER',
|
|
114
|
+
password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
|
|
115
|
+
database: 'teleporthq.secrets.TELEPORT_DB_NAME',
|
|
116
|
+
selectedTables: { teleport_products: { columns: [{ name: 'id', type: 'uuid' }] } },
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
const code = generateDataSourceFetcherWithCore(dataSource as never, 'teleport_products')
|
|
120
|
+
.replace(
|
|
121
|
+
"import { Client, types as pgTypes } from 'pg'",
|
|
122
|
+
"const { Client, types: pgTypes } = require('pg')"
|
|
123
|
+
)
|
|
124
|
+
.replace(/^export \{[^}]*\}\s*$/m, '')
|
|
125
|
+
.replace(/^export default \{[^}]*\}\s*$/m, '')
|
|
126
|
+
.concat('\nmodule.exports = { handler: handler, fetchData: fetchData }')
|
|
127
|
+
expect(code).not.toMatch(/^(export|import) /m)
|
|
128
|
+
|
|
129
|
+
const statements: string[] = []
|
|
130
|
+
class FakeClient {
|
|
131
|
+
public async connect(): Promise<void> {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
public async end(): Promise<void> {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
public async query(sql: string): Promise<any> {
|
|
138
|
+
statements.push(sql)
|
|
139
|
+
return { rows: [], rowCount: 0 }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const fakeRequire = (name: string): any => {
|
|
143
|
+
if (name === 'pg') {
|
|
144
|
+
return { Client: FakeClient }
|
|
145
|
+
}
|
|
146
|
+
if (name === 'next-auth/jwt') {
|
|
147
|
+
return { getToken: async (): Promise<null> => null }
|
|
148
|
+
}
|
|
149
|
+
throw new Error('unexpected require: ' + name)
|
|
150
|
+
}
|
|
151
|
+
const moduleObj: { exports: any } = { exports: {} }
|
|
152
|
+
new Function('require', 'module', 'exports', 'process', 'console', code)(
|
|
153
|
+
fakeRequire,
|
|
154
|
+
moduleObj,
|
|
155
|
+
moduleObj.exports,
|
|
156
|
+
{
|
|
157
|
+
env: {
|
|
158
|
+
NEXTAUTH_SECRET: 'secret',
|
|
159
|
+
TELEPORT_DB_HOST: 'h',
|
|
160
|
+
TELEPORT_DB_USER: 'u',
|
|
161
|
+
TELEPORT_DB_PASSWORD: 'p',
|
|
162
|
+
TELEPORT_DB_NAME: 'd',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{ error: (): void => undefined, warn: (): void => undefined, log: (): void => undefined }
|
|
166
|
+
)
|
|
167
|
+
const lastSelect = (): string =>
|
|
168
|
+
statements.filter((sql) => /^\s*SELECT \* FROM/i.test(sql)).pop() || ''
|
|
169
|
+
return {
|
|
170
|
+
atBuild: async (args: Array<Record<string, unknown>>) => {
|
|
171
|
+
await moduleObj.exports.fetchData(...args)
|
|
172
|
+
return lastSelect()
|
|
173
|
+
},
|
|
174
|
+
inBrowser: async (query: Record<string, string>) => {
|
|
175
|
+
const res = { status: () => res, json: () => res, setHeader: () => res }
|
|
176
|
+
await moduleObj.exports.handler({ method: 'GET', query, headers: {} }, res)
|
|
177
|
+
return lastSelect()
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const orderBy = (sql: string): string => (sql.match(/ ORDER BY .*? LIMIT/) || [''])[0]
|
|
183
|
+
export const rowWindow = (sql: string): string =>
|
|
184
|
+
(sql.match(/ LIMIT \d+( OFFSET \d+)?$/) || [''])[0]
|
|
@@ -271,6 +271,14 @@ export interface RepeaterOptions {
|
|
|
271
271
|
filters?: unknown[]
|
|
272
272
|
/** The mapper's editor node id, as the UIDL carries it on the repeater. */
|
|
273
273
|
nodeId?: string
|
|
274
|
+
/** The repeater's sort and direction as the UIDL carries them: a static column, or an expression. */
|
|
275
|
+
sort?: unknown
|
|
276
|
+
sortDirection?: unknown
|
|
277
|
+
/** The repeater's cache config. */
|
|
278
|
+
cache?: unknown
|
|
279
|
+
/** The repeater's `source`, for a list bound inside the payload (with `dataSourceType`). */
|
|
280
|
+
source?: string
|
|
281
|
+
dataSourceType?: string
|
|
274
282
|
}
|
|
275
283
|
|
|
276
284
|
export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
|
|
@@ -280,7 +288,7 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
|
|
|
280
288
|
resourceDefinition: {
|
|
281
289
|
dataSourceId: 'ds1',
|
|
282
290
|
tableName: 'products',
|
|
283
|
-
dataSourceType: 'postgresql',
|
|
291
|
+
dataSourceType: options.dataSourceType ?? 'postgresql',
|
|
284
292
|
},
|
|
285
293
|
resource: {
|
|
286
294
|
params: {
|
|
@@ -301,6 +309,10 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
|
|
|
301
309
|
...(options.infiniteScroll ? { infiniteScroll: true } : {}),
|
|
302
310
|
...(options.infiniteScrollLoadMore ? { infiniteScrollLoadMore: true } : {}),
|
|
303
311
|
...(options.nodeId ? { nodeId: options.nodeId } : {}),
|
|
312
|
+
...(options.sort ? { sort: options.sort } : {}),
|
|
313
|
+
...(options.sortDirection ? { sortDirection: options.sortDirection } : {}),
|
|
314
|
+
...(options.cache ? { cache: options.cache } : {}),
|
|
315
|
+
...(options.source ? { source: options.source } : {}),
|
|
304
316
|
nodes: { list: { type: 'element', content: { elementType: 'div' } } },
|
|
305
317
|
},
|
|
306
318
|
},
|
|
@@ -318,3 +330,57 @@ export const makeMultiMapperUidl = (repeaters: RepeaterOptions[]): any => ({
|
|
|
318
330
|
),
|
|
319
331
|
},
|
|
320
332
|
})
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The page's `getStaticProps` as the main data-source plugin leaves it: a
|
|
336
|
+
* `Promise.all` of prefetches and a `props` object, which the pagination plugin
|
|
337
|
+
* extends with each paginated list's first page and its count. Its presence is
|
|
338
|
+
* what makes the component a page.
|
|
339
|
+
*/
|
|
340
|
+
export const makeGetStaticPropsChunk = (): ChunkDefinition => ({
|
|
341
|
+
name: 'getStaticProps',
|
|
342
|
+
type: ChunkType.AST,
|
|
343
|
+
fileType: FileType.JS,
|
|
344
|
+
linkAfter: ['jsx-component'],
|
|
345
|
+
meta: {},
|
|
346
|
+
content: types.exportNamedDeclaration(
|
|
347
|
+
types.functionDeclaration(
|
|
348
|
+
types.identifier('getStaticProps'),
|
|
349
|
+
[types.identifier('context')],
|
|
350
|
+
types.blockStatement([
|
|
351
|
+
types.tryStatement(
|
|
352
|
+
types.blockStatement([
|
|
353
|
+
types.variableDeclaration('const', [
|
|
354
|
+
types.variableDeclarator(
|
|
355
|
+
types.arrayPattern([]),
|
|
356
|
+
types.awaitExpression(
|
|
357
|
+
types.callExpression(
|
|
358
|
+
types.memberExpression(types.identifier('Promise'), types.identifier('all')),
|
|
359
|
+
[types.arrayExpression([])]
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
),
|
|
363
|
+
]),
|
|
364
|
+
types.returnStatement(
|
|
365
|
+
types.objectExpression([
|
|
366
|
+
types.objectProperty(types.identifier('props'), types.objectExpression([])),
|
|
367
|
+
])
|
|
368
|
+
),
|
|
369
|
+
]),
|
|
370
|
+
types.catchClause(
|
|
371
|
+
types.identifier('error'),
|
|
372
|
+
types.blockStatement([
|
|
373
|
+
types.returnStatement(
|
|
374
|
+
types.objectExpression([
|
|
375
|
+
types.objectProperty(types.identifier('props'), types.objectExpression([])),
|
|
376
|
+
])
|
|
377
|
+
),
|
|
378
|
+
])
|
|
379
|
+
)
|
|
380
|
+
),
|
|
381
|
+
]),
|
|
382
|
+
false,
|
|
383
|
+
true
|
|
384
|
+
)
|
|
385
|
+
),
|
|
386
|
+
})
|
|
@@ -193,11 +193,25 @@ function bootTableRoute(tableName: string, options: BootOptions = {}) {
|
|
|
193
193
|
return moduleObj.exports.fetchData({})
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
// A page's own data, as its getStaticProps / getServerSideProps reads it.
|
|
197
|
+
const pageFetch = async (params: Record<string, unknown> = {}): Promise<unknown> => {
|
|
198
|
+
queries.length = 0
|
|
199
|
+
session = null
|
|
200
|
+
return moduleObj.exports.fetchData(params, { as: 'visitor' })
|
|
201
|
+
}
|
|
202
|
+
const pageCount = async (params: Record<string, unknown> = {}): Promise<unknown> => {
|
|
203
|
+
queries.length = 0
|
|
204
|
+
session = null
|
|
205
|
+
return moduleObj.exports.fetchCount(params, { as: 'visitor' })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { run, serverFetch, pageFetch, pageCount, queries, cacheCalls }
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
const internal = { 'x-internal-data-secret': SECRET }
|
|
200
212
|
|
|
213
|
+
const SORT_BY_NAME = JSON.stringify([{ field: 'name', order: 'asc' }])
|
|
214
|
+
|
|
201
215
|
const mainQuery = (result: RouteResult): string =>
|
|
202
216
|
(result.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql)) || { sql: '' }).sql
|
|
203
217
|
|
|
@@ -233,7 +247,7 @@ describe('blog posts: a visitor reads the published posts, never an author email
|
|
|
233
247
|
expect(JSON.stringify(shopper.body.data)).not.toContain('ana@example.com')
|
|
234
248
|
})
|
|
235
249
|
|
|
236
|
-
it(
|
|
250
|
+
it("serves the store everything: the admin, a server segment and the store's own server code", async () => {
|
|
237
251
|
const admin = await posts.run({ session: { role: 'admin' } })
|
|
238
252
|
expect(mainQuery(admin)).not.toContain("status = 'published'")
|
|
239
253
|
// The post view model carries no email for anyone — a page's data must not
|
|
@@ -252,6 +266,26 @@ describe('blog posts: a visitor reads the published posts, never an author email
|
|
|
252
266
|
expect(serverRead && serverRead.sql).not.toContain("(status = 'published')")
|
|
253
267
|
})
|
|
254
268
|
|
|
269
|
+
// This used to read as the store too: a page's getStaticProps called
|
|
270
|
+
// fetchData with the store's own marker. But a page's data is what it renders,
|
|
271
|
+
// and it reaches whoever opens the page (and `/_next/data` without rendering
|
|
272
|
+
// it): a draft painted on page 1 that the visitor's own page 2 skipped, a
|
|
273
|
+
// draft's details page anyone could open. A page reads `{ as: 'visitor' }`.
|
|
274
|
+
it("reads a page's data as a visitor: published posts, no author email", async () => {
|
|
275
|
+
await posts.pageFetch()
|
|
276
|
+
const pageRead = posts.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql))
|
|
277
|
+
expect(pageRead && pageRead.sql).toContain("(status = 'published')")
|
|
278
|
+
|
|
279
|
+
const rawRows = (await posts.pageFetch({ rawRows: 'true' })) as any[]
|
|
280
|
+
expect(JSON.stringify(rawRows)).not.toContain('ana@example.com')
|
|
281
|
+
expect(rawRows[0].title).toBe('Hello')
|
|
282
|
+
|
|
283
|
+
await posts.pageCount()
|
|
284
|
+
expect(posts.queries.some((q) => /COUNT\(\*\).*\(status = 'published'\)/.test(q.sql))).toBe(
|
|
285
|
+
true
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
255
289
|
it('narrows the count the same way', async () => {
|
|
256
290
|
const guest = await posts.run({ handler: 'getCount' })
|
|
257
291
|
expect(guest.status).toBe(200)
|
|
@@ -334,6 +368,172 @@ describe('products: a visitor reads the active products', () => {
|
|
|
334
368
|
)
|
|
335
369
|
expect(mainQuery(await products.run({ headers: internal }))).not.toContain("= 'active'")
|
|
336
370
|
})
|
|
371
|
+
|
|
372
|
+
it("narrows a page's own data like a visitor's request, page and count alike", async () => {
|
|
373
|
+
const visitor = mainQuery(
|
|
374
|
+
await products.run({ query: { page: '1', perPage: '20', sorts: SORT_BY_NAME } })
|
|
375
|
+
)
|
|
376
|
+
await products.pageFetch({ page: 1, perPage: 20, sorts: SORT_BY_NAME })
|
|
377
|
+
const page = (products.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql)) || { sql: '' })
|
|
378
|
+
.sql
|
|
379
|
+
expect(page).toContain("(LOWER(TRIM(status)) = 'active')")
|
|
380
|
+
expect(page).toBe(visitor)
|
|
381
|
+
|
|
382
|
+
await products.pageCount()
|
|
383
|
+
expect(
|
|
384
|
+
products.queries.some((q) =>
|
|
385
|
+
/COUNT\(\*\).*\(LOWER\(TRIM\(status\)\) = 'active'\)/.test(q.sql)
|
|
386
|
+
)
|
|
387
|
+
).toBe(true)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it("runs a page's own statement over the products a visitor is served", async () => {
|
|
391
|
+
const featured =
|
|
392
|
+
'SELECT tp.slug, fpc.blurb FROM featured_product_curations fpc INNER JOIN teleport_products tp ON tp.id = fpc.product_id ORDER BY fpc.display_rank ASC LIMIT 8'
|
|
393
|
+
await products.pageFetch({ rawQuery: featured })
|
|
394
|
+
expect(products.queries.map((q) => q.sql)).toEqual([
|
|
395
|
+
`WITH "teleport_products" AS (SELECT * FROM "teleport_products" AS __brp_rows WHERE (LOWER(TRIM(status)) = 'active')) ${featured}`,
|
|
396
|
+
])
|
|
397
|
+
|
|
398
|
+
// Its own WITH list comes after, so its entries read the visitor's rows too.
|
|
399
|
+
const picked =
|
|
400
|
+
'WITH picks AS (SELECT * FROM teleport_products WHERE price > 6) SELECT slug FROM picks;'
|
|
401
|
+
await products.pageFetch({ rawQuery: picked })
|
|
402
|
+
expect(products.queries.map((q) => q.sql)).toEqual([
|
|
403
|
+
`WITH "teleport_products" AS (SELECT * FROM "teleport_products" AS __brp_rows WHERE (LOWER(TRIM(status)) = 'active')), picks AS (SELECT * FROM teleport_products WHERE price > 6) SELECT slug FROM picks`,
|
|
404
|
+
])
|
|
405
|
+
|
|
406
|
+
// A statement over no narrowed table runs as written.
|
|
407
|
+
await products.pageFetch({ rawQuery: 'SELECT id FROM category_tiles' })
|
|
408
|
+
expect(products.queries.map((q) => q.sql)).toEqual(['SELECT id FROM category_tiles'])
|
|
409
|
+
|
|
410
|
+
// The store's own server code still reads every product.
|
|
411
|
+
await products.serverFetch()
|
|
412
|
+
expect(products.queries.map((q) => q.sql).join(' ')).not.toContain("= 'active'")
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('refuses a statement it cannot narrow, rather than run it whole', async () => {
|
|
416
|
+
// A schema reaches the table past the visitor's rows; a RECURSIVE list
|
|
417
|
+
// would see its own entries.
|
|
418
|
+
for (const rawQuery of [
|
|
419
|
+
'SELECT * FROM public.teleport_products',
|
|
420
|
+
'SELECT * FROM "public"."teleport_products"',
|
|
421
|
+
'WITH RECURSIVE t AS (SELECT 1) SELECT * FROM teleport_products',
|
|
422
|
+
]) {
|
|
423
|
+
await expect(products.pageFetch({ rawQuery })).rejects.toThrow(/cannot be read for a visitor/)
|
|
424
|
+
expect(products.queries).toEqual([])
|
|
425
|
+
}
|
|
426
|
+
// A statement that can read a table without naming it is refused sooner:
|
|
427
|
+
// it may be reading any protected one.
|
|
428
|
+
await expect(
|
|
429
|
+
products.pageFetch({
|
|
430
|
+
rawQuery: "SELECT query_to_xml('select * from teleport_products', true, true, '')",
|
|
431
|
+
})
|
|
432
|
+
).rejects.toThrow('Unauthenticated')
|
|
433
|
+
expect(products.queries).toEqual([])
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe("members: a page carries a member's public profile, never their account", () => {
|
|
438
|
+
const MEMBER = {
|
|
439
|
+
id: 'u2',
|
|
440
|
+
name: 'Bo',
|
|
441
|
+
email: 'bo@example.com',
|
|
442
|
+
phone: '+40 700 000 000',
|
|
443
|
+
details: { address: '1 Main St' },
|
|
444
|
+
role: 'user',
|
|
445
|
+
password: 'hash',
|
|
446
|
+
access_token: 'token',
|
|
447
|
+
image: 'https://cdn.example.com/bo.png',
|
|
448
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
449
|
+
}
|
|
450
|
+
const PRIVATE = [
|
|
451
|
+
'bo@example.com',
|
|
452
|
+
'+40 700',
|
|
453
|
+
'1 Main St',
|
|
454
|
+
'hash',
|
|
455
|
+
'token',
|
|
456
|
+
'"role"',
|
|
457
|
+
'2026-01-01',
|
|
458
|
+
]
|
|
459
|
+
const byId = JSON.stringify([
|
|
460
|
+
{ type: 'condition', source: 'id', destination: 'u2', operand: '=' },
|
|
461
|
+
])
|
|
462
|
+
|
|
463
|
+
it('reads the profile a page shows: id, name and avatar only', async () => {
|
|
464
|
+
const users = bootTableRoute('users', { rows: [MEMBER] })
|
|
465
|
+
const rows = (await users.pageFetch({ filters: byId, limit: 1 })) as any[]
|
|
466
|
+
|
|
467
|
+
expect(rows).toEqual([{ id: 'u2', name: 'Bo', image: 'https://cdn.example.com/bo.png' }])
|
|
468
|
+
PRIVATE.forEach((value) => expect(JSON.stringify(rows)).not.toContain(value))
|
|
469
|
+
expect(mainQuery({ queries: users.queries } as RouteResult)).toBe(
|
|
470
|
+
'SELECT * FROM users WHERE id = $1 AND (TRUE) LIMIT 1'
|
|
471
|
+
)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('never answers a filter, sort or search over a private column', async () => {
|
|
475
|
+
const users = bootTableRoute('users', { rows: [MEMBER] })
|
|
476
|
+
const email = JSON.stringify([
|
|
477
|
+
{ type: 'condition', source: 'email', destination: 'bo@example.com', operand: '=' },
|
|
478
|
+
])
|
|
479
|
+
await expect(users.pageFetch({ filters: email })).rejects.toThrow('email is not readable')
|
|
480
|
+
await expect(
|
|
481
|
+
users.pageFetch({ sorts: JSON.stringify([{ field: 'role', order: 'asc' }]) })
|
|
482
|
+
).rejects.toThrow('role is not readable')
|
|
483
|
+
expect(users.queries).toEqual([])
|
|
484
|
+
|
|
485
|
+
await users.pageFetch({ query: 'bo', queryColumns: JSON.stringify(['name', 'email']) })
|
|
486
|
+
const searched = mainQuery({ queries: users.queries } as RouteResult)
|
|
487
|
+
expect(searched).toContain('"name"::text ILIKE')
|
|
488
|
+
expect(searched).not.toContain('"email"')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("keeps a browser out, the admin's and the server's reads whole, and the profile out of every cache", async () => {
|
|
492
|
+
const users = bootTableRoute('users', {
|
|
493
|
+
rows: [MEMBER],
|
|
494
|
+
cache: { scope: 'ds-1:users', ttlSeconds: 60, sMaxAge: 30 },
|
|
495
|
+
})
|
|
496
|
+
expect((await users.run({})).status).toBe(401)
|
|
497
|
+
expect((await users.run({ session: { role: 'user' } })).status).toBe(403)
|
|
498
|
+
const admin = await users.run({ session: { role: 'admin' } })
|
|
499
|
+
expect(admin.body.data[0].email).toBe('bo@example.com')
|
|
500
|
+
expect(((await users.serverFetch()) as any[])[0].email).toBe('bo@example.com')
|
|
501
|
+
// Never a credential, whoever reads.
|
|
502
|
+
expect(JSON.stringify(admin.body.data)).not.toContain('hash')
|
|
503
|
+
|
|
504
|
+
users.cacheCalls.length = 0
|
|
505
|
+
await users.pageFetch({ filters: byId, limit: 1 })
|
|
506
|
+
expect(users.cacheCalls).toEqual([])
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe("a page's own data never carries a table a visitor may not read", () => {
|
|
511
|
+
it('refuses the protected tables, by name or in a statement', async () => {
|
|
512
|
+
const orders = bootTableRoute('teleport_orders', { rows: [{ id: 'o1', total: 10 }] })
|
|
513
|
+
await expect(orders.pageFetch()).rejects.toThrow('Unauthenticated')
|
|
514
|
+
await expect(orders.pageCount()).rejects.toThrow('Unauthenticated')
|
|
515
|
+
expect(orders.queries).toEqual([])
|
|
516
|
+
// The store's own server code reads them, as it always has.
|
|
517
|
+
expect(await orders.serverFetch()).toEqual([{ id: 'o1', total: 10, __rowNumber: 1 }])
|
|
518
|
+
|
|
519
|
+
const products = bootTableRoute('teleport_products')
|
|
520
|
+
await expect(
|
|
521
|
+
products.pageFetch({
|
|
522
|
+
rawQuery: 'SELECT * FROM teleport_products p JOIN users u ON u.id = p.owner_id',
|
|
523
|
+
})
|
|
524
|
+
).rejects.toThrow('Unauthenticated')
|
|
525
|
+
expect(products.queries).toEqual([])
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it("shares the visitors' cache entry, never the store's", async () => {
|
|
529
|
+
const posts = bootTableRoute('teleport_blog_posts', {
|
|
530
|
+
rows: POSTS,
|
|
531
|
+
cache: { scope: 'ds-1:teleport_blog_posts', ttlSeconds: 60, sMaxAge: 30 },
|
|
532
|
+
})
|
|
533
|
+
posts.cacheCalls.length = 0
|
|
534
|
+
await posts.pageFetch()
|
|
535
|
+
expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts', sMaxAge: 30 }])
|
|
536
|
+
})
|
|
337
537
|
})
|
|
338
538
|
|
|
339
539
|
describe('the AI assistant tables are read by the store only', () => {
|
|
@@ -209,11 +209,12 @@ describe('data-source fetches in a multi-language project', () => {
|
|
|
209
209
|
describe('a paginated list page', () => {
|
|
210
210
|
it('fetches the first page for the language getStaticProps runs for', async () => {
|
|
211
211
|
const { staticProps } = await run(pagesPlugin, makeListNode(true), makeListProvider(), true)
|
|
212
|
+
// Read as a visitor is served the rows: a public page.
|
|
212
213
|
expect(collapse(staticProps)).toContain(
|
|
213
|
-
'fetchData({ page: 1, perPage: 20, queryColumns: JSON.stringify(["name", "es_name"]), locale: context.locale })'
|
|
214
|
+
'fetchData({ page: 1, perPage: 20, queryColumns: JSON.stringify(["name", "es_name"]), locale: context.locale }, { as: "visitor" })'
|
|
214
215
|
)
|
|
215
216
|
// The count is the same in every language.
|
|
216
|
-
expect(collapse(staticProps)).toContain('fetchCount().catch')
|
|
217
|
+
expect(collapse(staticProps)).toContain('fetchCount({}, { as: "visitor" }).catch')
|
|
217
218
|
})
|
|
218
219
|
|
|
219
220
|
it('sends the client locale with every refetch and refetches when it changes', async () => {
|
|
@@ -247,7 +248,7 @@ describe('data-source fetches in a multi-language project', () => {
|
|
|
247
248
|
true
|
|
248
249
|
)
|
|
249
250
|
expect(collapse(staticProps)).toContain(
|
|
250
|
-
'fetchData({ "queryColumns": JSON.stringify(["name", "es_name"]), locale: context.locale })'
|
|
251
|
+
'fetchData({ "queryColumns": JSON.stringify(["name", "es_name"]), locale: context.locale }, { as: "visitor" })'
|
|
251
252
|
)
|
|
252
253
|
expect(collapse(code)).toContain(
|
|
253
254
|
'params={useMemo(() => ({ locale: router?.locale }), [router?.locale])}'
|
|
@@ -481,8 +481,12 @@ describe('generateDataSourceFetcherWithCore', () => {
|
|
|
481
481
|
const dataSource = createPostgreSQLDataSource('ds-1')
|
|
482
482
|
const code = generateDataSourceFetcherWithCore(dataSource, 'users')
|
|
483
483
|
|
|
484
|
-
|
|
485
|
-
expect(code).toContain('
|
|
484
|
+
// The store's own call by default; a page's data `{ as: 'visitor' }`.
|
|
485
|
+
expect(code).toContain('const req = __tqServerRequest(params, options)')
|
|
486
|
+
expect(code).toContain("return { query: params, method: 'GET', __tqServerCall: true }")
|
|
487
|
+
expect(code).toContain(
|
|
488
|
+
"return { query: params, method: 'GET', headers: {}, __tqVisitorView: true }"
|
|
489
|
+
)
|
|
486
490
|
expect(code).toContain('const res = {')
|
|
487
491
|
expect(code).toContain('status: (code)')
|
|
488
492
|
expect(code).toContain('json: (data)')
|