@teleporthq/teleport-plugin-next-data-source 0.44.14 → 0.44.17
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/exposure.ts +29 -0
- package/__tests__/_helpers/first-page-runtime.ts +12 -2
- package/__tests__/blog-post-contents.test.ts +3 -1
- package/__tests__/blog-post-page-enrichment.test.ts +2 -1
- package/__tests__/blog-post-seo-fields.test.ts +124 -0
- package/__tests__/browser-row-policy.test.ts +65 -5
- package/__tests__/data-source-cache-wrapping.test.ts +48 -7
- package/__tests__/external-read-routes-guard.test.ts +991 -0
- package/__tests__/null-filter-where.test.ts +19 -2
- package/__tests__/range-control.test.ts +29 -0
- package/__tests__/search-relevance.test.ts +42 -6
- package/__tests__/table-read-route-protected-tables.test.ts +54 -4
- package/dist/cjs/count-fetchers.d.ts.map +1 -1
- package/dist/cjs/count-fetchers.js +1 -1
- package/dist/cjs/count-fetchers.js.map +1 -1
- package/dist/cjs/data-source-fetchers.d.ts +4 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +97 -20
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts +3 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +11 -3
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +20 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +37 -7
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +2 -1
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +5 -3
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/pagination-controls.d.ts +4 -1
- package/dist/cjs/pagination-controls.d.ts.map +1 -1
- package/dist/cjs/pagination-controls.js +4 -1
- package/dist/cjs/pagination-controls.js.map +1 -1
- package/dist/cjs/pagination-plugin.js +1 -0
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/range-control.d.ts +10 -5
- package/dist/cjs/range-control.d.ts.map +1 -1
- package/dist/cjs/range-control.js +23 -2
- package/dist/cjs/range-control.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +5 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +6 -2
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +18 -2
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +26 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/count-fetchers.d.ts.map +1 -1
- package/dist/esm/count-fetchers.js +1 -1
- package/dist/esm/count-fetchers.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts +4 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +97 -20
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +3 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +12 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +20 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +38 -8
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +2 -1
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +6 -4
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
- package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/pagination-controls.d.ts +4 -1
- package/dist/esm/pagination-controls.d.ts.map +1 -1
- package/dist/esm/pagination-controls.js +4 -1
- package/dist/esm/pagination-controls.js.map +1 -1
- package/dist/esm/pagination-plugin.js +1 -0
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/range-control.d.ts +10 -5
- package/dist/esm/range-control.d.ts.map +1 -1
- package/dist/esm/range-control.js +23 -2
- package/dist/esm/range-control.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +5 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +6 -2
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +18 -2
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +27 -2
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/count-fetchers.ts +10 -2
- package/src/data-source-fetchers.ts +144 -23
- package/src/fetchers/postgresql.ts +38 -9
- package/src/fetchers/raw-query.ts +125 -13
- package/src/fetchers/redshift.ts +3 -0
- package/src/fetchers/teleport.ts +29 -8
- package/src/fetchers/utils/external-read-guard.ts +289 -0
- package/src/fetchers/utils/table-access-guard.ts +469 -20
- package/src/index.ts +1 -1
- package/src/pagination-controls.ts +4 -1
- package/src/pagination-plugin.ts +1 -0
- package/src/range-control.ts +40 -8
- package/src/transformations/blog-post.ts +107 -4
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
'
|
|
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é & 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
|
-
|
|
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
|
-
: {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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('
|
|
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,
|
|
64
|
+
const code = generateDataSourceFetcherWithCore(mysql, 'products', false, PUBLIC, CACHE)
|
|
61
65
|
|
|
62
|
-
expect(code).toContain('const cachedHandler =
|
|
63
|
-
expect(code).toContain('const cachedGetCount =
|
|
64
|
-
expect(code).
|
|
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,
|