@teleporthq/teleport-plugin-next-data-source 0.44.15 → 0.45.0

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 (125) hide show
  1. package/__tests__/_helpers/exposure.ts +29 -0
  2. package/__tests__/_helpers/first-page-runtime.ts +12 -2
  3. package/__tests__/blog-post-contents.test.ts +3 -1
  4. package/__tests__/blog-post-page-enrichment.test.ts +2 -1
  5. package/__tests__/blog-post-seo-fields.test.ts +124 -0
  6. package/__tests__/browser-row-policy.test.ts +65 -5
  7. package/__tests__/data-source-cache-wrapping.test.ts +48 -7
  8. package/__tests__/external-read-routes-guard.test.ts +991 -0
  9. package/__tests__/null-filter-where.test.ts +19 -2
  10. package/__tests__/range-control.test.ts +29 -0
  11. package/__tests__/search-relevance.test.ts +42 -6
  12. package/__tests__/table-read-route-protected-tables.test.ts +54 -4
  13. package/dist/cjs/count-fetchers.d.ts.map +1 -1
  14. package/dist/cjs/count-fetchers.js +1 -1
  15. package/dist/cjs/count-fetchers.js.map +1 -1
  16. package/dist/cjs/data-source-fetchers.d.ts +4 -0
  17. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  18. package/dist/cjs/data-source-fetchers.js +97 -20
  19. package/dist/cjs/data-source-fetchers.js.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.d.ts +3 -1
  21. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/postgresql.js +11 -3
  23. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  24. package/dist/cjs/fetchers/raw-query.d.ts +20 -1
  25. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/raw-query.js +37 -7
  27. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  28. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  29. package/dist/cjs/fetchers/redshift.js +2 -1
  30. package/dist/cjs/fetchers/redshift.js.map +1 -1
  31. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  32. package/dist/cjs/fetchers/teleport.js +5 -3
  33. package/dist/cjs/fetchers/teleport.js.map +1 -1
  34. package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
  35. package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
  36. package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
  37. package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
  38. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
  39. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
  41. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
  42. package/dist/cjs/index.js +1 -1
  43. package/dist/cjs/pagination-controls.d.ts +4 -1
  44. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  45. package/dist/cjs/pagination-controls.js +4 -1
  46. package/dist/cjs/pagination-controls.js.map +1 -1
  47. package/dist/cjs/pagination-plugin.js +1 -0
  48. package/dist/cjs/pagination-plugin.js.map +1 -1
  49. package/dist/cjs/range-control.d.ts +10 -5
  50. package/dist/cjs/range-control.d.ts.map +1 -1
  51. package/dist/cjs/range-control.js +23 -2
  52. package/dist/cjs/range-control.js.map +1 -1
  53. package/dist/cjs/transformations/blog-post.d.ts +5 -0
  54. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  55. package/dist/cjs/transformations/blog-post.js +6 -2
  56. package/dist/cjs/transformations/blog-post.js.map +1 -1
  57. package/dist/cjs/transformations/index.d.ts +18 -2
  58. package/dist/cjs/transformations/index.d.ts.map +1 -1
  59. package/dist/cjs/transformations/index.js +26 -1
  60. package/dist/cjs/transformations/index.js.map +1 -1
  61. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  62. package/dist/esm/count-fetchers.d.ts.map +1 -1
  63. package/dist/esm/count-fetchers.js +1 -1
  64. package/dist/esm/count-fetchers.js.map +1 -1
  65. package/dist/esm/data-source-fetchers.d.ts +4 -0
  66. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  67. package/dist/esm/data-source-fetchers.js +97 -20
  68. package/dist/esm/data-source-fetchers.js.map +1 -1
  69. package/dist/esm/fetchers/postgresql.d.ts +3 -1
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +12 -4
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/raw-query.d.ts +20 -1
  74. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  75. package/dist/esm/fetchers/raw-query.js +38 -8
  76. package/dist/esm/fetchers/raw-query.js.map +1 -1
  77. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  78. package/dist/esm/fetchers/redshift.js +2 -1
  79. package/dist/esm/fetchers/redshift.js.map +1 -1
  80. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  81. package/dist/esm/fetchers/teleport.js +6 -4
  82. package/dist/esm/fetchers/teleport.js.map +1 -1
  83. package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
  84. package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
  85. package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
  86. package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
  87. package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
  88. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
  89. package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
  90. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
  91. package/dist/esm/index.js +1 -1
  92. package/dist/esm/pagination-controls.d.ts +4 -1
  93. package/dist/esm/pagination-controls.d.ts.map +1 -1
  94. package/dist/esm/pagination-controls.js +4 -1
  95. package/dist/esm/pagination-controls.js.map +1 -1
  96. package/dist/esm/pagination-plugin.js +1 -0
  97. package/dist/esm/pagination-plugin.js.map +1 -1
  98. package/dist/esm/range-control.d.ts +10 -5
  99. package/dist/esm/range-control.d.ts.map +1 -1
  100. package/dist/esm/range-control.js +23 -2
  101. package/dist/esm/range-control.js.map +1 -1
  102. package/dist/esm/transformations/blog-post.d.ts +5 -0
  103. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  104. package/dist/esm/transformations/blog-post.js +6 -2
  105. package/dist/esm/transformations/blog-post.js.map +1 -1
  106. package/dist/esm/transformations/index.d.ts +18 -2
  107. package/dist/esm/transformations/index.d.ts.map +1 -1
  108. package/dist/esm/transformations/index.js +27 -2
  109. package/dist/esm/transformations/index.js.map +1 -1
  110. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +5 -5
  112. package/src/count-fetchers.ts +10 -2
  113. package/src/data-source-fetchers.ts +144 -23
  114. package/src/fetchers/postgresql.ts +38 -9
  115. package/src/fetchers/raw-query.ts +125 -13
  116. package/src/fetchers/redshift.ts +3 -0
  117. package/src/fetchers/teleport.ts +29 -8
  118. package/src/fetchers/utils/external-read-guard.ts +289 -0
  119. package/src/fetchers/utils/table-access-guard.ts +469 -20
  120. package/src/index.ts +1 -1
  121. package/src/pagination-controls.ts +4 -1
  122. package/src/pagination-plugin.ts +1 -0
  123. package/src/range-control.ts +40 -8
  124. package/src/transformations/blog-post.ts +107 -4
  125. package/src/transformations/index.ts +44 -1
