@teleporthq/teleport-plugin-next-data-source 0.43.75 → 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.
Files changed (199) hide show
  1. package/__tests__/_helpers/first-page-runtime.ts +184 -0
  2. package/__tests__/_helpers/pagination-widget-fixture.ts +154 -5
  3. package/__tests__/booking-item-transform.test.ts +120 -0
  4. package/__tests__/browser-row-policy.test.ts +210 -4
  5. package/__tests__/data-source-request-locale.test.ts +4 -3
  6. package/__tests__/fetchers.test.ts +7 -3
  7. package/__tests__/fixed-list-sort.test.ts +1 -1
  8. package/__tests__/infinite-scroll.test.ts +363 -9
  9. package/__tests__/list-sort-order.test.ts +61 -0
  10. package/__tests__/null-filter-where.test.ts +132 -0
  11. package/__tests__/page-data-visitor-read.test.ts +196 -0
  12. package/__tests__/pg-date-parser.test.ts +73 -0
  13. package/__tests__/plain-list-sort.test.ts +2 -2
  14. package/__tests__/plain-list-starting-sort.test.ts +323 -0
  15. package/__tests__/product-price-sort.ts +11 -6
  16. package/__tests__/range-control.test.ts +159 -0
  17. package/__tests__/refetch-loading-state.test.ts +158 -2
  18. package/__tests__/search-input-marker.test.ts +86 -0
  19. package/__tests__/search-url-sync.test.ts +34 -8
  20. package/__tests__/starting-sort-first-page.test.ts +285 -0
  21. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  22. package/dist/cjs/cache/ast.d.ts +2 -0
  23. package/dist/cjs/cache/ast.d.ts.map +1 -1
  24. package/dist/cjs/cache/ast.js +5 -2
  25. package/dist/cjs/cache/ast.js.map +1 -1
  26. package/dist/cjs/count-effect.d.ts +7 -1
  27. package/dist/cjs/count-effect.d.ts.map +1 -1
  28. package/dist/cjs/count-effect.js +15 -5
  29. package/dist/cjs/count-effect.js.map +1 -1
  30. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  31. package/dist/cjs/data-source-fetchers.js +10 -2
  32. package/dist/cjs/data-source-fetchers.js.map +1 -1
  33. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  34. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  35. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  36. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  37. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  38. package/dist/cjs/fetchers/postgresql.js +3 -1
  39. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  40. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  41. package/dist/cjs/fetchers/raw-query.js +2 -1
  42. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  43. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  44. package/dist/cjs/fetchers/teleport.js +4 -2
  45. package/dist/cjs/fetchers/teleport.js.map +1 -1
  46. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/utils/browser-row-policy.js +5 -1
  48. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  49. package/dist/cjs/index.d.ts.map +1 -1
  50. package/dist/cjs/index.js +14 -2
  51. package/dist/cjs/index.js.map +1 -1
  52. package/dist/cjs/infinite-scroll.d.ts +89 -4
  53. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  54. package/dist/cjs/infinite-scroll.js +243 -33
  55. package/dist/cjs/infinite-scroll.js.map +1 -1
  56. package/dist/cjs/list-sort-order.d.ts +14 -0
  57. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  58. package/dist/cjs/list-sort-order.js +36 -0
  59. package/dist/cjs/list-sort-order.js.map +1 -0
  60. package/dist/cjs/loading-state.d.ts +23 -2
  61. package/dist/cjs/loading-state.d.ts.map +1 -1
  62. package/dist/cjs/loading-state.js +50 -15
  63. package/dist/cjs/loading-state.js.map +1 -1
  64. package/dist/cjs/pagination-controls.d.ts +22 -3
  65. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  66. package/dist/cjs/pagination-controls.js +29 -3
  67. package/dist/cjs/pagination-controls.js.map +1 -1
  68. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  69. package/dist/cjs/pagination-plugin.js +336 -164
  70. package/dist/cjs/pagination-plugin.js.map +1 -1
  71. package/dist/cjs/range-control.d.ts +45 -0
  72. package/dist/cjs/range-control.d.ts.map +1 -0
  73. package/dist/cjs/range-control.js +165 -0
  74. package/dist/cjs/range-control.js.map +1 -0
  75. package/dist/cjs/row-order.d.ts +3 -2
  76. package/dist/cjs/row-order.d.ts.map +1 -1
  77. package/dist/cjs/row-order.js +5 -4
  78. package/dist/cjs/row-order.js.map +1 -1
  79. package/dist/cjs/search-input.d.ts +42 -0
  80. package/dist/cjs/search-input.d.ts.map +1 -0
  81. package/dist/cjs/search-input.js +158 -0
  82. package/dist/cjs/search-input.js.map +1 -0
  83. package/dist/cjs/sort-utils.d.ts +53 -1
  84. package/dist/cjs/sort-utils.d.ts.map +1 -1
  85. package/dist/cjs/sort-utils.js +105 -13
  86. package/dist/cjs/sort-utils.js.map +1 -1
  87. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  88. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  89. package/dist/cjs/transformations/booking-item.js +27 -0
  90. package/dist/cjs/transformations/booking-item.js.map +1 -0
  91. package/dist/cjs/transformations/index.d.ts +4 -2
  92. package/dist/cjs/transformations/index.d.ts.map +1 -1
  93. package/dist/cjs/transformations/index.js +14 -5
  94. package/dist/cjs/transformations/index.js.map +1 -1
  95. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  96. package/dist/cjs/utils.d.ts +3 -2
  97. package/dist/cjs/utils.d.ts.map +1 -1
  98. package/dist/cjs/utils.js +48 -18
  99. package/dist/cjs/utils.js.map +1 -1
  100. package/dist/esm/cache/ast.d.ts +2 -0
  101. package/dist/esm/cache/ast.d.ts.map +1 -1
  102. package/dist/esm/cache/ast.js +3 -1
  103. package/dist/esm/cache/ast.js.map +1 -1
  104. package/dist/esm/count-effect.d.ts +7 -1
  105. package/dist/esm/count-effect.d.ts.map +1 -1
  106. package/dist/esm/count-effect.js +15 -5
  107. package/dist/esm/count-effect.js.map +1 -1
  108. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  109. package/dist/esm/data-source-fetchers.js +10 -2
  110. package/dist/esm/data-source-fetchers.js.map +1 -1
  111. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  112. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  113. package/dist/esm/fetchers/pg-client-import.js +12 -0
  114. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  115. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  116. package/dist/esm/fetchers/postgresql.js +3 -1
  117. package/dist/esm/fetchers/postgresql.js.map +1 -1
  118. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  119. package/dist/esm/fetchers/raw-query.js +2 -1
  120. package/dist/esm/fetchers/raw-query.js.map +1 -1
  121. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  122. package/dist/esm/fetchers/teleport.js +4 -2
  123. package/dist/esm/fetchers/teleport.js.map +1 -1
  124. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  125. package/dist/esm/fetchers/utils/browser-row-policy.js +6 -2
  126. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  127. package/dist/esm/index.d.ts.map +1 -1
  128. package/dist/esm/index.js +14 -2
  129. package/dist/esm/index.js.map +1 -1
  130. package/dist/esm/infinite-scroll.d.ts +89 -4
  131. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  132. package/dist/esm/infinite-scroll.js +237 -32
  133. package/dist/esm/infinite-scroll.js.map +1 -1
  134. package/dist/esm/list-sort-order.d.ts +14 -0
  135. package/dist/esm/list-sort-order.d.ts.map +1 -0
  136. package/dist/esm/list-sort-order.js +32 -0
  137. package/dist/esm/list-sort-order.js.map +1 -0
  138. package/dist/esm/loading-state.d.ts +23 -2
  139. package/dist/esm/loading-state.d.ts.map +1 -1
  140. package/dist/esm/loading-state.js +50 -15
  141. package/dist/esm/loading-state.js.map +1 -1
  142. package/dist/esm/pagination-controls.d.ts +22 -3
  143. package/dist/esm/pagination-controls.d.ts.map +1 -1
  144. package/dist/esm/pagination-controls.js +28 -2
  145. package/dist/esm/pagination-controls.js.map +1 -1
  146. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  147. package/dist/esm/pagination-plugin.js +340 -168
  148. package/dist/esm/pagination-plugin.js.map +1 -1
  149. package/dist/esm/range-control.d.ts +45 -0
  150. package/dist/esm/range-control.d.ts.map +1 -0
  151. package/dist/esm/range-control.js +137 -0
  152. package/dist/esm/range-control.js.map +1 -0
  153. package/dist/esm/row-order.d.ts +3 -2
  154. package/dist/esm/row-order.d.ts.map +1 -1
  155. package/dist/esm/row-order.js +5 -4
  156. package/dist/esm/row-order.js.map +1 -1
  157. package/dist/esm/search-input.d.ts +42 -0
  158. package/dist/esm/search-input.d.ts.map +1 -0
  159. package/dist/esm/search-input.js +129 -0
  160. package/dist/esm/search-input.js.map +1 -0
  161. package/dist/esm/sort-utils.d.ts +53 -1
  162. package/dist/esm/sort-utils.d.ts.map +1 -1
  163. package/dist/esm/sort-utils.js +100 -12
  164. package/dist/esm/sort-utils.js.map +1 -1
  165. package/dist/esm/transformations/booking-item.d.ts +24 -0
  166. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  167. package/dist/esm/transformations/booking-item.js +23 -0
  168. package/dist/esm/transformations/booking-item.js.map +1 -0
  169. package/dist/esm/transformations/index.d.ts +4 -2
  170. package/dist/esm/transformations/index.d.ts.map +1 -1
  171. package/dist/esm/transformations/index.js +14 -5
  172. package/dist/esm/transformations/index.js.map +1 -1
  173. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  174. package/dist/esm/utils.d.ts +3 -2
  175. package/dist/esm/utils.d.ts.map +1 -1
  176. package/dist/esm/utils.js +49 -19
  177. package/dist/esm/utils.js.map +1 -1
  178. package/package.json +5 -5
  179. package/src/cache/ast.ts +4 -1
  180. package/src/count-effect.ts +31 -8
  181. package/src/data-source-fetchers.ts +33 -14
  182. package/src/fetchers/pg-client-import.ts +16 -0
  183. package/src/fetchers/postgresql.ts +15 -9
  184. package/src/fetchers/raw-query.ts +2 -1
  185. package/src/fetchers/teleport.ts +19 -10
  186. package/src/fetchers/utils/browser-row-policy.ts +79 -5
  187. package/src/index.ts +8 -2
  188. package/src/infinite-scroll.ts +420 -81
  189. package/src/list-sort-order.ts +34 -0
  190. package/src/loading-state.ts +85 -13
  191. package/src/pagination-controls.ts +26 -3
  192. package/src/pagination-plugin.ts +532 -218
  193. package/src/range-control.ts +207 -0
  194. package/src/row-order.ts +5 -4
  195. package/src/search-input.ts +190 -0
  196. package/src/sort-utils.ts +164 -21
  197. package/src/transformations/booking-item.ts +71 -0
  198. package/src/transformations/index.ts +25 -2
  199. package/src/utils.ts +55 -18
