@teleporthq/teleport-plugin-next-data-source 0.44.0 → 0.44.4

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 (94) hide show
  1. package/__tests__/_helpers/first-page-runtime.ts +184 -0
  2. package/__tests__/_helpers/pagination-widget-fixture.ts +67 -1
  3. package/__tests__/browser-row-policy.test.ts +202 -2
  4. package/__tests__/data-source-request-locale.test.ts +4 -3
  5. package/__tests__/fetchers.test.ts +6 -2
  6. package/__tests__/infinite-scroll.test.ts +210 -5
  7. package/__tests__/page-data-visitor-read.test.ts +196 -0
  8. package/__tests__/plain-list-sort.test.ts +1 -1
  9. package/__tests__/plain-list-starting-sort.test.ts +323 -0
  10. package/__tests__/refetch-loading-state.test.ts +158 -2
  11. package/__tests__/starting-sort-first-page.test.ts +285 -0
  12. package/dist/cjs/cache/ast.d.ts +2 -0
  13. package/dist/cjs/cache/ast.d.ts.map +1 -1
  14. package/dist/cjs/cache/ast.js +5 -2
  15. package/dist/cjs/cache/ast.js.map +1 -1
  16. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  17. package/dist/cjs/data-source-fetchers.js +10 -2
  18. package/dist/cjs/data-source-fetchers.js.map +1 -1
  19. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/teleport.js +1 -1
  21. package/dist/cjs/fetchers/teleport.js.map +1 -1
  22. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/utils/browser-row-policy.js +5 -1
  24. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  25. package/dist/cjs/index.d.ts.map +1 -1
  26. package/dist/cjs/index.js +14 -2
  27. package/dist/cjs/index.js.map +1 -1
  28. package/dist/cjs/infinite-scroll.d.ts +64 -2
  29. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  30. package/dist/cjs/infinite-scroll.js +102 -3
  31. package/dist/cjs/infinite-scroll.js.map +1 -1
  32. package/dist/cjs/loading-state.d.ts +23 -2
  33. package/dist/cjs/loading-state.d.ts.map +1 -1
  34. package/dist/cjs/loading-state.js +50 -15
  35. package/dist/cjs/loading-state.js.map +1 -1
  36. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  37. package/dist/cjs/pagination-plugin.js +200 -64
  38. package/dist/cjs/pagination-plugin.js.map +1 -1
  39. package/dist/cjs/sort-utils.d.ts +53 -1
  40. package/dist/cjs/sort-utils.d.ts.map +1 -1
  41. package/dist/cjs/sort-utils.js +105 -13
  42. package/dist/cjs/sort-utils.js.map +1 -1
  43. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  44. package/dist/cjs/utils.d.ts +3 -2
  45. package/dist/cjs/utils.d.ts.map +1 -1
  46. package/dist/cjs/utils.js +48 -18
  47. package/dist/cjs/utils.js.map +1 -1
  48. package/dist/esm/cache/ast.d.ts +2 -0
  49. package/dist/esm/cache/ast.d.ts.map +1 -1
  50. package/dist/esm/cache/ast.js +3 -1
  51. package/dist/esm/cache/ast.js.map +1 -1
  52. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  53. package/dist/esm/data-source-fetchers.js +10 -2
  54. package/dist/esm/data-source-fetchers.js.map +1 -1
  55. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  56. package/dist/esm/fetchers/teleport.js +1 -1
  57. package/dist/esm/fetchers/teleport.js.map +1 -1
  58. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  59. package/dist/esm/fetchers/utils/browser-row-policy.js +6 -2
  60. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  61. package/dist/esm/index.d.ts.map +1 -1
  62. package/dist/esm/index.js +14 -2
  63. package/dist/esm/index.js.map +1 -1
  64. package/dist/esm/infinite-scroll.d.ts +64 -2
  65. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  66. package/dist/esm/infinite-scroll.js +97 -2
  67. package/dist/esm/infinite-scroll.js.map +1 -1
  68. package/dist/esm/loading-state.d.ts +23 -2
  69. package/dist/esm/loading-state.d.ts.map +1 -1
  70. package/dist/esm/loading-state.js +50 -15
  71. package/dist/esm/loading-state.js.map +1 -1
  72. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  73. package/dist/esm/pagination-plugin.js +204 -68
  74. package/dist/esm/pagination-plugin.js.map +1 -1
  75. package/dist/esm/sort-utils.d.ts +53 -1
  76. package/dist/esm/sort-utils.d.ts.map +1 -1
  77. package/dist/esm/sort-utils.js +100 -12
  78. package/dist/esm/sort-utils.js.map +1 -1
  79. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  80. package/dist/esm/utils.d.ts +3 -2
  81. package/dist/esm/utils.d.ts.map +1 -1
  82. package/dist/esm/utils.js +49 -19
  83. package/dist/esm/utils.js.map +1 -1
  84. package/package.json +4 -4
  85. package/src/cache/ast.ts +4 -1
  86. package/src/data-source-fetchers.ts +33 -14
  87. package/src/fetchers/teleport.ts +8 -2
  88. package/src/fetchers/utils/browser-row-policy.ts +79 -5
  89. package/src/index.ts +8 -2
  90. package/src/infinite-scroll.ts +182 -4
  91. package/src/loading-state.ts +85 -13
  92. package/src/pagination-plugin.ts +369 -83
  93. package/src/sort-utils.ts +164 -21
  94. 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
- return { run, serverFetch, queries, cacheCalls }
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('serves the store everything: the admin, a server segment and getStaticProps', async () => {
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
- expect(code).toContain('const req = {')
485
- expect(code).toContain('query: params')
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)')