@@ -0,0 +1,29 @@
1
+ import type { DataReadExposure, ProjectDataExposure } from '@teleporthq/teleport-types'
2
+ import { DataExposure } from '@teleporthq/teleport-shared'
3
+
4
+ /**
5
+ * The exposure a project gives tables a page every visitor may open binds —
6
+ * what `DataExposure.resolveDataExposure` decides for a public list. A read
7
+ * route generated without one serves the app's own server code only.
8
+ */
9
+ export const publicRead = (overrides: Partial<DataReadExposure> = {}): DataReadExposure => ({
10
+ public: true,
11
+ anySession: false,
12
+ roles: [],
13
+ defaultLimit: DataExposure.BROWSER_READ_DEFAULT_LIMIT,
14
+ maxLimit: DataExposure.BROWSER_READ_MAX_LIMIT,
15
+ ...overrides,
16
+ })
17
+
18
+ /** `tables` all bound by a public page (and `dataSources`, by id, for the sources without tables). */
19
+ export const publicExposure = (
20
+ tables: string[],
21
+ dataSources: string[] = []
22
+ ): { dataExposure: ProjectDataExposure } => ({
23
+ dataExposure: {
24
+ tables: Object.fromEntries(
25
+ tables.map((table) => [DataExposure.tableExposureKey(null, table), publicRead()])
26
+ ),
27
+ dataSources: Object.fromEntries(dataSources.map((id) => [id, publicRead()])),
28
+ },
29
+ })
@@ -2,6 +2,7 @@
2
2
  import * as types from '@babel/types'
3
3
  import generator from '@babel/generator'