@@ -0,0 +1,196 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import {
4
+ ChunkType,
5
+ FileType,
6
+ ComponentStructure,
7
+ ChunkDefinition,
8
+ UIDLAuthentication,
9
+ UIDLDataSource,
10
+ } from '@teleporthq/teleport-types'
11
+ import { createNextPagesDataSourcePlugin } from '../src/index'
12
+
13
+ /**
14
+ * A page's lists are pre-rendered from rows its getStaticProps reads, and
15
+ * those rows reach whoever opens the page — and anyone who asks
16
+ * `/_next/data` for its data. They were read as the store: a store's draft
17
+ * and inactive products were painted on the products page, whose page 2 the
18
+ * visitor's own browser then fetched from the active products only. A page
19
+ * now reads `{ as: 'visitor' }`; only an admin page nobody but the store's
20
+ * trusted readers may open reads as the store.
21
+ */
22
+
23
+ const DS_ID = 'ds1'
24
+ const TABLE = 'teleport_products'
25
+
26
+ const dataSource: UIDLDataSource = {
27
+ id: DS_ID,
28
+ name: 'TeleportHQ database',
29
+ type: 'teleport',
30
+ config: { selectedTables: { [TABLE]: { columns: [{ name: 'id' }, { name: 'name' }] } } },
31
+ }
32
+
33
+ const jsx = (name: string, attrs: types.JSXAttribute[], children: types.JSXElement[] = []) =>
34
+ types.jsxElement(
35
+ types.jsxOpeningElement(types.jsxIdentifier(name), attrs, children.length === 0),
36
+ children.length === 0 ? null : types.jsxClosingElement(types.jsxIdentifier(name)),
37
+ children,
38
+ children.length === 0
39
+ )
40
+
41
+ const makeListProvider = (): types.JSXElement =>
42
+ jsx('DataProvider', [
43
+ types.jsxAttribute(
44
+ types.jsxIdentifier('name'),
45
+ types.jsxExpressionContainer(types.stringLiteral('items'))
46
+ ),
47
+ types.jsxAttribute(
48
+ types.jsxIdentifier('resourceDefinition'),
49
+ types.jsxExpressionContainer(
50
+ types.objectExpression([
51
+ types.objectProperty(types.stringLiteral('dataSourceId'), types.stringLiteral(DS_ID)),
52
+ types.objectProperty(types.stringLiteral('tableName'), types.stringLiteral(TABLE)),
53
+ types.objectProperty(
54
+ types.stringLiteral('dataSourceType'),
55
+ types.stringLiteral('teleport')
56
+ ),
57
+ ])
58
+ )
59
+ ),
60
+ types.jsxAttribute(
61
+ types.jsxIdentifier('renderSuccess'),
62
+ types.jsxExpressionContainer(
63
+ types.arrowFunctionExpression(
64
+ [types.identifier('items')],
65
+ jsx('Repeater', [
66
+ types.jsxAttribute(
67
+ types.jsxIdentifier('items'),
68
+ types.jsxExpressionContainer(types.identifier('items'))
69
+ ),
70
+ types.jsxAttribute(
71
+ types.jsxIdentifier('renderItem'),
72
+ types.jsxExpressionContainer(
73
+ types.arrowFunctionExpression([types.identifier('product')], jsx('div', []))
74
+ )
75
+ ),
76
+ ])
77
+ )
78
+ )
79
+ ),
80
+ ])
81
+
82
+ // tslint:disable-next-line:no-any
83
+ const makeListNode = (paginated: boolean): any => ({
84
+ type: 'data-source-list',
85
+ content: {
86
+ renderPropIdentifier: 'items',
87
+ resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
88
+ resource: { id: 'TQ_products', params: {} },
89
+ nodes: {
90
+ success: {
91
+ type: 'cms-list-repeater',
92
+ content: {
93
+ renderPropIdentifier: 'product',
94
+ paginated,
95
+ perPage: 20,
96
+ searchEnabled: false,
97
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ })
103
+
104
+ const protection = (route: string, allowedRoles: string[]) => ({
105
+ requiresAuth: true,
106
+ allowedRoles,
107
+ pageName: route.split('/').pop(),
108
+ route,
109
+ })
110
+
111
+ // An admin panel admitting admins, with one screen staff may open too.
112
+ const AUTH = {
113
+ enabled: true,
114
+ roles: ['admin', 'staff', 'user'],
115
+ pageProtection: {
116
+ 'p-products': protection('/admin/products', ['admin']),
117
+ 'p-dashboard': protection('/admin/dashboard', ['admin', 'staff']),
118
+ },
119
+ folderProtection: {
120
+ 'f-admin': {
121
+ requiresAuth: true,
122
+ allowedRoles: ['admin'],
123
+ folderName: 'admin-panel',
124
+ parentId: 'pages',
125
+ children: { 'p-products': 'page', 'p-dashboard': 'page' },
126
+ },
127
+ },
128
+ } as unknown as UIDLAuthentication
129
+
130
+ const runPage = async (
131
+ paginated: boolean,
132
+ outputOptions: { folderPath: string[]; fileName: string; pageId?: string }
133
+ ): Promise<string> => {
134
+ const provider = makeListProvider()
135
+ const chunk: ChunkDefinition = {
136
+ name: 'jsx-component',
137
+ type: ChunkType.AST,
138
+ fileType: FileType.JS,
139
+ linkAfter: [],
140
+ content: types.variableDeclaration('const', [
141
+ types.variableDeclarator(
142
+ types.identifier('ProductsList'),
143
+ types.arrowFunctionExpression(
144
+ [types.identifier('props')],
145
+ types.blockStatement([types.returnStatement(jsx('div', [], [provider]))])
146
+ )
147
+ ),
148
+ ]),
149
+ meta: { nodesLookup: { provider } },
150
+ }
151
+ const structure: ComponentStructure = {
152
+ uidl: { name: 'ProductsList', node: makeListNode(paginated), outputOptions },
153
+ chunks: [chunk],
154
+ dependencies: {},
155
+ options: { dataSources: { [DS_ID]: dataSource }, extractedResources: {}, auth: AUTH },
156
+ } as never
157
+ await createNextPagesDataSourcePlugin()(structure)
158
+ const getStaticProps = structure.chunks.find((c) => c.name === 'getStaticProps')
159
+ return generator(getStaticProps!.content as types.Node).code.replace(/\s+/g, ' ')
160
+ }
161
+
162
+ describe("a page's own data", () => {
163
+ it('reads a paginated list page as a visitor: the first page and the count', async () => {
164
+ const staticProps = await runPage(true, { folderPath: [], fileName: 'products' })
165
+
166
+ expect(staticProps).toContain(
167
+ 'teleportTeleportProductsDs1.fetchData({ page: 1, perPage: 20 }, { as: "visitor" })'
168
+ )
169
+ expect(staticProps).toContain('teleportTeleportProductsDs1.fetchCount({}, { as: "visitor" })')
170
+ })
171
+
172
+ it('reads a plain list page as a visitor', async () => {
173
+ const staticProps = await runPage(false, { folderPath: [], fileName: 'products' })
174
+
175
+ expect(staticProps).toContain('teleportTeleportProductsDs1.fetchData({}, { as: "visitor" })')
176
+ })
177
+
178
+ it('reads as the store only on an admin page nobody but its trusted readers opens', async () => {
179
+ const admin = await runPage(true, {
180
+ folderPath: ['admin'],
181
+ fileName: 'products',
182
+ pageId: 'p-products',
183
+ })
184
+ expect(admin).toContain('teleportTeleportProductsDs1.fetchData({ page: 1, perPage: 20 })')
185
+ expect(admin).toContain('teleportTeleportProductsDs1.fetchCount()')
186
+ expect(admin).not.toContain('visitor')
187
+
188
+ // Staff may open this one, and staff read what a visitor reads.
189
+ const staffScreen = await runPage(true, {
190
+ folderPath: ['admin'],
191
+ fileName: 'dashboard',
192
+ pageId: 'p-dashboard',
193
+ })
194
+ expect(staffScreen).toContain('fetchData({ page: 1, perPage: 20 }, { as: "visitor" })')
195
+ })
196
+ })
@@ -0,0 +1,73 @@
1
+ /* tslint:disable:function-constructor */
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+
4
+ /**
5
+ * A Postgres DATE is a calendar day. node-pg reads it as midnight in the
6
+ * server's time zone, so a server east of UTC served `2026-10-09` as
7
+ * `2026-10-08T21:00:00.000Z`: the list showed the day before, and an edit form
8
+ * that loaded the row saved that day back. Every Postgres fetcher keeps the
9
+ * day as its own text; a stand-in driver without `types` still loads.
10
+ */
11
+
12
+ type ParserSpy = Array<{ oid: number; parse: (value: string) => unknown }>
13
+
14
+ const bootFetcher = (type: 'teleport' | 'postgresql', withTypes: boolean): ParserSpy => {
15
+ const dataSource = {
16
+ id: 'ds-1',
17
+ name: 'db',
18
+ type,
19
+ config:
20
+ type === 'teleport'
21
+ ? {
22
+ host: 'teleporthq.secrets.TELEPORT_DB_HOST',
23
+ port: 5432,
24
+ user: 'teleporthq.secrets.TELEPORT_DB_USER',
25
+ password: 'teleporthq.secrets.TELEPORT_DB_PASSWORD',
26
+ database: 'teleporthq.secrets.TELEPORT_DB_NAME',
27
+ selectedTables: { orders: { columns: [{ name: 'id' }, { name: 'due' }] } },
28
+ }
29
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' },
30
+ }
31
+ const code = generateDataSourceFetcherWithCore(dataSource as never, 'orders', false, {})
32
+ expect(code.startsWith("import { Client, types as pgTypes } from 'pg'")).toBe(true)
33
+ const runnable = code
34
+ .replace(
35
+ "import { Client, types as pgTypes } from 'pg'",
36
+ "const { Client, types: pgTypes } = require('pg')"
37
+ )
38
+ .replace(/^export default async function handler/m, 'async function handler')
39
+ .replace(/^export \{[^}]*\}\s*$/m, '')
40
+ .replace(/^export default \{[^}]*\}\s*$/m, '')
41
+ const registered: ParserSpy = []
42
+ const types = {
43
+ setTypeParser: (oid: number, parse: (value: string) => unknown) =>
44
+ registered.push({ oid, parse }),
45
+ }
46
+ new Function('require', 'module', 'exports', 'process', 'console', runnable)(
47
+ (name: string) => {
48
+ if (name === 'pg') {
49
+ return withTypes ? { Client: class {}, types } : { Client: class {} }
50
+ }
51
+ throw new Error('unexpected require: ' + name)
52
+ },
53
+ { exports: {} },
54
+ {},
55
+ { env: {} },
56
+ { error: () => undefined, warn: () => undefined, log: () => undefined }
57
+ )
58
+ return registered
59
+ }
60
+
61
+ describe.each(['teleport', 'postgresql'] as const)('%s fetcher DATE columns', (type) => {
62
+ it('keeps a DATE as the day it names', () => {
63
+ const registered = bootFetcher(type, true)
64
+ const date = registered.find(({ oid }) => oid === 1082)
65
+ expect(date).toBeDefined()
66
+ expect(date?.parse('2026-10-09')).toBe('2026-10-09')
67
+ expect(registered.map(({ oid }) => oid)).toEqual([1082])
68
+ })
69
+
70
+ it('loads with a driver that has no type registry', () => {
71
+ expect(bootFetcher(type, false)).toEqual([])
72
+ })
73
+ })
@@ -182,7 +182,7 @@ describe('a plain list fetches with its repeater’s sort', () => {
182
182
  it('sends a sort bound to state, and refetches when the state changes', async () => {
183
183
  const code = squash(await runPlugin('state'))
184
184
  expect(code).toContain(
185
- 'sorts: JSON.stringify([{ field: sortBy.split("-")[0], order: sortBy.split("-")[1] }])'
185
+ 'sorts: JSON.stringify([{ field: sortBy.split("-")?.[0], order: sortBy.split("-")?.[1] }])'
186
186
  )
187
187
  expect(code).toMatch(/\}\), \[sortBy\]\)\}/)
188
188
  })