4
4
  import { generateDataSourceFetcherWithCore } from '../../src/data-source-fetchers'
5
+ import { publicExposure } from './exposure'
5
6
 
6
7
  /**
7
8
  * Runs what a generated page asks its data module for: the arguments its
@@ -113,10 +114,19 @@ export const bootProductsRoute = () => {
113
114
  user: 'teleporthq.secrets.TELEPORT_DB_USER',
114
115
  password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
115
116
  database: 'teleporthq.secrets.TELEPORT_DB_NAME',
116
- selectedTables: { teleport_products: { columns: [{ name: 'id', type: 'uuid' }] } },
117
+ // The table's schema as the editor exports it: the only columns a request may name.
118
+ selectedTables: {
119
+ teleport_products: { columns: [{ name: 'id', type: 'uuid' }, { name: 'name' }] },
120
+ },
117
121
  },
118
122
  }
119
- const code = generateDataSourceFetcherWithCore(dataSource as never, 'teleport_products')
123
+ const code = generateDataSourceFetcherWithCore(
124
+ dataSource as never,
125
+ 'teleport_products',
126
+ false,
127
+ // Bound by the store's public listing page: any visitor may read it.
128
+ publicExposure(['teleport_products'])
129
+ )
120
130
  .replace(
121
131
  "import { Client, types as pgTypes } from 'pg'",
122
132
  "const { Client, types: pgTypes } = require('pg')"
@@ -32,11 +32,13 @@ const DETAILS = { details: true }
32
32
 
33
33
  const CONTENTS_FIXTURES: Array<{ name: string; html: string; sections: string[][] }> = [
34
34
  {
35
- name: 'a help article: its h2 sections, not the h3 under them, never the h1',
35
+ // A body's own h1 is demoted to a section (the page title is the one h1).
36
+ name: 'a help article: its h2 sections, not the h3 under them; a body h1 is a section too',
36
37
  html:
37
38
  '<h1>Title</h1><p>Intro</p><h2>Before you start</h2><p>x</p><h2>Steps</h2>' +
38
39
  '<h3>Open the panel</h3><h2>Check that it worked</h2><h2>Troubleshooting</h2>',
39
40
  sections: [
41
+ ['title', 'Title'],
40
42
  ['before-you-start', 'Before you start'],
41
43
  ['steps', 'Steps'],
42
44
  ['check-that-it-worked', 'Check that it worked'],
@@ -174,7 +174,8 @@ describe('blog post transform — heading anchors', () => {
174
174
  `<${tag}${attrs}><a class="tq-heading-link" href="#${id}">${text}</a>` +
175
175
  `<a class="tq-heading-anchor" href="#${id}" aria-hidden="true" tabindex="-1">#</a></${tag}>`
176
176
  expect(built.content).toBe(
177
- '<h1>Title</h1>' +
177
+ // The body's h1 is demoted to a section like any other (the page title is the one h1).
178
+ '<h2 id="title"><a class="tq-heading-link" href="#title">Title</a><a class="tq-heading-anchor" href="#title" aria-hidden="true" tabindex="-1">#</a></h2>' +
178
179
  anchored('h2', ' id="getting-started"', 'getting-started', 'Getting Started') +
179
180
  anchored('h3', ' class="x" id="custom"', 'custom', 'Café &amp; Crème') +
180
181
  anchored('h2', ' id="getting-started-3"', 'getting-started-3', 'Getting started') +
@@ -0,0 +1,124 @@
1
+ import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+
4
+ /**
5
+ * What the post page's head, structured data and "Updated on" line read from
6
+ * a post: meta fields that always have a value, dates as ISO strings, a
7
+ * sharing picture only when the post has one of its own, the slug per
8
+ * locale, the FAQ pairs of the body, and one H1 at most on the page.
9
+ */
10
+
11
+ const buildPost = (
12
+ options: Parameters<typeof generateBlogPostTransformationCode>[0] = {}
13
+ ): ((record: unknown, callOptions?: unknown) => Record<string, unknown>) => {
14
+ const code =
15
+ generateSharedTransformationCode() + '\n' + generateBlogPostTransformationCode(options)
16
+ return new Function(code + '\nreturn buildBlogPost;')() as (
17
+ record: unknown,
18
+ callOptions?: unknown
19
+ ) => Record<string, unknown>
20
+ }
21
+
22
+ const words = (count: number) => Array.from({ length: count }, (_, i) => `word${i}`).join(' ')
23
+ const POST = { id: 'p1', title: 'Connect a domain', slug: 'connect-a-domain', status: 'published' }
24
+
25
+ describe('meta fields always have a value', () => {
26
+ it('falls back to the title, the excerpt, then the opening of the body', () => {
27
+ expect(buildPost()({ ...POST, meta_title: ' Own title ' }).metaTitle).toBe(' Own title ')
28
+ expect(buildPost()({ ...POST }).metaTitle).toBe('Connect a domain')
29
+ expect(buildPost()({ ...POST, excerpt: 'Short summary.' }).metaDescription).toBe(
30
+ 'Short summary.'
31
+ )
32
+ const fromBody = buildPost()({ ...POST, content: `<p>${words(60)}</p>` })
33
+ .metaDescription as string
34
+ expect(fromBody.length).toBeLessThanOrEqual(155)
35
+ expect(fromBody).toMatch(/^word0 word1/)
36
+ expect(fromBody.split(' ').every((word) => /^word\d+$/.test(word))).toBe(true)
37
+ expect(buildPost()({ ...POST }).metaDescription).toBeNull()
38
+ })
39
+ })
40
+
41
+ describe('dates and the updated line', () => {
42
+ it('gives ISO dates and shows "updated" only a day after publishing', () => {
43
+ const post = buildPost()({
44
+ ...POST,
45
+ published_at: '2026-03-01T10:00:00.000Z',
46
+ updated_at: '2026-03-03T10:00:00.000Z',
47
+ })
48
+ expect(post.publishedAtIso).toBe('2026-03-01T10:00:00.000Z')
49
+ expect(post.updatedAtIso).toBe('2026-03-03T10:00:00.000Z')
50
+ expect(post.updatedAfterPublishIso).toBe('2026-03-03T10:00:00.000Z')
51
+ const fresh = buildPost()({
52
+ ...POST,
53
+ published_at: '2026-03-01T10:00:00.000Z',
54
+ updated_at: '2026-03-01T20:00:00.000Z',
55
+ })
56
+ expect(fresh.updatedAfterPublishIso).toBeNull()
57
+ expect(buildPost()({ ...POST, status: 'draft' }).publishedAtIso).toBeNull()
58
+ })
59
+ })
60
+
61
+ describe('what the head shares', () => {
62
+ it('shares the post picture only when it has one of its own', () => {
63
+ expect(buildPost()({ ...POST, featured_image_url: 'https://cdn/x.jpg' }).socialImageUrl).toBe(
64
+ 'https://cdn/x.jpg'
65
+ )
66
+ const standIn = buildPost()({ ...POST })
67
+ expect(String(standIn.featuredImageUrl)).toMatch(/^data:image/)
68
+ expect(standIn.socialImageUrl).toBeNull()
69
+ })
70
+
71
+ it('counts the words, joins the tags and names the primary category', () => {
72
+ const post = buildPost({
73
+ categories: [{ id: 'c1', name: 'Guides', slug: 'guides' } as never],
74
+ })({
75
+ ...POST,
76
+ content: `<h2>Steps</h2><p>${words(40)}</p>`,
77
+ tags: JSON.stringify(['dns', 'hosting']),
78
+ category_ids: JSON.stringify(['c1']),
79
+ })
80
+ expect(post.wordCount).toBe(41)
81
+ expect(post.tagsText).toBe('dns, hosting')
82
+ expect(post.categoryName).toBe('Guides')
83
+ expect(post.categorySlug).toBe('guides')
84
+ expect(buildPost()({ ...POST }).tagsText).toBeNull()
85
+ expect(buildPost()({ ...POST }).categorySlug).toBeNull()
86
+ })
87
+
88
+ it('names the slug per locale from the translated columns', () => {
89
+ const post = buildPost({ locales: ['en', 'ro', 'de'] })(
90
+ { ...POST, ro_slug: 'conecteaza-un-domeniu', de_slug: '' },
91
+ { mainLanguage: 'en', currentLanguage: 'ro' }
92
+ )
93
+ expect(post.slugByLocale).toEqual({ en: 'connect-a-domain', ro: 'conecteaza-un-domeniu' })
94
+ expect(buildPost()({ ...POST }).slugByLocale).toEqual({})
95
+ })
96
+ })
97
+
98
+ describe('the body on the page', () => {
99
+ it('reads the FAQ pairs out of the body, text only', () => {
100
+ const post = buildPost()({
101
+ ...POST,
102
+ content:
103
+ '<p>Intro</p><section class="tq-faq"><h2>FAQ</h2>' +
104
+ '<details><summary>Is it <em>free</em>?</summary><p>Yes, <a href="/x">mostly</a>.</p></details>' +
105
+ '<details><summary>How long?</summary><p>A day.</p><p>Sometimes two.</p></details>' +
106
+ '<details><summary></summary><p>No question.</p></details>' +
107
+ '</section>',
108
+ })
109
+ expect(post.faq).toEqual([
110
+ { question: 'Is it free?', answer: 'Yes, mostly.' },
111
+ { question: 'How long?', answer: 'A day. Sometimes two.' },
112
+ ])
113
+ expect(buildPost()({ ...POST, content: '<p>No faq</p>' }).faq).toEqual([])
114
+ })
115
+
116
+ it('demotes a body H1 to an H2: the page title is the one H1', () => {
117
+ const post = buildPost({ contents: true })(
118
+ { ...POST, content: '<h1 class="x">Top</h1><p>a</p><h2>Next</h2>' },
119
+ { details: true }
120
+ )
121
+ expect(post.content).toMatch(/<h2 id="top" class="x">Top<\/h2>/)
122
+ expect(post.content).not.toMatch(/<h1[\s>]/)
123
+ })
124
+ })
@@ -1,6 +1,14 @@
1
1
  /* tslint:disable:function-constructor max-classes-per-file */