@@ -200,7 +200,7 @@ describe('a plain list fetches with its repeater’s sort', () => {
200
200
  expect(code).not.toContain('field: "title"')
201
201
  })
202
202
 
203
- it('drops the prefetched rows for a sort bound to state, which the server cannot know', async () => {
203
+ it('drops the prefetched rows for a sort bound to state whose start the build cannot know', async () => {
204
204
  expect(squash(await runPlugin('state', { prefetched: true }))).not.toContain('initialData')
205
205
  expect(squash(await runPlugin('fixed', { prefetched: true }))).toContain(
206
206
  'initialData={props.items}'
@@ -0,0 +1,323 @@
1
+ /* tslint:disable:function-constructor no-any */
2
+ import * as types from '@babel/types'
3
+ import generator from '@babel/generator'
4
+ import {
5
+ ChunkDefinition,
6
+ ChunkType,
7
+ ComponentStructure,
8
+ FileType,
9
+ UIDLStateDefinition,
10
+ } from '@teleporthq/teleport-types'
11
+ import { createNextPagesDataSourcePlugin } from '../src/index'
12
+ import {
13
+ bootProductsRoute,
14
+ browserParams,
15
+ buildTimeArguments,
16
+ orderBy,
17
+ rowWindow,
18
+ } from './_helpers/first-page-runtime'
19
+
20
+ /**
21
+ * A PLAIN list — no paging, no search — sorted by the dropdown a visitor picks
22
+ * from (`sortBy.split("-")[0]`, a page state starting at "name-asc"). The
23
+ * page's getStaticProps fetched its rows unsorted and the list dropped them, so
24
+ * the server HTML carried its loading slot and the browser fetched the rows on
25
+ * load. The build now fetches them by the sort a visitor starts with, the list
26
+ * shows them while the sort is still there, and any other sort is fetched fresh
27
+ * — the rule the paged lists follow (starting-sort-first-page.test.ts).
28
+ */
29
+
30
+ const DS_ID = 'ds1'
31
+ const TABLE = 'teleport_products'
32
+
33
+ const STORE_SORT = {
34
+ sort: { type: 'expr', content: `sortBy.split("-")[0] || ''` },
35
+ sortDirection: { type: 'expr', content: `sortBy.split("-")[1] || ''` },
36
+ }
37
+
38
+ // The sort dropdown's state, as the store template declares it.
39
+ const SORT_DROPDOWN: Record<string, UIDLStateDefinition> = {
40
+ sortBy: { type: 'string', defaultValue: 'name-asc', urlSearchParamBinding: { key: 'sortBy' } },
41
+ }
42
+
43
+ const STARTING_SORTS = `(sortBy => JSON.stringify([{ field: sortBy.split("-")?.[0] || '', order: sortBy.split("-")?.[1] || '' }]))("name-asc")`
44
+
45
+ const collapse = (code: string): string => code.replace(/\s+/g, ' ')
46
+
47
+ const jsx = (name: string, attrs: types.JSXAttribute[], children: types.JSXElement[] = []) =>
48
+ types.jsxElement(
49
+ types.jsxOpeningElement(types.jsxIdentifier(name), attrs, children.length === 0),
50
+ children.length === 0 ? null : types.jsxClosingElement(types.jsxIdentifier(name)),
51
+ children,
52
+ children.length === 0
53
+ )
54
+
55
+ // The provider the component generator emits for a list of six products.
56
+ const makeProvider = (): types.JSXElement =>
57
+ jsx('DataProvider', [
58
+ types.jsxAttribute(
59
+ types.jsxIdentifier('name'),
60
+ types.jsxExpressionContainer(types.stringLiteral('items'))
61
+ ),
62
+ types.jsxAttribute(
63
+ types.jsxIdentifier('resourceDefinition'),
64
+ types.jsxExpressionContainer(
65
+ types.objectExpression([
66
+ types.objectProperty(types.stringLiteral('dataSourceId'), types.stringLiteral(DS_ID)),
67
+ types.objectProperty(types.stringLiteral('tableName'), types.stringLiteral(TABLE)),
68
+ types.objectProperty(
69
+ types.stringLiteral('dataSourceType'),
70
+ types.stringLiteral('teleport')
71
+ ),
72
+ ])
73
+ )
74
+ ),
75
+ types.jsxAttribute(
76
+ types.jsxIdentifier('params'),
77
+ types.jsxExpressionContainer(
78
+ types.objectExpression([
79
+ types.objectProperty(types.stringLiteral('limit'), types.numericLiteral(6)),
80
+ ])
81
+ )
82
+ ),
83
+ types.jsxAttribute(
84
+ types.jsxIdentifier('renderSuccess'),
85
+ types.jsxExpressionContainer(
86
+ types.arrowFunctionExpression(
87
+ [types.identifier('items')],
88
+ jsx('Repeater', [
89
+ types.jsxAttribute(
90
+ types.jsxIdentifier('items'),
91
+ types.jsxExpressionContainer(types.identifier('items'))
92
+ ),
93
+ types.jsxAttribute(
94
+ types.jsxIdentifier('renderItem'),
95
+ types.jsxExpressionContainer(
96
+ types.arrowFunctionExpression([types.identifier('product')], jsx('div', []))
97
+ )
98
+ ),
99
+ ])
100
+ )
101
+ )
102
+ ),
103
+ ])
104
+
105
+ interface ListOptions {
106
+ sorted?: boolean
107
+ filters?: unknown[]
108
+ }
109
+
110
+ const makeListNode = ({ sorted = true, filters }: ListOptions = {}): any => ({
111
+ type: 'data-source-list',
112
+ content: {
113
+ renderPropIdentifier: 'items',
114
+ resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
115
+ resource: {
116
+ id: 'TQ_products',
117
+ params: {
118
+ limit: { type: 'static', content: 6 },
119
+ ...(filters ? { filters: { type: 'static', content: filters } } : {}),
120
+ },
121
+ },
122
+ nodes: {
123
+ success: {
124
+ type: 'cms-list-repeater',
125
+ content: {
126
+ renderPropIdentifier: 'product',
127
+ paginated: false,
128
+ searchEnabled: false,
129
+ ...(sorted ? STORE_SORT : {}),
130
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ })
136
+
137
+ /** A products page drawing the given plain lists, run through the page's data-source plugin. */
138
+ const runPage = async (
139
+ lists: ListOptions[],
140
+ // `null`: the page declares no state, so the build cannot know the sort.
141
+ stateDefinitions: Record<string, UIDLStateDefinition> | null = SORT_DROPDOWN
142
+ ) => {
143
+ const providers = lists.map(makeProvider)
144
+ const chunk: ChunkDefinition = {
145
+ name: 'jsx-component',
146
+ type: ChunkType.AST,
147
+ fileType: FileType.JS,
148
+ linkAfter: [],
149
+ content: types.variableDeclaration('const', [
150
+ types.variableDeclarator(
151
+ types.identifier('ProductsList'),
152
+ types.arrowFunctionExpression(
153
+ [types.identifier('props')],
154
+ types.blockStatement([types.returnStatement(jsx('div', [], providers))])
155
+ )
156
+ ),
157
+ ]),
158
+ meta: { nodesLookup: Object.fromEntries(providers.map((p, i) => [`provider${i}`, p])) },
159
+ }
160
+ const structure: ComponentStructure = {
161
+ uidl: {
162
+ name: 'ProductsList',
163
+ node: {
164
+ type: 'element',
165
+ content: { elementType: 'container', children: lists.map(makeListNode) },
166
+ },
167
+ ...(stateDefinitions ? { stateDefinitions } : {}),
168
+ outputOptions: { folderPath: [], fileName: 'products' },
169
+ },
170
+ chunks: [chunk],
171
+ dependencies: {},
172
+ options: {
173
+ dataSources: {
174
+ [DS_ID]: {
175
+ id: DS_ID,
176
+ name: 'TeleportHQ database',
177
+ type: 'teleport',
178
+ config: { selectedTables: { [TABLE]: { columns: [{ name: 'id' }, { name: 'name' }] } } },
179
+ },
180
+ },
181
+ extractedResources: {},
182
+ },
183
+ } as never
184
+ await createNextPagesDataSourcePlugin()(structure)
185
+ const staticPropsChunk = structure.chunks.find((c) => c.name === 'getStaticProps')
186
+ return {
187
+ component: collapse(generator(chunk.content as types.Node).code),
188
+ staticProps: staticPropsChunk
189
+ ? collapse(generator(staticPropsChunk.content as types.Node).code)
190
+ : '',
191
+ chunk,
192
+ staticPropsChunk,
193
+ providers,
194
+ }
195
+ }
196
+
197
+ // The provider's `initialData` as the browser evaluates it for the given state.
198
+ const initialDataOf = (provider: types.JSXElement, state: Record<string, unknown>): unknown => {
199
+ const attr = provider.openingElement.attributes.find(
200
+ (a) => a.type === 'JSXAttribute' && a.name.name === 'initialData'
201
+ ) as types.JSXAttribute | undefined
202
+ if (!attr) {
203
+ return undefined
204
+ }
205
+ const expression = (attr.value as types.JSXExpressionContainer).expression as types.Expression
206
+ const names = Object.keys(state)
207
+ return new Function(...names, `return (${generator(expression).code})`)(
208
+ ...names.map((name) => state[name])
209
+ )
210
+ }
211
+
212
+ describe('a plain list sorted by a dropdown, on a page', () => {
213
+ it('fetches its rows at build time by the sort a visitor starts with, as a visitor', async () => {
214
+ const { staticProps } = await runPage([{}])
215
+
216
+ // The browser's own sort expression, with the dropdown at its default.
217
+ expect(staticProps).toContain(
218
+ `teleportTeleportProductsDs1.fetchData({ "limit": 6, "sorts": ${STARTING_SORTS} }, { as: "visitor" })`
219
+ )
220
+ })
221
+
222
+ it('shows those rows while the sort is where a visitor starts', async () => {
223
+ const { component } = await runPage([{}])
224
+
225
+ expect(component).toContain(
226
+ 'initialData={sortBy === "name-asc" ? props.items_products : undefined}'
227
+ )
228
+ // Any other sort is another query: the params follow the dropdown.
229
+ expect(component).toMatch(
230
+ /sorts: JSON\.stringify\(\[\{ field: sortBy\.split\("-"\).*\}\), \[sortBy\]\)\}/
231
+ )
232
+ })
233
+
234
+ it('keeps fetching in the browser when the build cannot know where the sort starts', async () => {
235
+ const { component, staticProps } = await runPage([{}], null)
236
+
237
+ expect(component).not.toContain('initialData')
238
+ expect(staticProps).not.toContain('"sorts"')
239
+ })
240
+
241
+ it('fetches an unsorted list beside it with the same params on its own', async () => {
242
+ const { component, staticProps } = await runPage([{ sorted: false }, {}])
243
+
244
+ expect(staticProps).toContain(
245
+ 'const [items_products, items_products_2] = await Promise.all([teleportTeleportProductsDs1.fetchData({ "limit": 6 }, { as: "visitor" })'
246
+ )
247
+ expect(staticProps).toContain(
248
+ `teleportTeleportProductsDs1.fetchData({ "limit": 6, "sorts": ${STARTING_SORTS} }, { as: "visitor" })`
249
+ )
250
+ expect(component).toContain('initialData={props.items_products}')
251
+ expect(component).toContain(
252
+ 'initialData={sortBy === "name-asc" ? props.items_products_2 : undefined}'
253
+ )
254
+ })
255
+
256
+ it('is never seeded while a visitor filters it', async () => {
257
+ const byCategory = {
258
+ type: 'condition',
259
+ source: 'category',
260
+ destination: { type: 'dynamic', content: { referenceType: 'state', id: 'selectedCategory' } },
261
+ operand: '=',
262
+ }
263
+ const { component, staticProps } = await runPage([{ filters: [byCategory] }], {
264
+ ...SORT_DROPDOWN,
265
+ selectedCategory: { type: 'string', defaultValue: '' },
266
+ })
267
+
268
+ expect(component).not.toContain('initialData')
269
+ expect(staticProps).not.toContain('fetchData')
270
+ })
271
+ })
272
+
273
+ /**
274
+ * Executed: the rows the build fetches and the ones the browser asks for must
275
+ * be one ordering of one row set, and the build's stand in only for the
276
+ * starting sort.
277
+ */
278
+ describe('a plain list sorted by a dropdown, run', () => {
279
+ it('sends the build and the browser one sort string while the dropdown is untouched', async () => {
280
+ const { staticPropsChunk, chunk } = await runPage([{}])
281
+ const [atBuild, readAs] = buildTimeArguments(staticPropsChunk)
282
+
283
+ expect(readAs).toEqual({ as: 'visitor' })
284
+ expect(atBuild.sorts).toBe('[{"field":"name","order":"asc"}]')
285
+ expect(browserParams(chunk, { sortBy: 'name-asc' })).toEqual({
286
+ limit: '6',
287
+ sorts: atBuild.sorts,
288
+ })
289
+ expect(browserParams(chunk, { sortBy: 'price-desc' }).sorts).toBe(
290
+ '[{"field":"price","order":"desc"}]'
291
+ )
292
+ })
293
+
294
+ it('orders the rows at build time exactly as the browser orders them', async () => {
295
+ const { staticPropsChunk, chunk } = await runPage([{}])
296
+ const route = bootProductsRoute()
297
+
298
+ const atBuild = await route.atBuild(buildTimeArguments(staticPropsChunk))
299
+ const inBrowser = await route.inBrowser(browserParams(chunk, { sortBy: 'name-asc' }))
300
+
301
+ // The same columns, rows without a value last, the row's id breaking ties,
302
+ // over the products a visitor is served.
303
+ expect(orderBy(atBuild)).toBe(' ORDER BY name ASC NULLS LAST, "id" ASC LIMIT')
304
+ expect(rowWindow(atBuild)).toBe(' LIMIT 6')
305
+ expect(atBuild).toContain("WHERE (LOWER(TRIM(status)) = 'active')")
306
+ expect(inBrowser).toBe(atBuild)
307
+ })
308
+
309
+ it("shows the build's rows for the starting sort only; any other is fetched fresh", async () => {
310
+ const { providers, staticPropsChunk, chunk } = await runPage([{ sorted: false }, {}])
311
+ const props = { items_products: ['unsorted'], items_products_2: ['by name'] }
312
+
313
+ expect(initialDataOf(providers[1], { sortBy: 'name-asc', props })).toEqual(['by name'])
314
+ // Another pick: no seed, and params the provider has not fetched yet.
315
+ expect(initialDataOf(providers[1], { sortBy: 'price-desc', props })).toBeUndefined()
316
+ expect(browserParams(chunk, { sortBy: 'price-desc' }, 1).sorts).not.toBe(
317
+ buildTimeArguments(staticPropsChunk, 'items_products_2')[0].sorts
318
+ )
319
+ // The unsorted list beside it keeps its own rows, whatever the dropdown says.
320
+ expect(initialDataOf(providers[0], { sortBy: 'price-desc', props })).toEqual(['unsorted'])
321
+ expect(buildTimeArguments(staticPropsChunk, 'items_products')[0]).toEqual({ limit: 6 })
322
+ })
323
+ })
@@ -121,8 +121,8 @@ describe('the generated Postgres route', () => {
121
121
  it('keeps a plain-column ORDER BY to fall back to', () => {
122
122
  // If a database ever rejects the sub-select, the page must degrade to the
123
123
  // previous ordering rather than serve nothing.
124
- expect(productsRoute).toContain('const usedDiscountAwareSort = orderBySql !== plainOrderBySql')
125
- expect(productsRoute).toContain('if (!usedDiscountAwareSort) throw sortError')
124
+ expect(productsRoute).toContain('const usedRewrittenSort = orderBySql !== plainOrderBySql')
125
+ expect(productsRoute).toContain('if (!usedRewrittenSort) throw sortError')
126
126
  expect(productsRoute).toContain('baseSql + plainOrderBySql + sqlTail')
127
127
  })
128
128
 
@@ -227,6 +227,13 @@ describe('stable pagination', () => {
227
227
  expect(rowIdentityColumn({}, 'teleport_orders')).toBeNull()
228
228
  })
229
229
 
230
+ it('breaks ties on every listing a visitor sorts: the blog listing and the booking page too', () => {
231
+ // "Sort by reading time" ties every five-minute post, "by price" every
232
+ // item at the same price.
233
+ expect(rowIdentityColumn({}, 'teleport_blog_posts')).toBe('id')
234
+ expect(rowIdentityColumn({}, 'teleport_booking_items')).toBe('id')
235
+ })
236
+
230
237
  it('is applied to the real and the fallback ORDER BY, and to a single sort column', () => {
231
238
  for (const route of [
232
239
  generatePostgreSQLFetcher(PG_CONFIG, 'teleport_products'),
@@ -274,10 +281,8 @@ describe('the fetcher a generated storefront actually gets', () => {
274
281
  })
275
282
 
276
283
  it('keeps the plain-column fallback and the tiebreaker', () => {
277
- expect(teleportProducts).toContain(
278
- 'const usedDiscountAwareSort = orderBySql !== plainOrderBySql'
279
- )
280
- expect(teleportProducts).toContain('if (!usedDiscountAwareSort) throw sortError')
284
+ expect(teleportProducts).toContain('const usedRewrittenSort = orderBySql !== plainOrderBySql')
285
+ expect(teleportProducts).toContain('if (!usedRewrittenSort) throw sortError')
281
286
  expect(teleportProducts).toContain('baseSql + plainOrderBySql + sqlTail')
282
287
  expect(teleportProducts).toContain('const rowOrderTiebreak = (fields, direction) =>')
283
288
  expect(teleportProducts).toMatch(/\$\{tiebreak\}/)