2
2
  import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+ import { publicExposure } from './_helpers/exposure'
3
4
  import { generateRawQueryFetcher } from '../src/fetchers/raw-query'
5
+ import {
6
+ isSecurityRuntimeSpecifier,
7
+ loadSecurityRuntime,
8
+ } from '../../teleport-plugin-next-workflows/__tests__/_helpers/load-security-runtime'
9
+
10
+ // The data routes compare the app secret with the shared runtime's safeEqual.
11
+ const securityRuntime = loadSecurityRuntime()
4
12
 
5
13
  /**
6
14
  * The per-table read routes served whole tables to anyone: every draft,
@@ -55,15 +63,56 @@ function bootTableRoute(tableName: string, options: BootOptions = {}) {
55
63
  user: 'teleporthq.secrets.TELEPORT_DB_USER',
56
64
  password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
57
65
  database: 'teleporthq.secrets.TELEPORT_DB_NAME',
58
- selectedTables: { [tableName]: { columns: [{ name: 'id', type: 'uuid' }] } },
66
+ // The table's schema as the editor exports it: the only columns a request may name.
67
+ selectedTables: {
68
+ [tableName]: {
69
+ columns: [
70
+ { name: 'id', type: 'uuid' },
71
+ { name: 'title' },
72
+ { name: 'name' },
73
+ { name: 'status' },
74
+ { name: 'slug' },
75
+ { name: 'author_email' },
76
+ { name: 'created_at' },
77
+ { name: 'price' },
78
+ { name: 'code' },
79
+ { name: 'email' },
80
+ { name: 'role' },
81
+ ],
82
+ },
83
+ },
59
84
  }
60
- : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
85
+ : {
86
+ host: 'h',
87
+ port: 5432,
88
+ user: 'u',
89
+ password: 'p',
90
+ database: 'd',
91
+ selectedTables: {
92
+ [tableName]: {
93
+ columns: [
94
+ { name: 'id', type: 'uuid' },
95
+ { name: 'title' },
96
+ { name: 'name' },
97
+ { name: 'status' },
98
+ { name: 'slug' },
99
+ { name: 'author_email' },
100
+ { name: 'created_at' },
101
+ { name: 'price' },
102
+ { name: 'code' },
103
+ { name: 'email' },
104
+ { name: 'role' },
105
+ ],
106
+ },
107
+ },
108
+ },
61
109
  }
62
110
  const esm = generateDataSourceFetcherWithCore(
63
111
  dataSource as never,
64
112
  tableName,
65
113
  false,
66
- { trustedReaderRoles: ADMIN_ROLES },
114
+ // Bound by a public page: what is left to decide is the row policy.
115
+ { trustedReaderRoles: ADMIN_ROLES, ...publicExposure([tableName]) },
67
116
  options.cache
68
117
  )
69
118
  const code = esm
@@ -133,6 +182,9 @@ function bootTableRoute(tableName: string, options: BootOptions = {}) {
133
182
  getToken: async () => (session ? { sub: session.sub || 'u1', role: session.role } : null),
134
183
  }
135
184
  }
185
+ if (isSecurityRuntimeSpecifier(name)) {
186
+ return securityRuntime
187
+ }
136
188
  throw new Error('unexpected require: ' + name)
137
189
  }
138
190
  const moduleObj: { exports: any } = { exports: {} }
@@ -585,9 +637,11 @@ describe('the response cache never hands one view to the other', () => {
585
637
  await posts.run({})
586
638
  expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts', sMaxAge: 30 }])
587
639
 
640
+ // The store's own server code reads whole tables (a browser's read is
641
+ // capped), so it keeps a cache of its own as well.
588
642
  posts.cacheCalls.length = 0
589
643
  await posts.serverFetch()
590
- expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:trusted', sMaxAge: 0 }])
644
+ expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:server', sMaxAge: 0 }])
591
645
 
592
646
  posts.cacheCalls.length = 0
593
647
  await posts.run({ session: { role: 'admin' } })
@@ -598,14 +652,17 @@ describe('the response cache never hands one view to the other', () => {
598
652
  expect(posts.cacheCalls).toEqual([{ scope: 'ds-1:teleport_blog_posts:count', sMaxAge: 30 }])
599
653
  })
600
654
 
601
- it('lets every caller share one view of a table that answers everyone alike', async () => {
655
+ it('lets every visitor share one view of a table that answers everyone alike', async () => {
602
656
  const members = bootTableRoute('team_members', {
603
657
  cache: { scope: 'ds-1:team_members', ttlSeconds: 60 },
604
658
  })
605
659
  members.cacheCalls.length = 0
606
660
  await members.serverFetch()
607
661
  await members.run({})
662
+ await members.run({ session: { role: 'member' } })
663
+ // The server's whole-table read keeps its own entry; every browser shares one.
608
664
  expect(members.cacheCalls.map((call) => call.scope)).toEqual([
665
+ 'ds-1:team_members:server',
609
666
  'ds-1:team_members',
610
667
  'ds-1:team_members',
611
668
  ])
@@ -651,6 +708,9 @@ describe('raw-query state routes bind {{Current User.*}} to the session', () =>
651
708
  if (name === 'next-auth/jwt') {
652
709
  return { getToken: async () => session }
653
710
  }
711
+ if (isSecurityRuntimeSpecifier(name)) {
712
+ return securityRuntime
713
+ }
654
714
  throw new Error('unexpected require: ' + name)
655
715
  }
656
716
  const moduleObj: { exports: any } = { exports: {} }
@@ -1,6 +1,7 @@
1
1
  import { UIDLDataSource } from '@teleporthq/teleport-types'
2
2
  import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
3
  import { mergeServerCacheOptions } from '../src/cache/config'
4
+ import { publicExposure } from './_helpers/exposure'
4
5
 
5
6
  const dataSource: UIDLDataSource = {
6
7
  id: 'ds_1',
@@ -11,6 +12,9 @@ const dataSource: UIDLDataSource = {
11
12
 
12
13
  const CACHE = { scope: 'ds_1:teleport_products', ttlSeconds: 60 }
13
14
 
15
+ // The products table as a project with a public shop page exposes it.
16
+ const PUBLIC = publicExposure(['teleport_products', 'products'])
17
+
14
18
  describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
15
19
  /**
16
20
  * The regression pin. A project that never asked for caching must generate
@@ -36,7 +40,7 @@ describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
36
40
  dataSource,
37
41
  'teleport_products',
38
42
  false,
39
- {},
43
+ PUBLIC,
40
44
  CACHE
41
45
  )
42
46
 
@@ -50,18 +54,55 @@ describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
50
54
  )
51
55
  })
52
56
 
53
- it('wraps a route without the row policy in one shared cache, as before', () => {
57
+ it('an external driver route carries the read guard, so its cache is split by view too', () => {
54
58
  const mysql: UIDLDataSource = {
55
59
  id: 'ds_2',
56
60
  name: 'Shop',
57
61
  type: 'mysql',
58
62
  config: { host: 'h', port: 3306, user: 'u', password: 'p', database: 'd' },
59
63
  }
60
- const code = generateDataSourceFetcherWithCore(mysql, 'products', false, {}, CACHE)
64
+ const code = generateDataSourceFetcherWithCore(mysql, 'products', false, PUBLIC, CACHE)
61
65
 
62
- expect(code).toContain('const cachedHandler = tqWithCache(handler, __tqDataCache)')
63
- expect(code).toContain('const cachedGetCount = tqWithCache(getCount, __tqCountCache)')
64
- expect(code).not.toContain('__tqCacheByView')
66
+ expect(code).toContain('const cachedHandler = __tqCacheByView(handler, __tqDataCache)')
67
+ expect(code).toContain('const cachedGetCount = __tqCacheByView(getCount, __tqCountCache)')
68
+ expect(code).toContain('__taGuardedDriverRead(req, res, __tqUnguardedHandler')
69
+ })
70
+
71
+ /**
72
+ * A table only some sessions may read: a cached answer filled by one of them
73
+ * must never reach a visitor the route would refuse, and never a CDN.
74
+ */
75
+ it('checks the session before the cache, and shares nothing, for a table not every visitor may read', () => {
76
+ const code = generateDataSourceFetcherWithCore(
77
+ dataSource,
78
+ 'teleport_products',
79
+ false,
80
+ {},
81
+ { ...CACHE, sMaxAge: 30, staleWhileRevalidate: 300 }
82
+ )
83
+
84
+ expect(code).toContain(
85
+ 'const cachedHandler = __tqExposureFirst(__tqCacheByView(handler, __tqDataCache))'
86
+ )
87
+ expect(code).toContain(
88
+ 'const cachedGetCount = __tqExposureFirst(__tqCacheByView(getCount, __tqCountCache))'
89
+ )
90
+ expect(code).toContain('"sMaxAge":0')
91
+ expect(code).not.toContain('"sMaxAge":30')
92
+ })
93
+
94
+ /** The app's own server code reads whole tables; a browser's read is capped. */
95
+ it('never shares a cache entry between a server-side read and a browser read', () => {
96
+ const code = generateDataSourceFetcherWithCore(
97
+ dataSource,
98
+ 'teleport_products',
99
+ false,
100
+ PUBLIC,
101
+ CACHE
102
+ )
103
+
104
+ expect(code).toContain("scope: opts.scope + ':server'")
105
+ expect(code).toContain('if (__taIsInternalRequest(req)) return serverView(req, res)')
65
106
  })
66
107
 
67
108
  /**
@@ -117,7 +158,7 @@ describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
117
158
  dataSource,
118
159
  'teleport_products',
119
160
  false,
120
- {},
161
+ PUBLIC,
121
162
  {
122
163
  ...CACHE,
123
164
  sMaxAge: 30,