@snolla/data 0.0.0-stage → 1.0.7
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/dist/actor.d.ts +28 -0
- package/dist/actor.js +63 -0
- package/dist/audit.d.ts +36 -0
- package/dist/audit.js +198 -0
- package/dist/dataProvider.d.ts +41 -0
- package/dist/dataProvider.js +148 -0
- package/dist/dataSource.d.ts +4 -0
- package/dist/dataSource.js +65 -0
- package/dist/dbProvider.d.ts +1261 -0
- package/dist/dbProvider.js +1898 -0
- package/dist/freeze.d.ts +29 -0
- package/dist/freeze.js +69 -0
- package/dist/guard.d.ts +53 -0
- package/dist/guard.js +397 -0
- package/dist/insert.d.ts +9 -0
- package/dist/insert.js +44 -0
- package/dist/migrations/siteid.d.mts +738 -0
- package/dist/migrations/siteid.mjs +512 -0
- package/dist/publish.d.ts +1 -0
- package/dist/publish.js +1 -0
- package/dist/schema/dsl.d.ts +64 -0
- package/dist/schema/dsl.js +28 -0
- package/dist/schema/generate.d.mts +14 -0
- package/dist/schema/generate.mjs +282 -0
- package/dist/schema/generated/ddl.d.ts +7 -0
- package/dist/schema/generated/ddl.js +11 -0
- package/dist/schema/generated/kysely-types.d.ts +1988 -0
- package/dist/schema/generated/kysely-types.js +5 -0
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/provision.d.mts +11 -0
- package/dist/schema/provision.mjs +111 -0
- package/dist/schema/tables.d.ts +2 -0
- package/dist/schema/tables.js +3301 -0
- package/dist/scripts/canon-audit.d.mts +25 -0
- package/dist/scripts/canon-audit.mjs +213 -0
- package/dist/scripts/canon-smoke.d.mts +1 -0
- package/dist/scripts/canon-smoke.mjs +44 -0
- package/dist/scripts/dialect-ddl-stand.d.mts +1 -0
- package/dist/scripts/dialect-ddl-stand.mjs +136 -0
- package/dist/scripts/owner-spec-check.d.mts +1 -0
- package/dist/scripts/owner-spec-check.mjs +166 -0
- package/dist/scripts/owners/owner-resolve-spec.json +540 -0
- package/dist/scripts/owners-audit.d.mts +60 -0
- package/dist/scripts/owners-audit.mjs +443 -0
- package/dist/scripts/owners-fixture.d.mts +20 -0
- package/dist/scripts/owners-fixture.mjs +192 -0
- package/dist/scripts/schema-lengths-audit.d.mts +22 -0
- package/dist/scripts/schema-lengths-audit.mjs +146 -0
- package/dist/scripts/siteid-migrate.d.mts +7 -0
- package/dist/scripts/siteid-migrate.mjs +260 -0
- package/dist/scripts/tenant-invariant-audit.d.mts +1 -0
- package/dist/scripts/tenant-invariant-audit.mjs +34 -0
- package/dist/singleMode.d.ts +5 -0
- package/dist/singleMode.js +23 -0
- package/dist/sqliteDialect.d.ts +6 -0
- package/dist/sqliteDialect.js +92 -0
- package/dist/templateDefs.d.ts +38 -0
- package/dist/templateDefs.js +163 -0
- package/dist/test/fixtures/broken-driver/index.d.ts +3 -0
- package/dist/test/fixtures/broken-driver/index.js +5 -0
- package/dist/test/support/dialect-stand.d.ts +37 -0
- package/dist/test/support/dialect-stand.js +162 -0
- package/dist/test/support/render-parity.d.ts +44 -0
- package/dist/test/support/render-parity.js +262 -0
- package/package.json +50 -5
- package/README.md +0 -3
|
@@ -0,0 +1,1898 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// Kysely db backend behind the data-provider seam (task:3821, requirements:65 п.2).
|
|
10
|
+
//
|
|
11
|
+
// dataProvider.js froze the seam: `get(slice, key)` / `enumerate(slice, opts)`
|
|
12
|
+
// return the DB-loaded (template-shaped) row, so the engine does not care whether
|
|
13
|
+
// a row came from fixtures or from the database. This module is the `db` backend of
|
|
14
|
+
// that seam and the single Kysely runtime the product moves to (plan:49 Q2): one
|
|
15
|
+
// query layer over Postgres/MariaDB/SQLite, with MSSQL kept as a migration source.
|
|
16
|
+
// No Sequelize, no dialect branching above this file.
|
|
17
|
+
//
|
|
18
|
+
// Two properties are not optional:
|
|
19
|
+
// * every read is tenant-scoped — the cell's `siteId` feeds the R1 guard
|
|
20
|
+
// (deny-by-default), never the query payload, so the backend cannot serve
|
|
21
|
+
// another tenant's rows even by accident;
|
|
22
|
+
// * the module stays Sequelize-free and is imported by subpath
|
|
23
|
+
// (`@snolla/data/dbProvider.js`), like dataProvider.js — the engine can boot
|
|
24
|
+
// with no `config.data.sequelize` at all.
|
|
25
|
+
//
|
|
26
|
+
// Cell config (the per-slice/per-cell override, not a global canon):
|
|
27
|
+
// { dialect: 'postgres'|'mariadb'|'sqlite'|'mssql', dsn: string, siteId: uuid }
|
|
28
|
+
//
|
|
29
|
+
// Like the rest of the canon modules this file imports TypeScript sources (the R1
|
|
30
|
+
// guard) — the consuming runtime strips types (Node ≥22.6) or bundles (Vite/Nitro),
|
|
31
|
+
// which is how every canon consumer already runs.
|
|
32
|
+
import { AliasNode, ColumnNode, IdentifierNode, Kysely, MssqlDialect, MssqlQueryCompiler, MysqlDialect, OffsetNode, OperationNodeTransformer, OrderByItemNode, OrderByNode, PostgresDialect, RawNode, ReferenceNode, SelectionNode, TopNode, ValueNode, sql, } from 'kysely';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { canonicalSchema, isSiteScoped } from "./schema/index.js";
|
|
35
|
+
import { tenantGuard } from "./guard.js";
|
|
36
|
+
export const DIALECTS = ['postgres', 'mariadb', 'sqlite', 'mssql'];
|
|
37
|
+
export const CELL_REQUIRED_KEYS = ['dialect', 'dsn', 'siteId'];
|
|
38
|
+
const CANON_TABLES = new Map(canonicalSchema.tables.map((table) => [table.name, table]));
|
|
39
|
+
const asBool = (value) => value === true || value === 1 || value === '1';
|
|
40
|
+
/**
|
|
41
|
+
* A `datetime` column as an instant (task:4504). Drivers hand back what their type says:
|
|
42
|
+
* pg/mysql2/tedious return a `Date`. SQLite returns the stored text, and `sqliteDialect`
|
|
43
|
+
* writes it as UTC — so `Date.parse` on it reads LOCAL time and the blog post's day window
|
|
44
|
+
* resolves the wrong day (a dated URL then 404s a post it names). A naive timestamp is UTC
|
|
45
|
+
* by construction; a value that already carries a zone is trusted as it stands.
|
|
46
|
+
*/
|
|
47
|
+
const asInstant = (value) => {
|
|
48
|
+
if (value instanceof Date || value === null || value === undefined)
|
|
49
|
+
return value;
|
|
50
|
+
const text = String(value).trim();
|
|
51
|
+
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(text))
|
|
52
|
+
return new Date(text);
|
|
53
|
+
return new Date(`${text.replace(' ', 'T')}Z`);
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A slice is data, not code: which canon table it reads, which column is the key,
|
|
57
|
+
* the one optional join to the slice's template row, and how the flat row becomes
|
|
58
|
+
* the DB-loaded entity the view-model builders already consume. Adding a slice is
|
|
59
|
+
* a declaration here — no new query branch.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* A part-template table in the seam's terms (task:3888). Six canon tables carry the same shape
|
|
63
|
+
* — a `TemplateId` key, one owner column, `Name`/`Alias` and the stored field-definitions in
|
|
64
|
+
* `Content` — and the owner column is the only thing that differs, because each content
|
|
65
|
+
* container (site, blog, catalog, store) owns its own part templates. `owner` is the column the
|
|
66
|
+
* Sequelize model exposes as `ownerId`, which is what the engine passes in.
|
|
67
|
+
*/
|
|
68
|
+
const partTemplateSlice = (table, owner) => ({
|
|
69
|
+
table,
|
|
70
|
+
key: 'TemplateId',
|
|
71
|
+
columns: ['TemplateId', owner, 'Name', 'Alias', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
72
|
+
shape: (row) => ({
|
|
73
|
+
id: row.TemplateId,
|
|
74
|
+
ownerId: row[owner],
|
|
75
|
+
name: row.Name,
|
|
76
|
+
alias: row.Alias,
|
|
77
|
+
content: row.Content,
|
|
78
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
79
|
+
layouts: row.Layouts,
|
|
80
|
+
key: row.TemplateId,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
export const SLICES = {
|
|
84
|
+
// Boot surfaces (task:3873 step 1): the engine's first reads are the site row,
|
|
85
|
+
// its themes and its domains (`@snolla/web` initApp) — they must come from the
|
|
86
|
+
// same seam as content, or "the engine on another DSN" starts with a Sequelize read.
|
|
87
|
+
sites: {
|
|
88
|
+
table: 'Sites',
|
|
89
|
+
key: 'SiteId',
|
|
90
|
+
// Password is deliberately absent: the serving path never reads it, and a read
|
|
91
|
+
// contract that hands out credentials is a leak waiting for a caller.
|
|
92
|
+
columns: [
|
|
93
|
+
'SiteId', 'AccountId', 'Title', 'Alias', 'LoweredAlias', 'PrimaryDomain',
|
|
94
|
+
'TimeOffset', 'Culture', 'MultiCultural', 'Cultures', 'ActiveThemeId', 'RobotsTxt',
|
|
95
|
+
'Live', 'Production', 'PrefixAction', 'SettingsData', 'DefaultContentType',
|
|
96
|
+
'DefaultEncoding', 'DefaultPageName', 'DefaultExtension', 'CachingOptions',
|
|
97
|
+
'EnableCors', 'DefaultCorsPolicy', 'ContentCacheOptions', 'Writable',
|
|
98
|
+
// the search drainer's dirty flag (content-api/routes/search.js): null = rebuild
|
|
99
|
+
'SearchIndexCreationTimestamp',
|
|
100
|
+
],
|
|
101
|
+
// the Sequelize-loaded asset shape (sequelize/snolla/site.js) — `app.locals.site` consumers
|
|
102
|
+
shape: (row) => ({
|
|
103
|
+
id: row.SiteId,
|
|
104
|
+
accountId: row.AccountId,
|
|
105
|
+
title: row.Title,
|
|
106
|
+
alias: row.Alias,
|
|
107
|
+
loweredAlias: row.LoweredAlias,
|
|
108
|
+
primaryDomain: row.PrimaryDomain,
|
|
109
|
+
timeOffset: row.TimeOffset,
|
|
110
|
+
culture: row.Culture,
|
|
111
|
+
multiCultural: asBool(row.MultiCultural),
|
|
112
|
+
cultures: row.Cultures,
|
|
113
|
+
activeThemeId: row.ActiveThemeId,
|
|
114
|
+
robotsTxt: row.RobotsTxt,
|
|
115
|
+
live: asBool(row.Live),
|
|
116
|
+
production: asBool(row.Production),
|
|
117
|
+
prefixAction: row.PrefixAction,
|
|
118
|
+
settingsData: row.SettingsData,
|
|
119
|
+
defaultContentType: row.DefaultContentType,
|
|
120
|
+
defaultEncoding: row.DefaultEncoding,
|
|
121
|
+
defaultPageName: row.DefaultPageName,
|
|
122
|
+
defaultExtension: row.DefaultExtension,
|
|
123
|
+
cachingOptions: row.CachingOptions,
|
|
124
|
+
enableCors: asBool(row.EnableCors),
|
|
125
|
+
defaultCorsPolicy: row.DefaultCorsPolicy,
|
|
126
|
+
contentCacheOptions: row.ContentCacheOptions,
|
|
127
|
+
writable: asBool(row.Writable),
|
|
128
|
+
searchIndexCreationTimestamp: row.SearchIndexCreationTimestamp,
|
|
129
|
+
key: row.SiteId,
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
themes: {
|
|
133
|
+
table: 'Themes',
|
|
134
|
+
key: 'ThemeId',
|
|
135
|
+
columns: ['ThemeId', 'SiteId', 'Name', 'ViewEngineId', 'ImageSizes', 'ParentThemeId', 'SettingsData', 'SettingsMarkup'],
|
|
136
|
+
shape: (row) => ({
|
|
137
|
+
id: row.ThemeId,
|
|
138
|
+
siteId: row.SiteId,
|
|
139
|
+
name: row.Name,
|
|
140
|
+
viewEngineId: row.ViewEngineId,
|
|
141
|
+
imageSizes: row.ImageSizes,
|
|
142
|
+
parentThemeId: row.ParentThemeId,
|
|
143
|
+
settingsData: row.SettingsData,
|
|
144
|
+
settingsMarkup: row.SettingsMarkup,
|
|
145
|
+
key: row.ThemeId,
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
domains: {
|
|
149
|
+
table: 'Domains',
|
|
150
|
+
key: 'DomainId',
|
|
151
|
+
columns: ['DomainId', 'SiteId', 'Name', 'ThemeId', 'PrefixAction', 'RobotsTxt', 'Public', 'SettingsData'],
|
|
152
|
+
shape: (row) => ({
|
|
153
|
+
id: row.DomainId,
|
|
154
|
+
siteId: row.SiteId,
|
|
155
|
+
name: row.Name,
|
|
156
|
+
themeId: row.ThemeId,
|
|
157
|
+
prefixAction: row.PrefixAction,
|
|
158
|
+
robotsTxt: row.RobotsTxt,
|
|
159
|
+
public: asBool(row.Public),
|
|
160
|
+
settingsData: row.SettingsData,
|
|
161
|
+
key: row.DomainId,
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
redirections: {
|
|
165
|
+
// The redirect table is read on every GET (packages/web/middleware/redirections.js),
|
|
166
|
+
// so the service caches it — the slice only has to preserve the Seq order the matcher
|
|
167
|
+
// depends on (first match wins). Column notes come from the Sequelize model:
|
|
168
|
+
// `TargetUrl` is the URL to MATCH (`sourceUrl` in JS), `DestinationUrl` is the target.
|
|
169
|
+
table: 'Redirections',
|
|
170
|
+
key: 'RedirectionId',
|
|
171
|
+
columns: [
|
|
172
|
+
'RedirectionId', 'SiteId', 'Title', 'Seq', 'UrlDetection', 'TargetUrl', 'SourceType',
|
|
173
|
+
'Regex', 'IgnoreCase', 'Action', 'DestinationUrl', 'Permanent', 'ThemeId',
|
|
174
|
+
'BrowserDetection', 'MobileDeviceDetection', 'DeviceTypeDetection', 'IgnoreOptions',
|
|
175
|
+
],
|
|
176
|
+
shape: (row) => ({
|
|
177
|
+
id: row.RedirectionId,
|
|
178
|
+
siteId: row.SiteId,
|
|
179
|
+
title: row.Title,
|
|
180
|
+
seq: row.Seq,
|
|
181
|
+
urlDetection: asBool(row.UrlDetection),
|
|
182
|
+
sourceUrl: row.TargetUrl,
|
|
183
|
+
sourceType: row.SourceType,
|
|
184
|
+
regex: asBool(row.Regex),
|
|
185
|
+
ignoreCase: asBool(row.IgnoreCase),
|
|
186
|
+
action: row.Action,
|
|
187
|
+
destinationUrl: row.DestinationUrl,
|
|
188
|
+
permanent: asBool(row.Permanent),
|
|
189
|
+
themeId: row.ThemeId,
|
|
190
|
+
browserDetection: asBool(row.BrowserDetection),
|
|
191
|
+
mobileDeviceDetection: asBool(row.MobileDeviceDetection),
|
|
192
|
+
deviceTypeDetection: asBool(row.DeviceTypeDetection),
|
|
193
|
+
ignoreOptions: asBool(row.IgnoreOptions),
|
|
194
|
+
key: row.RedirectionId,
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
stores: {
|
|
198
|
+
// The store record (one per site) — the entry to every store route (task:3873).
|
|
199
|
+
table: 'Stores',
|
|
200
|
+
key: 'StoreId',
|
|
201
|
+
columns: [
|
|
202
|
+
'StoreId', 'SiteId', 'Title', 'Path', 'Alias', 'Content', 'StoreFields',
|
|
203
|
+
'StoreLayout', 'StoreLayouts', 'ProductLayout', 'ProductsPath', 'ProductPath',
|
|
204
|
+
'ProductNumberFormat', 'CategoriesLayout', 'CategoryLayout', 'CategoryLayouts',
|
|
205
|
+
'CategoryFields', 'CategoriesPath', 'TagsLayout', 'TagLayout', 'TagLayouts',
|
|
206
|
+
'TagFields', 'TagsPath', 'VendorsLayout', 'VendorLayout', 'VendorLayouts',
|
|
207
|
+
'VendorFields', 'VendorsPath', 'IncludeInSitemap', 'SitemapLastUpdatedTime',
|
|
208
|
+
'SitemapChangeFrequency', 'SitemapPriority',
|
|
209
|
+
],
|
|
210
|
+
shape: (row) => ({
|
|
211
|
+
id: row.StoreId,
|
|
212
|
+
siteId: row.SiteId,
|
|
213
|
+
title: row.Title,
|
|
214
|
+
path: row.Path,
|
|
215
|
+
alias: row.Alias,
|
|
216
|
+
content: row.Content,
|
|
217
|
+
storeFields: row.StoreFields,
|
|
218
|
+
storeLayout: row.StoreLayout,
|
|
219
|
+
storeLayouts: row.StoreLayouts,
|
|
220
|
+
productLayout: row.ProductLayout,
|
|
221
|
+
productsPath: row.ProductsPath,
|
|
222
|
+
productPath: row.ProductPath,
|
|
223
|
+
productNumberFormat: row.ProductNumberFormat,
|
|
224
|
+
categoriesLayout: row.CategoriesLayout,
|
|
225
|
+
categoryLayout: row.CategoryLayout,
|
|
226
|
+
categoryLayouts: row.CategoryLayouts,
|
|
227
|
+
categoryFields: row.CategoryFields,
|
|
228
|
+
categoriesPath: row.CategoriesPath,
|
|
229
|
+
tagsLayout: row.TagsLayout,
|
|
230
|
+
tagLayout: row.TagLayout,
|
|
231
|
+
tagLayouts: row.TagLayouts,
|
|
232
|
+
tagFields: row.TagFields,
|
|
233
|
+
tagsPath: row.TagsPath,
|
|
234
|
+
vendorsLayout: row.VendorsLayout,
|
|
235
|
+
vendorLayout: row.VendorLayout,
|
|
236
|
+
vendorLayouts: row.VendorLayouts,
|
|
237
|
+
vendorFields: row.VendorFields,
|
|
238
|
+
vendorsPath: row.VendorsPath,
|
|
239
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
240
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
241
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
242
|
+
sitemapPriority: row.SitemapPriority,
|
|
243
|
+
key: row.StoreId,
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
storeProducts: {
|
|
247
|
+
// The store product (Sequelize model `storeProduct` → table Products, StoreId-keyed).
|
|
248
|
+
table: 'Products',
|
|
249
|
+
key: 'ProductId',
|
|
250
|
+
columns: [
|
|
251
|
+
'ProductId', 'StoreId', 'SiteId', 'Number', 'TypeId', 'Content', 'Slug', 'Title',
|
|
252
|
+
'Rating', 'VotesCount', 'CountryOfOrigin', 'VendorId', 'Published', 'DateCreated',
|
|
253
|
+
'IncludeInSitemap', 'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
254
|
+
],
|
|
255
|
+
shape: (row) => ({
|
|
256
|
+
id: row.ProductId,
|
|
257
|
+
storeId: row.StoreId,
|
|
258
|
+
number: row.Number,
|
|
259
|
+
templateId: row.TypeId,
|
|
260
|
+
content: row.Content,
|
|
261
|
+
slug: row.Slug,
|
|
262
|
+
title: row.Title,
|
|
263
|
+
rating: row.Rating,
|
|
264
|
+
votesCount: row.VotesCount,
|
|
265
|
+
countryOfOrigin: row.CountryOfOrigin,
|
|
266
|
+
vendorId: row.VendorId,
|
|
267
|
+
published: asBool(row.Published),
|
|
268
|
+
dateCreated: row.DateCreated,
|
|
269
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
270
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
271
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
272
|
+
sitemapPriority: row.SitemapPriority,
|
|
273
|
+
key: row.ProductId,
|
|
274
|
+
}),
|
|
275
|
+
},
|
|
276
|
+
storeProductTemplates: {
|
|
277
|
+
// `ProductTypes` is the product-template table (the Sequelize model keeps the legacy
|
|
278
|
+
// name `storeProductTemplate`, the canon names the table after its PK).
|
|
279
|
+
table: 'ProductTypes',
|
|
280
|
+
key: 'TypeId',
|
|
281
|
+
columns: ['TypeId', 'Name', 'Description', 'StoreId', 'Content', 'Alias', 'CustomDefaultLayout', 'Layouts', 'VariantTemplateId'],
|
|
282
|
+
shape: (row) => ({
|
|
283
|
+
id: row.TypeId,
|
|
284
|
+
name: row.Name,
|
|
285
|
+
description: row.Description,
|
|
286
|
+
storeId: row.StoreId,
|
|
287
|
+
content: row.Content,
|
|
288
|
+
alias: row.Alias,
|
|
289
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
290
|
+
layouts: row.Layouts,
|
|
291
|
+
variantTemplateId: row.VariantTemplateId,
|
|
292
|
+
key: row.TypeId,
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
storeProductVariantTemplates: {
|
|
296
|
+
table: 'ProductVariantTemplates',
|
|
297
|
+
key: 'TemplateId',
|
|
298
|
+
columns: ['TemplateId', 'StoreId', 'Name', 'Alias', 'Description', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
299
|
+
shape: (row) => ({
|
|
300
|
+
id: row.TemplateId,
|
|
301
|
+
storeId: row.StoreId,
|
|
302
|
+
name: row.Name,
|
|
303
|
+
alias: row.Alias,
|
|
304
|
+
description: row.Description,
|
|
305
|
+
content: row.Content,
|
|
306
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
307
|
+
layouts: row.Layouts,
|
|
308
|
+
key: row.TemplateId,
|
|
309
|
+
}),
|
|
310
|
+
},
|
|
311
|
+
storeProductVariants: {
|
|
312
|
+
table: 'ProductVariants',
|
|
313
|
+
key: 'VariantId',
|
|
314
|
+
columns: ['VariantId', 'ProductId', 'Position', 'Title', 'Sku', 'VendorCode', 'TemplateId', 'Content', 'Price', 'CompareAtPrice', 'InventoryQuantity', 'InventoryPolicyId'],
|
|
315
|
+
shape: (row) => ({
|
|
316
|
+
id: row.VariantId,
|
|
317
|
+
productId: row.ProductId,
|
|
318
|
+
position: row.Position,
|
|
319
|
+
title: row.Title,
|
|
320
|
+
sku: row.Sku,
|
|
321
|
+
vendorCode: row.VendorCode,
|
|
322
|
+
templateId: row.TemplateId,
|
|
323
|
+
content: row.Content,
|
|
324
|
+
price: row.Price,
|
|
325
|
+
compareAtPrice: row.CompareAtPrice,
|
|
326
|
+
inventoryQuantity: row.InventoryQuantity,
|
|
327
|
+
inventoryPolicyId: row.InventoryPolicyId,
|
|
328
|
+
key: row.VariantId,
|
|
329
|
+
}),
|
|
330
|
+
},
|
|
331
|
+
storeCategories: {
|
|
332
|
+
table: 'StoreCategories',
|
|
333
|
+
key: 'CategoryId',
|
|
334
|
+
columns: ['CategoryId', 'StoreId', 'Title', 'Description', 'Slug', 'Content'],
|
|
335
|
+
shape: (row) => ({
|
|
336
|
+
id: row.CategoryId,
|
|
337
|
+
storeId: row.StoreId,
|
|
338
|
+
title: row.Title,
|
|
339
|
+
description: row.Description,
|
|
340
|
+
slug: row.Slug,
|
|
341
|
+
content: row.Content,
|
|
342
|
+
key: row.CategoryId,
|
|
343
|
+
}),
|
|
344
|
+
},
|
|
345
|
+
storeTags: {
|
|
346
|
+
table: 'StoreTags',
|
|
347
|
+
key: 'TagId',
|
|
348
|
+
columns: ['TagId', 'StoreId', 'Title', 'Slug', 'Content'],
|
|
349
|
+
shape: (row) => ({
|
|
350
|
+
id: row.TagId,
|
|
351
|
+
storeId: row.StoreId,
|
|
352
|
+
title: row.Title,
|
|
353
|
+
slug: row.Slug,
|
|
354
|
+
content: row.Content,
|
|
355
|
+
key: row.TagId,
|
|
356
|
+
}),
|
|
357
|
+
},
|
|
358
|
+
storeVendors: {
|
|
359
|
+
table: 'StoreVendors',
|
|
360
|
+
key: 'VendorId',
|
|
361
|
+
columns: ['VendorId', 'StoreId', 'Title', 'Slug', 'Content'],
|
|
362
|
+
shape: (row) => ({
|
|
363
|
+
id: row.VendorId,
|
|
364
|
+
storeId: row.StoreId,
|
|
365
|
+
title: row.Title,
|
|
366
|
+
slug: row.Slug,
|
|
367
|
+
content: row.Content,
|
|
368
|
+
key: row.VendorId,
|
|
369
|
+
}),
|
|
370
|
+
},
|
|
371
|
+
productStoreCategories: {
|
|
372
|
+
// The product↔category edge. The join table is the association; a product's
|
|
373
|
+
// categories are composed in the service (one indexed read per edge), not joined —
|
|
374
|
+
// a to-many join would fan the product row out.
|
|
375
|
+
table: 'ProductStoreCategories',
|
|
376
|
+
key: 'Product_Id',
|
|
377
|
+
columns: ['Product_Id', 'ProductCategory_Id', 'SiteId'],
|
|
378
|
+
shape: (row) => ({ productId: row.Product_Id, categoryId: row.ProductCategory_Id, key: row.Product_Id }),
|
|
379
|
+
},
|
|
380
|
+
productStoreTags: {
|
|
381
|
+
table: 'ProductStoreTags',
|
|
382
|
+
key: 'Product_Id',
|
|
383
|
+
columns: ['Product_Id', 'ProductTag_Id', 'SiteId'],
|
|
384
|
+
shape: (row) => ({ productId: row.Product_Id, tagId: row.ProductTag_Id, key: row.Product_Id }),
|
|
385
|
+
},
|
|
386
|
+
blogs: {
|
|
387
|
+
// Blogs (one site has many) — the entry to every blog route (task:3873).
|
|
388
|
+
table: 'Blogs',
|
|
389
|
+
key: 'BlogId',
|
|
390
|
+
columns: [
|
|
391
|
+
'BlogId', 'SiteId', 'Title', 'Alias', 'Path', 'Content', 'Subtitle', 'About', 'AboutType',
|
|
392
|
+
'BlogLayout', 'BlogLayouts', 'PostLayout', 'CategoryLayout', 'TagLayout', 'AuthorLayout',
|
|
393
|
+
'ArchiveLayout', 'BlogFields', 'CategoryFields', 'TagFields', 'AuthorFields',
|
|
394
|
+
'PostPath', 'CategoriesPath', 'TagsPath', 'AuthorsPath', 'ArchivesPath',
|
|
395
|
+
'PostNumberFormat', 'YearFormat', 'MonthFormat', 'DayFormat', 'LastDateUpdated',
|
|
396
|
+
'IncludeInSitemap', 'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
397
|
+
],
|
|
398
|
+
shape: (row) => ({
|
|
399
|
+
id: row.BlogId,
|
|
400
|
+
siteId: row.SiteId,
|
|
401
|
+
title: row.Title,
|
|
402
|
+
// the legacy blog model swaps the two columns (sequelize/snolla/blog.js):
|
|
403
|
+
// `path` ← the Alias column, `alias` ← the Path column. The engine reads the
|
|
404
|
+
// ATTRIBUTE names, so the slice speaks the attribute mapping, not the column names.
|
|
405
|
+
path: row.Alias,
|
|
406
|
+
alias: row.Path,
|
|
407
|
+
content: row.Content,
|
|
408
|
+
subtitle: row.Subtitle,
|
|
409
|
+
about: row.About,
|
|
410
|
+
aboutType: row.AboutType,
|
|
411
|
+
blogLayout: row.BlogLayout,
|
|
412
|
+
blogLayouts: row.BlogLayouts,
|
|
413
|
+
postLayout: row.PostLayout,
|
|
414
|
+
categoryLayout: row.CategoryLayout,
|
|
415
|
+
tagLayout: row.TagLayout,
|
|
416
|
+
authorLayout: row.AuthorLayout,
|
|
417
|
+
archiveLayout: row.ArchiveLayout,
|
|
418
|
+
blogFields: row.BlogFields,
|
|
419
|
+
categoryFields: row.CategoryFields,
|
|
420
|
+
tagFields: row.TagFields,
|
|
421
|
+
authorFields: row.AuthorFields,
|
|
422
|
+
postPath: row.PostPath,
|
|
423
|
+
categoriesPath: row.CategoriesPath,
|
|
424
|
+
tagsPath: row.TagsPath,
|
|
425
|
+
authorsPath: row.AuthorsPath,
|
|
426
|
+
archivesPath: row.ArchivesPath,
|
|
427
|
+
postNumberFormat: row.PostNumberFormat,
|
|
428
|
+
yearFormat: row.YearFormat,
|
|
429
|
+
monthFormat: row.MonthFormat,
|
|
430
|
+
dayFormat: row.DayFormat,
|
|
431
|
+
lastDateUpdated: row.LastDateUpdated,
|
|
432
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
433
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
434
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
435
|
+
sitemapPriority: row.SitemapPriority,
|
|
436
|
+
key: row.BlogId,
|
|
437
|
+
}),
|
|
438
|
+
},
|
|
439
|
+
blogPosts: {
|
|
440
|
+
// The blog post (Sequelize `blogPost` → table Posts). `slug` is the `Alias` column.
|
|
441
|
+
table: 'Posts',
|
|
442
|
+
key: 'PostId',
|
|
443
|
+
columns: [
|
|
444
|
+
'PostId', 'BlogId', 'SiteId', 'AuthorId', 'Number', 'Title', 'Excerpt', 'ExcerptType',
|
|
445
|
+
'Content', 'Alias', 'Published', 'DatePublished', 'DateCreated', 'TemplateId',
|
|
446
|
+
'IncludeInSitemap', 'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
447
|
+
],
|
|
448
|
+
shape: (row) => ({
|
|
449
|
+
id: row.PostId,
|
|
450
|
+
blogId: row.BlogId,
|
|
451
|
+
authorId: row.AuthorId,
|
|
452
|
+
number: row.Number,
|
|
453
|
+
title: row.Title,
|
|
454
|
+
excerpt: row.Excerpt,
|
|
455
|
+
excerptType: row.ExcerptType,
|
|
456
|
+
content: row.Content,
|
|
457
|
+
slug: row.Alias,
|
|
458
|
+
published: asBool(row.Published),
|
|
459
|
+
datePublished: asInstant(row.DatePublished),
|
|
460
|
+
dateCreated: asInstant(row.DateCreated),
|
|
461
|
+
templateId: row.TemplateId,
|
|
462
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
463
|
+
sitemapLastUpdatedTime: asInstant(row.SitemapLastUpdatedTime),
|
|
464
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
465
|
+
sitemapPriority: row.SitemapPriority,
|
|
466
|
+
key: row.PostId,
|
|
467
|
+
}),
|
|
468
|
+
},
|
|
469
|
+
blogPostTemplates: {
|
|
470
|
+
table: 'BlogPostTemplates',
|
|
471
|
+
key: 'TemplateId',
|
|
472
|
+
columns: ['TemplateId', 'BlogId', 'Name', 'Alias', 'Description', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
473
|
+
shape: (row) => ({
|
|
474
|
+
id: row.TemplateId,
|
|
475
|
+
blogId: row.BlogId,
|
|
476
|
+
name: row.Name,
|
|
477
|
+
alias: row.Alias,
|
|
478
|
+
description: row.Description,
|
|
479
|
+
content: row.Content,
|
|
480
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
481
|
+
layouts: row.Layouts,
|
|
482
|
+
key: row.TemplateId,
|
|
483
|
+
}),
|
|
484
|
+
},
|
|
485
|
+
blogCategories: {
|
|
486
|
+
table: 'BlogCategories',
|
|
487
|
+
key: 'CategoryId',
|
|
488
|
+
columns: ['CategoryId', 'BlogId', 'Title', 'Description', 'Alias', 'Published', 'Content'],
|
|
489
|
+
shape: (row) => ({
|
|
490
|
+
id: row.CategoryId,
|
|
491
|
+
blogId: row.BlogId,
|
|
492
|
+
title: row.Title,
|
|
493
|
+
description: row.Description,
|
|
494
|
+
slug: row.Alias,
|
|
495
|
+
published: asBool(row.Published),
|
|
496
|
+
content: row.Content,
|
|
497
|
+
key: row.CategoryId,
|
|
498
|
+
}),
|
|
499
|
+
},
|
|
500
|
+
blogTags: {
|
|
501
|
+
table: 'BlogTags',
|
|
502
|
+
key: 'TagId',
|
|
503
|
+
columns: ['TagId', 'BlogId', 'Title', 'Description', 'Alias', 'Published', 'Content'],
|
|
504
|
+
shape: (row) => ({
|
|
505
|
+
id: row.TagId,
|
|
506
|
+
blogId: row.BlogId,
|
|
507
|
+
title: row.Title,
|
|
508
|
+
description: row.Description,
|
|
509
|
+
slug: row.Alias,
|
|
510
|
+
published: asBool(row.Published),
|
|
511
|
+
content: row.Content,
|
|
512
|
+
key: row.TagId,
|
|
513
|
+
}),
|
|
514
|
+
},
|
|
515
|
+
blogAuthors: {
|
|
516
|
+
table: 'BlogAuthors',
|
|
517
|
+
key: 'AuthorId',
|
|
518
|
+
columns: ['AuthorId', 'BlogId', 'Title', 'Slug', 'AdministratorId', 'Content'],
|
|
519
|
+
shape: (row) => ({
|
|
520
|
+
id: row.AuthorId,
|
|
521
|
+
blogId: row.BlogId,
|
|
522
|
+
title: row.Title,
|
|
523
|
+
slug: row.Slug,
|
|
524
|
+
administratorId: row.AdministratorId,
|
|
525
|
+
content: row.Content,
|
|
526
|
+
key: row.AuthorId,
|
|
527
|
+
}),
|
|
528
|
+
},
|
|
529
|
+
blogCategoryPosts: {
|
|
530
|
+
table: 'BlogCategoryPosts',
|
|
531
|
+
key: 'BlogCategory_Id',
|
|
532
|
+
columns: ['BlogCategory_Id', 'Post_Id', 'SiteId'],
|
|
533
|
+
shape: (row) => ({ categoryId: row.BlogCategory_Id, postId: row.Post_Id, key: row.BlogCategory_Id }),
|
|
534
|
+
},
|
|
535
|
+
blogTagPosts: {
|
|
536
|
+
table: 'BlogTagPosts',
|
|
537
|
+
key: 'BlogTag_Id',
|
|
538
|
+
columns: ['BlogTag_Id', 'Post_Id', 'SiteId'],
|
|
539
|
+
shape: (row) => ({ tagId: row.BlogTag_Id, postId: row.Post_Id, key: row.BlogTag_Id }),
|
|
540
|
+
},
|
|
541
|
+
pages: {
|
|
542
|
+
table: 'Pages',
|
|
543
|
+
key: 'Path',
|
|
544
|
+
// Legacy `getPageByPath` lowercases the path and relied on the MSSQL collation
|
|
545
|
+
// being case-insensitive. Postgres compares case-sensitively, so an exact match
|
|
546
|
+
// would silently 404 every mixed-case path — parity lives here, not in a caller.
|
|
547
|
+
keyFold: 'lower',
|
|
548
|
+
columns: [
|
|
549
|
+
'PageId', 'SiteId', 'Path', 'Title', 'Content', 'TemplateId', 'Published', 'IncludeInSitemap',
|
|
550
|
+
// the sitemap author controls (sitemap.service.js reads them off the row)
|
|
551
|
+
'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
552
|
+
],
|
|
553
|
+
join: {
|
|
554
|
+
table: 'PageTemplates',
|
|
555
|
+
on: ['TemplateId', 'TemplateId'],
|
|
556
|
+
columns: ['TemplateId', 'Alias', 'Name', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
557
|
+
},
|
|
558
|
+
shape: (row) => ({
|
|
559
|
+
// the DB-loaded shape getPageViewModel expects (contentPages.js)
|
|
560
|
+
id: row.PageId,
|
|
561
|
+
siteId: row.SiteId,
|
|
562
|
+
path: row.Path,
|
|
563
|
+
title: row.Title,
|
|
564
|
+
content: row.Content,
|
|
565
|
+
templateId: row.TemplateId,
|
|
566
|
+
published: asBool(row.Published),
|
|
567
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
568
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
569
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
570
|
+
sitemapPriority: row.SitemapPriority,
|
|
571
|
+
template: row.tpl_TemplateId
|
|
572
|
+
? {
|
|
573
|
+
id: row.tpl_TemplateId,
|
|
574
|
+
alias: row.tpl_Alias,
|
|
575
|
+
name: row.tpl_Name,
|
|
576
|
+
content: row.tpl_Content,
|
|
577
|
+
customDefaultLayout: row.tpl_CustomDefaultLayout,
|
|
578
|
+
layouts: row.tpl_Layouts,
|
|
579
|
+
}
|
|
580
|
+
: null,
|
|
581
|
+
// the fixture backend carries the key on every row; a provider swap must not lose it
|
|
582
|
+
key: row.Path,
|
|
583
|
+
}),
|
|
584
|
+
},
|
|
585
|
+
staticPages: {
|
|
586
|
+
// Static pages are a separate canon table (StaticPages) with their own sitemap
|
|
587
|
+
// flags — the sitemap enumerates them as their own source (`static-pages`), and the
|
|
588
|
+
// staticPages middleware serves their body/content-type by path.
|
|
589
|
+
table: 'StaticPages',
|
|
590
|
+
key: 'PageId',
|
|
591
|
+
columns: [
|
|
592
|
+
'PageId', 'SiteId', 'Path', 'Content', 'ContentType', 'Encoding', 'Published', 'IncludeInSitemap',
|
|
593
|
+
'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
594
|
+
],
|
|
595
|
+
shape: (row) => ({
|
|
596
|
+
id: row.PageId,
|
|
597
|
+
siteId: row.SiteId,
|
|
598
|
+
path: row.Path,
|
|
599
|
+
content: row.Content,
|
|
600
|
+
contentType: row.ContentType,
|
|
601
|
+
encoding: row.Encoding,
|
|
602
|
+
published: asBool(row.Published),
|
|
603
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
604
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
605
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
606
|
+
sitemapPriority: row.SitemapPriority,
|
|
607
|
+
key: row.PageId,
|
|
608
|
+
}),
|
|
609
|
+
},
|
|
610
|
+
pageTemplates: {
|
|
611
|
+
// The template row on its own (task:3933): a listing narrows pages by template, and
|
|
612
|
+
// the pages slice cannot filter on its joined columns — so the alias resolves to an id
|
|
613
|
+
// first, and the paginated read/count carry the id filter.
|
|
614
|
+
table: 'PageTemplates',
|
|
615
|
+
key: 'TemplateId',
|
|
616
|
+
columns: ['TemplateId', 'SiteId', 'Alias', 'Name', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
617
|
+
shape: (row) => ({
|
|
618
|
+
id: row.TemplateId,
|
|
619
|
+
siteId: row.SiteId,
|
|
620
|
+
alias: row.Alias,
|
|
621
|
+
name: row.Name,
|
|
622
|
+
content: row.Content,
|
|
623
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
624
|
+
layouts: row.Layouts,
|
|
625
|
+
key: row.TemplateId,
|
|
626
|
+
}),
|
|
627
|
+
},
|
|
628
|
+
reviewTemplates: {
|
|
629
|
+
// A review template is its own row (task:3933): the reviews listing narrows by alias, and
|
|
630
|
+
// a joined column is out of the filter's reach — so the alias resolves to an id first.
|
|
631
|
+
table: 'ReviewTemplates',
|
|
632
|
+
key: 'TemplateId',
|
|
633
|
+
columns: ['TemplateId', 'SiteId', 'Name', 'Alias', 'Description', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
634
|
+
shape: (row) => ({
|
|
635
|
+
id: row.TemplateId,
|
|
636
|
+
siteId: row.SiteId,
|
|
637
|
+
name: row.Name,
|
|
638
|
+
alias: row.Alias,
|
|
639
|
+
description: row.Description,
|
|
640
|
+
content: row.Content,
|
|
641
|
+
customDefaultLayout: row.CustomDefaultLayout,
|
|
642
|
+
layouts: row.Layouts,
|
|
643
|
+
key: row.TemplateId,
|
|
644
|
+
}),
|
|
645
|
+
},
|
|
646
|
+
reviews: {
|
|
647
|
+
// The reviewed item is joined as `item` (task:3933): the legacy include narrowed the
|
|
648
|
+
// listing by the PAGE's `Published` (and its path), so that column is filterable through
|
|
649
|
+
// its qualifier (`where: { 'Pages.Published': true }`) — a bare `Published` is the review's.
|
|
650
|
+
table: 'Reviews',
|
|
651
|
+
key: 'ReviewId',
|
|
652
|
+
columns: [
|
|
653
|
+
'ReviewId', 'SiteId', 'ItemType', 'ItemId', 'TemplateId', 'ReviewerName', 'ReviewerEmail',
|
|
654
|
+
'Public', 'Rating', 'Summary', 'Details', 'Pros', 'Cons', 'Fields',
|
|
655
|
+
'HelpfulVotesCount', 'NotHelpfulVotesCount', 'Published', 'DatePublished', 'IsSpam', 'IsDublicate',
|
|
656
|
+
],
|
|
657
|
+
join: {
|
|
658
|
+
table: 'Pages',
|
|
659
|
+
on: ['ItemId', 'PageId'],
|
|
660
|
+
// the legacy `item` was loaded with `attributes: ['id', 'path', 'title']`
|
|
661
|
+
columns: ['PageId', 'Path', 'Title', 'Published'],
|
|
662
|
+
},
|
|
663
|
+
shape: (row) => ({
|
|
664
|
+
id: row.ReviewId,
|
|
665
|
+
siteId: row.SiteId,
|
|
666
|
+
itemType: row.ItemType,
|
|
667
|
+
itemId: row.ItemId,
|
|
668
|
+
templateId: row.TemplateId,
|
|
669
|
+
reviewerName: row.ReviewerName,
|
|
670
|
+
reviewerEmail: row.ReviewerEmail,
|
|
671
|
+
public: asBool(row.Public),
|
|
672
|
+
rating: row.Rating,
|
|
673
|
+
summary: row.Summary,
|
|
674
|
+
details: row.Details,
|
|
675
|
+
pros: row.Pros,
|
|
676
|
+
cons: row.Cons,
|
|
677
|
+
fields: row.Fields,
|
|
678
|
+
helpfulVotesCount: row.HelpfulVotesCount,
|
|
679
|
+
notHelpfulVotesCount: row.NotHelpfulVotesCount,
|
|
680
|
+
published: asBool(row.Published),
|
|
681
|
+
datePublished: row.DatePublished,
|
|
682
|
+
isSpam: asBool(row.IsSpam),
|
|
683
|
+
isDublicate: asBool(row.IsDublicate),
|
|
684
|
+
item: row.tpl_PageId ? { id: row.tpl_PageId, path: row.tpl_Path, title: row.tpl_Title } : null,
|
|
685
|
+
key: row.ReviewId,
|
|
686
|
+
}),
|
|
687
|
+
},
|
|
688
|
+
// Feeds (task:3933): a feed is resolved by its pre-lowered alias and configured by child
|
|
689
|
+
// rows, so the service reads two slices and nests (`core/lib/services/feeds.js`).
|
|
690
|
+
feeds: {
|
|
691
|
+
table: 'Feeds',
|
|
692
|
+
key: 'FeedId',
|
|
693
|
+
columns: ['FeedId', 'SiteId', 'Title', 'Alias', 'LoweredAlias', 'Discriminator', 'Content', 'FetchInterval'],
|
|
694
|
+
shape: (row) => ({
|
|
695
|
+
id: row.FeedId,
|
|
696
|
+
siteId: row.SiteId,
|
|
697
|
+
title: row.Title,
|
|
698
|
+
alias: row.Alias,
|
|
699
|
+
loweredAlias: row.LoweredAlias,
|
|
700
|
+
discriminator: row.Discriminator,
|
|
701
|
+
content: row.Content,
|
|
702
|
+
fetchInterval: row.FetchInterval,
|
|
703
|
+
key: row.FeedId,
|
|
704
|
+
}),
|
|
705
|
+
},
|
|
706
|
+
feedParams: {
|
|
707
|
+
table: 'FeedParams',
|
|
708
|
+
key: 'ParamId',
|
|
709
|
+
columns: ['ParamId', 'FeedId', 'Key', 'Value', 'Liquidizable', 'SiteId'],
|
|
710
|
+
// `key` here is the param NAME the feed renders (`path`, `template`, `orderby`), not the
|
|
711
|
+
// seam identity — the Sequelize shape the view models read spells it the same way.
|
|
712
|
+
shape: (row) => ({
|
|
713
|
+
id: row.ParamId,
|
|
714
|
+
feedId: row.FeedId,
|
|
715
|
+
key: row.Key,
|
|
716
|
+
value: row.Value,
|
|
717
|
+
liquidizable: asBool(row.Liquidizable),
|
|
718
|
+
}),
|
|
719
|
+
},
|
|
720
|
+
feedPages: {
|
|
721
|
+
// One table carries all three subtypes (EF table-per-hierarchy); `discriminator`
|
|
722
|
+
// separates root FeedPage from SegmentFeedPage/SlugFeedPage leaves.
|
|
723
|
+
table: 'FeedPages',
|
|
724
|
+
key: 'PageId',
|
|
725
|
+
columns: [
|
|
726
|
+
'PageId', 'SiteId', 'ParentPageId', 'Discriminator', 'Path', 'Slug', 'Title', 'FeedId',
|
|
727
|
+
'TemplateName', 'PageContent', 'PageNumberParamName', 'PageSizeParamName', 'PageSize', 'Level', 'Params',
|
|
728
|
+
],
|
|
729
|
+
shape: (row) => ({
|
|
730
|
+
id: row.PageId,
|
|
731
|
+
siteId: row.SiteId,
|
|
732
|
+
parentPageId: row.ParentPageId,
|
|
733
|
+
discriminator: row.Discriminator,
|
|
734
|
+
path: row.Path,
|
|
735
|
+
slug: row.Slug,
|
|
736
|
+
title: row.Title,
|
|
737
|
+
feedId: row.FeedId,
|
|
738
|
+
templateName: row.TemplateName,
|
|
739
|
+
pageContent: row.PageContent,
|
|
740
|
+
pageNumberParamName: row.PageNumberParamName,
|
|
741
|
+
pageSizeParamName: row.PageSizeParamName,
|
|
742
|
+
pageSize: row.PageSize,
|
|
743
|
+
level: row.Level,
|
|
744
|
+
params: row.Params,
|
|
745
|
+
key: row.PageId,
|
|
746
|
+
}),
|
|
747
|
+
},
|
|
748
|
+
catalogs: {
|
|
749
|
+
// A catalog is its own content container (many per site) — section pages, product
|
|
750
|
+
// listings and taxonomy hanging off it all read this row first (task:3873 sitemap).
|
|
751
|
+
table: 'Catalogs',
|
|
752
|
+
key: 'CatalogId',
|
|
753
|
+
columns: [
|
|
754
|
+
'CatalogId', 'SiteId', 'Title', 'Path', 'Alias', 'Content',
|
|
755
|
+
'CatalogLayout', 'CatalogLayouts', 'CatalogFields',
|
|
756
|
+
'CategoriesLayout', 'CategoryLayout', 'CategoryLayouts', 'CategoryFields',
|
|
757
|
+
'TagsLayout', 'TagLayout', 'TagLayouts', 'TagFields',
|
|
758
|
+
'ProductLayout', 'ProductPath', 'ProductsPath', 'ProductNumberFormat',
|
|
759
|
+
'CategoriesPath', 'TagsPath',
|
|
760
|
+
'IncludeInSitemap', 'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
761
|
+
],
|
|
762
|
+
shape: (row) => ({
|
|
763
|
+
id: row.CatalogId,
|
|
764
|
+
siteId: row.SiteId,
|
|
765
|
+
title: row.Title,
|
|
766
|
+
path: row.Path,
|
|
767
|
+
alias: row.Alias,
|
|
768
|
+
content: row.Content,
|
|
769
|
+
catalogLayout: row.CatalogLayout,
|
|
770
|
+
catalogLayouts: row.CatalogLayouts,
|
|
771
|
+
catalogFields: row.CatalogFields,
|
|
772
|
+
categoriesLayout: row.CategoriesLayout,
|
|
773
|
+
categoryLayout: row.CategoryLayout,
|
|
774
|
+
categoryLayouts: row.CategoryLayouts,
|
|
775
|
+
categoryFields: row.CategoryFields,
|
|
776
|
+
tagsLayout: row.TagsLayout,
|
|
777
|
+
tagLayout: row.TagLayout,
|
|
778
|
+
tagLayouts: row.TagLayouts,
|
|
779
|
+
tagFields: row.TagFields,
|
|
780
|
+
productLayout: row.ProductLayout,
|
|
781
|
+
productPath: row.ProductPath,
|
|
782
|
+
productsPath: row.ProductsPath,
|
|
783
|
+
productNumberFormat: row.ProductNumberFormat,
|
|
784
|
+
categoriesPath: row.CategoriesPath,
|
|
785
|
+
tagsPath: row.TagsPath,
|
|
786
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
787
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
788
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
789
|
+
sitemapPriority: row.SitemapPriority,
|
|
790
|
+
key: row.CatalogId,
|
|
791
|
+
}),
|
|
792
|
+
},
|
|
793
|
+
catalogProducts: {
|
|
794
|
+
// Catalog products are their own canon table (CatalogProducts, ProductId-keyed),
|
|
795
|
+
// one product row per catalog — a different table from the store's Products.
|
|
796
|
+
table: 'CatalogProducts',
|
|
797
|
+
key: 'ProductId',
|
|
798
|
+
columns: [
|
|
799
|
+
'ProductId', 'CatalogId', 'SiteId', 'TemplateId', 'Number', 'Slug', 'Title', 'Content',
|
|
800
|
+
'Published', 'IncludeInSitemap',
|
|
801
|
+
'SitemapLastUpdatedTime', 'SitemapChangeFrequency', 'SitemapPriority',
|
|
802
|
+
],
|
|
803
|
+
shape: (row) => ({
|
|
804
|
+
id: row.ProductId,
|
|
805
|
+
catalogId: row.CatalogId,
|
|
806
|
+
templateId: row.TemplateId,
|
|
807
|
+
number: row.Number,
|
|
808
|
+
slug: row.Slug,
|
|
809
|
+
title: row.Title,
|
|
810
|
+
content: row.Content,
|
|
811
|
+
published: asBool(row.Published),
|
|
812
|
+
includeInSitemap: asBool(row.IncludeInSitemap),
|
|
813
|
+
sitemapLastUpdatedTime: row.SitemapLastUpdatedTime,
|
|
814
|
+
sitemapChangeFrequency: row.SitemapChangeFrequency,
|
|
815
|
+
sitemapPriority: row.SitemapPriority,
|
|
816
|
+
key: row.ProductId,
|
|
817
|
+
}),
|
|
818
|
+
},
|
|
819
|
+
catalogCategories: {
|
|
820
|
+
table: 'CatalogCategories',
|
|
821
|
+
key: 'CategoryId',
|
|
822
|
+
columns: ['CategoryId', 'CatalogId', 'SiteId', 'Title', 'Slug', 'Content'],
|
|
823
|
+
shape: (row) => ({
|
|
824
|
+
id: row.CategoryId,
|
|
825
|
+
catalogId: row.CatalogId,
|
|
826
|
+
title: row.Title,
|
|
827
|
+
slug: row.Slug,
|
|
828
|
+
content: row.Content,
|
|
829
|
+
key: row.CategoryId,
|
|
830
|
+
}),
|
|
831
|
+
},
|
|
832
|
+
catalogTags: {
|
|
833
|
+
table: 'CatalogTags',
|
|
834
|
+
key: 'TagId',
|
|
835
|
+
columns: ['TagId', 'CatalogId', 'SiteId', 'Title', 'Slug', 'Content'],
|
|
836
|
+
shape: (row) => ({
|
|
837
|
+
id: row.TagId,
|
|
838
|
+
catalogId: row.CatalogId,
|
|
839
|
+
title: row.Title,
|
|
840
|
+
slug: row.Slug,
|
|
841
|
+
content: row.Content,
|
|
842
|
+
key: row.TagId,
|
|
843
|
+
}),
|
|
844
|
+
},
|
|
845
|
+
catalogProductTemplates: {
|
|
846
|
+
// The catalog product's own template (the legacy `include { as: 'template' }`).
|
|
847
|
+
table: 'CatalogProductTemplates',
|
|
848
|
+
key: 'TemplateId',
|
|
849
|
+
columns: ['TemplateId', 'CatalogId', 'Name', 'Alias', 'Content'],
|
|
850
|
+
shape: (row) => ({
|
|
851
|
+
id: row.TemplateId,
|
|
852
|
+
catalogId: row.CatalogId,
|
|
853
|
+
name: row.Name,
|
|
854
|
+
alias: row.Alias,
|
|
855
|
+
content: row.Content,
|
|
856
|
+
key: row.TemplateId,
|
|
857
|
+
}),
|
|
858
|
+
},
|
|
859
|
+
catalogProductsCategories: {
|
|
860
|
+
// The catalog product↔category edge; a product's categories are composed in the
|
|
861
|
+
// service (one indexed read per edge), not joined — a to-many join fans the row out.
|
|
862
|
+
table: 'CatalogProductsCategories',
|
|
863
|
+
key: 'ProductId',
|
|
864
|
+
columns: ['ProductId', 'CategoryId', 'SiteId'],
|
|
865
|
+
shape: (row) => ({ productId: row.ProductId, categoryId: row.CategoryId, key: row.ProductId }),
|
|
866
|
+
},
|
|
867
|
+
catalogProductsTags: {
|
|
868
|
+
table: 'CatalogProductsTags',
|
|
869
|
+
key: 'ProductId',
|
|
870
|
+
columns: ['ProductId', 'TagId', 'SiteId'],
|
|
871
|
+
shape: (row) => ({ productId: row.ProductId, tagId: row.TagId, key: row.ProductId }),
|
|
872
|
+
},
|
|
873
|
+
// Media (task:3888): the view-model builder reads the item's galleries, and images hang off
|
|
874
|
+
// a gallery 1:N — the declaration carries one table per slice (no `join`), so the caller
|
|
875
|
+
// enumerates both and nests (`core/lib/services/galleries.js`).
|
|
876
|
+
galleries: {
|
|
877
|
+
table: 'Gallery',
|
|
878
|
+
key: 'IdGallery',
|
|
879
|
+
columns: ['IdGallery', 'IdOwner', 'Name', 'Title', 'Description', 'TemplateId'],
|
|
880
|
+
shape: (row) => ({
|
|
881
|
+
id: row.IdGallery,
|
|
882
|
+
ownerId: row.IdOwner,
|
|
883
|
+
name: row.Name,
|
|
884
|
+
title: row.Title,
|
|
885
|
+
description: row.Description,
|
|
886
|
+
templateId: row.TemplateId,
|
|
887
|
+
key: row.IdGallery,
|
|
888
|
+
}),
|
|
889
|
+
},
|
|
890
|
+
galleryImages: {
|
|
891
|
+
table: 'GalleryImage',
|
|
892
|
+
key: 'IdImage',
|
|
893
|
+
columns: [
|
|
894
|
+
'IdImage', 'IdGallery', 'Seq', 'Title', 'Alt', 'Description', 'Visible',
|
|
895
|
+
'Author', 'Copyright', 'Hash', 'OriginalFilename',
|
|
896
|
+
// the image URL is signed over this pair (and the theme prints the rest): leaving them
|
|
897
|
+
// out of the read contract degraded every gallery image to s3://undefined/… (task:3944)
|
|
898
|
+
'StorageClient', 'StorageFilename', 'MimeType', 'Width', 'Height', 'FileSize',
|
|
899
|
+
],
|
|
900
|
+
shape: (row) => ({
|
|
901
|
+
id: row.IdImage,
|
|
902
|
+
galleryId: row.IdGallery,
|
|
903
|
+
seq: row.Seq,
|
|
904
|
+
title: row.Title,
|
|
905
|
+
alt: row.Alt,
|
|
906
|
+
description: row.Description,
|
|
907
|
+
// Surfaced as a bool instead of filtered here: `Visible` is a bool column, and a
|
|
908
|
+
// bound bool is dialect-dependent (sqlite/mariadb keep 0/1, postgres a real
|
|
909
|
+
// boolean), so the caller keeps the filter (services/galleries.js).
|
|
910
|
+
visible: asBool(row.Visible),
|
|
911
|
+
author: row.Author,
|
|
912
|
+
copyright: row.Copyright,
|
|
913
|
+
hash: row.Hash,
|
|
914
|
+
originalFilename: row.OriginalFilename,
|
|
915
|
+
storageClient: row.StorageClient,
|
|
916
|
+
storageFilename: row.StorageFilename,
|
|
917
|
+
mimeType: row.MimeType,
|
|
918
|
+
width: row.Width,
|
|
919
|
+
height: row.Height,
|
|
920
|
+
fileSize: row.FileSize,
|
|
921
|
+
key: row.IdImage,
|
|
922
|
+
}),
|
|
923
|
+
},
|
|
924
|
+
// Theme layouts (task:3944): the render path loads a theme's liquid layouts from the DB at
|
|
925
|
+
// boot, one row per layout name.
|
|
926
|
+
layouts: {
|
|
927
|
+
table: 'Layouts',
|
|
928
|
+
key: 'LayoutId',
|
|
929
|
+
columns: ['LayoutId', 'SiteId', 'ThemeId', 'Name', 'LoweredName', 'Content', 'ViewEngineId'],
|
|
930
|
+
shape: (row) => ({
|
|
931
|
+
id: row.LayoutId,
|
|
932
|
+
siteId: row.SiteId,
|
|
933
|
+
themeId: row.ThemeId,
|
|
934
|
+
name: row.Name,
|
|
935
|
+
loweredName: row.LoweredName,
|
|
936
|
+
content: row.Content,
|
|
937
|
+
viewEngineId: row.ViewEngineId,
|
|
938
|
+
key: row.LayoutId,
|
|
939
|
+
}),
|
|
940
|
+
},
|
|
941
|
+
// Media folders and files (task:3933): the media middleware resolves a folder by its owner +
|
|
942
|
+
// lowered path and then an asset by its folder + lowered filename — one read each.
|
|
943
|
+
folders: {
|
|
944
|
+
table: 'Folders',
|
|
945
|
+
key: 'FolderId',
|
|
946
|
+
columns: ['FolderId', 'SiteId', 'OwnerId', 'Path', 'LoweredPath'],
|
|
947
|
+
shape: (row) => ({
|
|
948
|
+
id: row.FolderId,
|
|
949
|
+
siteId: row.SiteId,
|
|
950
|
+
ownerId: row.OwnerId,
|
|
951
|
+
path: row.Path,
|
|
952
|
+
loweredPath: row.LoweredPath,
|
|
953
|
+
key: row.FolderId,
|
|
954
|
+
}),
|
|
955
|
+
},
|
|
956
|
+
files: {
|
|
957
|
+
table: 'Files',
|
|
958
|
+
key: 'FileId',
|
|
959
|
+
columns: ['FileId', 'SiteId', 'FolderId', 'Filename', 'LoweredFilename', 'ContentType', 'Size', 'StorageClient', 'StorageFilename', 'Hash'],
|
|
960
|
+
shape: (row) => ({
|
|
961
|
+
id: row.FileId,
|
|
962
|
+
siteId: row.SiteId,
|
|
963
|
+
folderId: row.FolderId,
|
|
964
|
+
filename: row.Filename,
|
|
965
|
+
loweredFilename: row.LoweredFilename,
|
|
966
|
+
contentType: row.ContentType,
|
|
967
|
+
size: Number(row.Size),
|
|
968
|
+
storageClient: row.StorageClient,
|
|
969
|
+
storageFilename: row.StorageFilename,
|
|
970
|
+
hash: row.Hash,
|
|
971
|
+
key: row.FileId,
|
|
972
|
+
}),
|
|
973
|
+
},
|
|
974
|
+
// Email templates (task:3933): the composer resolves one by its pre-lowered name.
|
|
975
|
+
emailTemplates: {
|
|
976
|
+
table: 'EmailTemplates',
|
|
977
|
+
key: 'TemplateId',
|
|
978
|
+
columns: [
|
|
979
|
+
'TemplateId', 'SiteId', 'Name', 'LoweredName', 'Description',
|
|
980
|
+
'From', 'FromDisplayName', 'To', 'Cc', 'Bcc', 'Subject', 'HtmlEnabled', 'PlainText', 'Html',
|
|
981
|
+
],
|
|
982
|
+
shape: (row) => ({
|
|
983
|
+
id: row.TemplateId,
|
|
984
|
+
siteId: row.SiteId,
|
|
985
|
+
name: row.Name,
|
|
986
|
+
loweredName: row.LoweredName,
|
|
987
|
+
description: row.Description,
|
|
988
|
+
from: row.From,
|
|
989
|
+
fromDisplayName: row.FromDisplayName,
|
|
990
|
+
to: row.To,
|
|
991
|
+
cc: row.Cc,
|
|
992
|
+
bcc: row.Bcc,
|
|
993
|
+
subject: row.Subject,
|
|
994
|
+
htmlEnabled: asBool(row.HtmlEnabled),
|
|
995
|
+
plainText: row.PlainText,
|
|
996
|
+
html: row.Html,
|
|
997
|
+
key: row.TemplateId,
|
|
998
|
+
}),
|
|
999
|
+
},
|
|
1000
|
+
// Forms (task:3944): the submission flow reads one form by path with its template, writes the
|
|
1001
|
+
// submission, and resolves the recipients' admin emails. `Administrators` is a control-scope
|
|
1002
|
+
// table, read here through the site channel because the mode is one schema (requirements:67
|
|
1003
|
+
// R2): the guard scopes site tables and leaves a control table alone.
|
|
1004
|
+
forms: {
|
|
1005
|
+
table: 'Forms',
|
|
1006
|
+
key: 'FormId',
|
|
1007
|
+
columns: ['FormId', 'SiteId', 'Name', 'Content', 'SubmissionAction', 'RedirectUrl', 'SuccessMessage', 'Path', 'TemplateId'],
|
|
1008
|
+
join: {
|
|
1009
|
+
table: 'FormTemplates',
|
|
1010
|
+
on: ['TemplateId', 'TemplateId'],
|
|
1011
|
+
columns: ['TemplateId', 'Alias', 'Name', 'Content', 'ConfirmationEmailTemplate', 'SubmissionEmailTemplate', 'CustomDefaultLayout', 'Layouts'],
|
|
1012
|
+
},
|
|
1013
|
+
shape: (row) => ({
|
|
1014
|
+
id: row.FormId,
|
|
1015
|
+
siteId: row.SiteId,
|
|
1016
|
+
name: row.Name,
|
|
1017
|
+
content: row.Content,
|
|
1018
|
+
submissionAction: row.SubmissionAction,
|
|
1019
|
+
redirectUrl: row.RedirectUrl,
|
|
1020
|
+
successMessage: row.SuccessMessage,
|
|
1021
|
+
path: row.Path,
|
|
1022
|
+
templateId: row.TemplateId,
|
|
1023
|
+
template: row.tpl_TemplateId
|
|
1024
|
+
? {
|
|
1025
|
+
id: row.tpl_TemplateId,
|
|
1026
|
+
alias: row.tpl_Alias,
|
|
1027
|
+
name: row.tpl_Name,
|
|
1028
|
+
content: row.tpl_Content,
|
|
1029
|
+
confirmationEmailTemplate: row.tpl_ConfirmationEmailTemplate,
|
|
1030
|
+
submissionEmailTemplate: row.tpl_SubmissionEmailTemplate,
|
|
1031
|
+
customDefaultLayout: row.tpl_CustomDefaultLayout,
|
|
1032
|
+
layouts: row.tpl_Layouts,
|
|
1033
|
+
}
|
|
1034
|
+
: null,
|
|
1035
|
+
key: row.FormId,
|
|
1036
|
+
}),
|
|
1037
|
+
},
|
|
1038
|
+
formSubmissions: {
|
|
1039
|
+
table: 'FormSubmissions',
|
|
1040
|
+
key: 'SubmissionId',
|
|
1041
|
+
columns: ['SubmissionId', 'SiteId', 'FormId', 'Date', 'UtcDate', 'Number', 'IpAddress', 'UserAgent', 'IsSpam', 'Content'],
|
|
1042
|
+
shape: (row) => ({
|
|
1043
|
+
id: row.SubmissionId,
|
|
1044
|
+
siteId: row.SiteId,
|
|
1045
|
+
formId: row.FormId,
|
|
1046
|
+
date: row.Date,
|
|
1047
|
+
utcDate: row.UtcDate,
|
|
1048
|
+
number: row.Number,
|
|
1049
|
+
ipAddress: row.IpAddress,
|
|
1050
|
+
userAgent: row.UserAgent,
|
|
1051
|
+
isSpam: asBool(row.IsSpam),
|
|
1052
|
+
content: row.Content,
|
|
1053
|
+
key: row.SubmissionId,
|
|
1054
|
+
}),
|
|
1055
|
+
},
|
|
1056
|
+
formRecipients: {
|
|
1057
|
+
table: 'FormRecipients',
|
|
1058
|
+
key: 'RecipientId',
|
|
1059
|
+
columns: ['RecipientId', 'SiteId', 'FormId', 'AdministratorId', 'DateCreated', 'UtcDateCreated'],
|
|
1060
|
+
shape: (row) => ({
|
|
1061
|
+
id: row.RecipientId,
|
|
1062
|
+
siteId: row.SiteId,
|
|
1063
|
+
formId: row.FormId,
|
|
1064
|
+
administratorId: row.AdministratorId,
|
|
1065
|
+
dateCreated: row.DateCreated,
|
|
1066
|
+
key: row.RecipientId,
|
|
1067
|
+
}),
|
|
1068
|
+
},
|
|
1069
|
+
administrators: {
|
|
1070
|
+
table: 'Administrators',
|
|
1071
|
+
key: 'AdministratorId',
|
|
1072
|
+
// no Password column: the serving path reads identity for the recipient emails, and a
|
|
1073
|
+
// read contract that hands out credentials is a leak waiting for a caller
|
|
1074
|
+
columns: ['AdministratorId', 'Username', 'FirstName', 'LastName', 'Email', 'DateCreated'],
|
|
1075
|
+
shape: (row) => ({
|
|
1076
|
+
id: row.AdministratorId,
|
|
1077
|
+
username: row.Username,
|
|
1078
|
+
firstName: row.FirstName,
|
|
1079
|
+
lastName: row.LastName,
|
|
1080
|
+
email: row.Email,
|
|
1081
|
+
dateCreated: row.DateCreated,
|
|
1082
|
+
key: row.AdministratorId,
|
|
1083
|
+
}),
|
|
1084
|
+
},
|
|
1085
|
+
// Numbering (task:3933): the task is a DICTIONARY (no SiteId — one set of rows for the whole
|
|
1086
|
+
// database), so it is read through the control channel; the counters are the tenant's own and
|
|
1087
|
+
// are read AND written through the site channel (`packages/numbering/numberingProvider.js`).
|
|
1088
|
+
numberingTasks: {
|
|
1089
|
+
table: 'NumberingTasks',
|
|
1090
|
+
key: 'TaskId',
|
|
1091
|
+
columns: ['TaskId', 'Label', 'Name', 'Commentary', 'PeriodTypeId', 'MinNumber', 'MaxNumber'],
|
|
1092
|
+
shape: (row) => ({
|
|
1093
|
+
id: row.TaskId,
|
|
1094
|
+
label: row.Label,
|
|
1095
|
+
name: row.Name,
|
|
1096
|
+
description: row.Commentary,
|
|
1097
|
+
periodTypeId: row.PeriodTypeId,
|
|
1098
|
+
minNumber: row.MinNumber,
|
|
1099
|
+
maxNumber: row.MaxNumber,
|
|
1100
|
+
key: row.TaskId,
|
|
1101
|
+
}),
|
|
1102
|
+
},
|
|
1103
|
+
numberingCounters: {
|
|
1104
|
+
table: 'NumberingCounters',
|
|
1105
|
+
key: 'CounterId',
|
|
1106
|
+
columns: ['CounterId', 'SiteId', 'TaskId', 'OwnerId', 'Date', 'LastNumber'],
|
|
1107
|
+
shape: (row) => ({
|
|
1108
|
+
id: row.CounterId,
|
|
1109
|
+
siteId: row.SiteId,
|
|
1110
|
+
taskId: row.TaskId,
|
|
1111
|
+
ownerId: row.OwnerId,
|
|
1112
|
+
date: row.Date,
|
|
1113
|
+
lastNumber: row.LastNumber,
|
|
1114
|
+
key: row.CounterId,
|
|
1115
|
+
}),
|
|
1116
|
+
},
|
|
1117
|
+
// Part templates (task:3888): one slice per canon table, named after the LEGACY model the
|
|
1118
|
+
// engine passes to fillViewModel (see core/lib/services/partTemplates.js for the mapping).
|
|
1119
|
+
pagePartTemplates: partTemplateSlice('PagePartTemplates', 'SiteId'),
|
|
1120
|
+
blogPostPartTemplates: partTemplateSlice('BlogPostPartTemplates', 'BlogId'),
|
|
1121
|
+
catalogProductPartTemplates: partTemplateSlice('CatalogProductPartTemplates', 'CatalogId'),
|
|
1122
|
+
storeProductPartTemplates: partTemplateSlice('ProductPartTemplates', 'StoreId'),
|
|
1123
|
+
storePartTemplates: partTemplateSlice('StorePartTemplates', 'StoreId'),
|
|
1124
|
+
tableRecordPartTemplates: partTemplateSlice('TableRecordPartTemplates', 'SiteId'),
|
|
1125
|
+
// Data tables (task:3933): a table row carries its template, and its records hang off
|
|
1126
|
+
// TableId — one read per call, composed in `core/lib/services/tables.js`.
|
|
1127
|
+
tables: {
|
|
1128
|
+
table: 'Tables',
|
|
1129
|
+
key: 'TableId',
|
|
1130
|
+
columns: ['TableId', 'SiteId', 'Title', 'Slug', 'TemplateId'],
|
|
1131
|
+
join: {
|
|
1132
|
+
table: 'TableTemplates',
|
|
1133
|
+
on: ['TemplateId', 'TemplateId'],
|
|
1134
|
+
columns: ['TemplateId', 'Alias', 'Name', 'Description', 'Content', 'CustomDefaultLayout', 'Layouts'],
|
|
1135
|
+
},
|
|
1136
|
+
shape: (row) => ({
|
|
1137
|
+
id: row.TableId,
|
|
1138
|
+
siteId: row.SiteId,
|
|
1139
|
+
title: row.Title,
|
|
1140
|
+
slug: row.Slug,
|
|
1141
|
+
templateId: row.TemplateId,
|
|
1142
|
+
template: row.tpl_TemplateId
|
|
1143
|
+
? {
|
|
1144
|
+
id: row.tpl_TemplateId,
|
|
1145
|
+
alias: row.tpl_Alias,
|
|
1146
|
+
name: row.tpl_Name,
|
|
1147
|
+
description: row.tpl_Description,
|
|
1148
|
+
content: row.tpl_Content,
|
|
1149
|
+
customDefaultLayout: row.tpl_CustomDefaultLayout,
|
|
1150
|
+
layouts: row.tpl_Layouts,
|
|
1151
|
+
}
|
|
1152
|
+
: null,
|
|
1153
|
+
key: row.TableId,
|
|
1154
|
+
}),
|
|
1155
|
+
},
|
|
1156
|
+
tableRecords: {
|
|
1157
|
+
table: 'TableRecords',
|
|
1158
|
+
key: 'RecordId',
|
|
1159
|
+
columns: ['RecordId', 'SiteId', 'TableId', 'Title', 'Slug', 'Content', 'Published', 'DatePublished'],
|
|
1160
|
+
shape: (row) => ({
|
|
1161
|
+
id: row.RecordId,
|
|
1162
|
+
siteId: row.SiteId,
|
|
1163
|
+
tableId: row.TableId,
|
|
1164
|
+
title: row.Title,
|
|
1165
|
+
slug: row.Slug,
|
|
1166
|
+
content: row.Content,
|
|
1167
|
+
published: asBool(row.Published),
|
|
1168
|
+
datePublished: row.DatePublished,
|
|
1169
|
+
key: row.RecordId,
|
|
1170
|
+
}),
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
/** `Server=host;Database=db;User Id=u;Password=p;Port=1433;Encrypt=true;…` → tedious config. */
|
|
1174
|
+
const parseMssqlDsn = (dsn) => {
|
|
1175
|
+
const parts = {};
|
|
1176
|
+
for (const pair of dsn.split(';')) {
|
|
1177
|
+
const at = pair.indexOf('=');
|
|
1178
|
+
if (at > 0)
|
|
1179
|
+
parts[pair.slice(0, at).trim().toLowerCase()] = pair.slice(at + 1).trim();
|
|
1180
|
+
}
|
|
1181
|
+
const pick = (...names) => names.map((name) => parts[name]).find((value) => value !== undefined);
|
|
1182
|
+
// classic MSSQL syntax puts the port after the host: `Server=host,1433`
|
|
1183
|
+
const rawServer = String(pick('server', 'data source', 'host') ?? '').replace(/^tcp:/i, '');
|
|
1184
|
+
const [host, inlinePort] = rawServer.split(',');
|
|
1185
|
+
const server = host.trim();
|
|
1186
|
+
const database = pick('database', 'initial catalog');
|
|
1187
|
+
const rawPort = pick('port') ?? inlinePort ?? 1433;
|
|
1188
|
+
const port = Number(rawPort);
|
|
1189
|
+
// this is the migration SOURCE (plan:49 Q4): a typo here means a transfer silently
|
|
1190
|
+
// reads nothing, and tedious reports it later in its own words
|
|
1191
|
+
if (!server)
|
|
1192
|
+
throw new Error('db-provider: the MSSQL DSN names no server (Server=/Data Source=)');
|
|
1193
|
+
if (!database)
|
|
1194
|
+
throw new Error('db-provider: the MSSQL DSN names no database (Database=/Initial Catalog=)');
|
|
1195
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1196
|
+
throw new Error(`db-provider: the MSSQL DSN has an unusable port (${JSON.stringify(rawPort)})`);
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
server,
|
|
1200
|
+
options: {
|
|
1201
|
+
database,
|
|
1202
|
+
port,
|
|
1203
|
+
encrypt: pick('encrypt') !== 'false',
|
|
1204
|
+
trustServerCertificate: pick('trustservercertificate', 'trust server certificate') !== 'false',
|
|
1205
|
+
// tedious defaults to 15 s; a cutover DELETE on a 166k-row table (Folders) legitimately
|
|
1206
|
+
// runs longer and dies as a bare ETIMEOUT. `RequestTimeout=0` = no client-side limit.
|
|
1207
|
+
requestTimeout: pick('requesttimeout') === undefined ? 15000 : Number(pick('requesttimeout')),
|
|
1208
|
+
},
|
|
1209
|
+
authentication: {
|
|
1210
|
+
type: 'default',
|
|
1211
|
+
options: {
|
|
1212
|
+
userName: pick('user id', 'uid', 'user', 'username'),
|
|
1213
|
+
password: pick('password', 'pwd'),
|
|
1214
|
+
},
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
};
|
|
1218
|
+
/**
|
|
1219
|
+
* T-SQL spells `limit`/`offset` as `OFFSET n ROWS FETCH NEXT m ROWS ONLY`, and the fetch
|
|
1220
|
+
* requires an `ORDER BY`. Kysely compiles the generic `limit`, which SQL Server rejects —
|
|
1221
|
+
* so paging on the migration source is fixed here, once, instead of in every caller
|
|
1222
|
+
* (found by task:3955: the admin API lists pages on MSSQL).
|
|
1223
|
+
*/
|
|
1224
|
+
class MssqlFetchCompiler extends MssqlQueryCompiler {
|
|
1225
|
+
#limit = null;
|
|
1226
|
+
compileQuery(node) {
|
|
1227
|
+
this.#limit = null;
|
|
1228
|
+
return super.compileQuery(new MssqlPagingTransformer().transformNode(node));
|
|
1229
|
+
}
|
|
1230
|
+
// `limit` is visited first, `offset` last: hold the fetch and emit it after the offset,
|
|
1231
|
+
// which is the only order T-SQL accepts (`OFFSET n ROWS FETCH NEXT m ROWS ONLY`).
|
|
1232
|
+
visitLimit(node) {
|
|
1233
|
+
this.#limit = node.limit;
|
|
1234
|
+
}
|
|
1235
|
+
visitOffset(node) {
|
|
1236
|
+
this.append('offset ');
|
|
1237
|
+
this.visitNode(node.offset);
|
|
1238
|
+
this.append(' rows');
|
|
1239
|
+
if (this.#limit) {
|
|
1240
|
+
this.append(' fetch next ');
|
|
1241
|
+
this.visitNode(this.#limit);
|
|
1242
|
+
this.append(' rows only');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
class MssqlPagingTransformer extends OperationNodeTransformer {
|
|
1247
|
+
transformSelectQuery(node) {
|
|
1248
|
+
const transformed = super.transformSelectQuery(node);
|
|
1249
|
+
if (!transformed.limit)
|
|
1250
|
+
return transformed;
|
|
1251
|
+
// `limit` alone is `TOP(n)`: a fetch needs an ORDER BY, and ordering a whole table
|
|
1252
|
+
// just to take n rows is the difference between milliseconds and a full sort.
|
|
1253
|
+
if (!transformed.offset && !transformed.orderBy) {
|
|
1254
|
+
// `top()` takes the literal count; the limit node wraps it in a value node.
|
|
1255
|
+
return { ...transformed, top: TopNode.create(transformed.limit.limit.value), limit: undefined };
|
|
1256
|
+
}
|
|
1257
|
+
// Paged reads keep an ORDER BY (offset/fetch demands one); an unordered page gets
|
|
1258
|
+
// the dialect-neutral `(select null)`.
|
|
1259
|
+
return {
|
|
1260
|
+
...transformed,
|
|
1261
|
+
orderBy: transformed.orderBy ??
|
|
1262
|
+
OrderByNode.create([
|
|
1263
|
+
OrderByItemNode.create(RawNode.createWithSql('(select null)'), RawNode.createWithSql('asc')),
|
|
1264
|
+
]),
|
|
1265
|
+
offset: transformed.offset ?? OffsetNode.create(ValueNode.createImmediate(0)),
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* SQL Server's driver hands every `decimal`/`numeric` back as a JS float — in every
|
|
1271
|
+
* version of tedious, by construction (`value * sign / 10**scale`). The canon says a
|
|
1272
|
+
* decimal is a string precisely because floats lose money, and `decimal(18,2)` reaches
|
|
1273
|
+
* past a double's exact range, so a read that skipped this would quietly round prices.
|
|
1274
|
+
*
|
|
1275
|
+
* The rewrite is per query: a plain `select *` of one canon table becomes the explicit
|
|
1276
|
+
* column list, with decimal columns cast to text. It stays inside the dialect seam
|
|
1277
|
+
* (task:3994) and only touches the shape it must — a joined `select *` is left alone,
|
|
1278
|
+
* and callers that name their columns are untouched.
|
|
1279
|
+
*/
|
|
1280
|
+
class MssqlDecimalTexts extends OperationNodeTransformer {
|
|
1281
|
+
transformSelectQuery(node) {
|
|
1282
|
+
const transformed = super.transformSelectQuery(node);
|
|
1283
|
+
if (transformed.joins?.length)
|
|
1284
|
+
return transformed;
|
|
1285
|
+
const source = transformed.from?.froms?.[0];
|
|
1286
|
+
if (!source)
|
|
1287
|
+
return transformed;
|
|
1288
|
+
const tableNode = source.kind === 'AliasNode' ? source.node : source;
|
|
1289
|
+
// a table reference is `SchemableIdentifierNode` (schema + identifier) in Kysely 0.28+
|
|
1290
|
+
const tableName = tableNode?.table?.identifier?.name ?? tableNode?.table?.name;
|
|
1291
|
+
const qualifier = source.kind === 'AliasNode' ? source.alias?.name ?? source.alias?.identifier?.name : tableName;
|
|
1292
|
+
const spec = tableNode?.kind === 'TableNode' ? CANON_TABLES.get(tableName) : null;
|
|
1293
|
+
if (!spec || !qualifier)
|
|
1294
|
+
return transformed;
|
|
1295
|
+
const decimals = Object.entries(spec.columns)
|
|
1296
|
+
.filter(([, column]) => column.type === 'decimal')
|
|
1297
|
+
.map(([name]) => name);
|
|
1298
|
+
if (!decimals.length)
|
|
1299
|
+
return transformed;
|
|
1300
|
+
const isStar = (selection) => selection.selection?.kind === 'SelectAllNode' || selection.selection?.column?.kind === 'SelectAllNode';
|
|
1301
|
+
if (!transformed.selections.some(isStar))
|
|
1302
|
+
return transformed;
|
|
1303
|
+
// The cast rides NEXT TO `*` under the SAME name: the driver keeps the last value for
|
|
1304
|
+
// a duplicated column, and the SQL Server evaluates the cast, so the caller sees the
|
|
1305
|
+
// exact text. Expanding `*` into an explicit canon list would instead break every
|
|
1306
|
+
// read of a database that predates the migration (no `SiteId` column yet) — which is
|
|
1307
|
+
// exactly the migration source (task:3994).
|
|
1308
|
+
const casts = decimals.map((name) => SelectionNode.create(AliasNode.create(sql `cast(${sql.id(qualifier, name)} as ${sql.raw('nvarchar(50)')})`.toOperationNode(), IdentifierNode.create(name))));
|
|
1309
|
+
return { ...transformed, selections: [...transformed.selections, ...casts] };
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
class PagingMssqlDialect extends MssqlDialect {
|
|
1313
|
+
createQueryCompiler() {
|
|
1314
|
+
return new MssqlFetchCompiler();
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/** The table name a `TableNode`/aliased table points at — nullable. */
|
|
1318
|
+
const tableNameOf = (node) => {
|
|
1319
|
+
const table = node?.kind === 'AliasNode' ? node.node : node;
|
|
1320
|
+
return table?.table?.identifier?.name ?? table?.table?.name ?? null;
|
|
1321
|
+
};
|
|
1322
|
+
/** True when the canon declares `table.column` a decimal (money). */
|
|
1323
|
+
const isDecimal = (table, column) => {
|
|
1324
|
+
const spec = table ? CANON_TABLES.get(table) : null;
|
|
1325
|
+
return spec?.columns?.[column]?.type === 'decimal';
|
|
1326
|
+
};
|
|
1327
|
+
/**
|
|
1328
|
+
* A SQLite decimal reaches the caller as a number, like on every other cell (task:4094, 4140).
|
|
1329
|
+
*
|
|
1330
|
+
* The canon renders a decimal column as `text` on SQLite (`mapType` — money must not become a
|
|
1331
|
+
* float in the DDL), so the driver hands back `"1170"`. The other dialects coerce at their own
|
|
1332
|
+
* seam (pg's NUMERIC parser, mysql2's `decimalNumbers`); SQLite had no seam, so a price read on
|
|
1333
|
+
* a SQLite cell dropped out of the site's `typeof price === 'number'` filter and the product card
|
|
1334
|
+
* lost its per-piece price (measured 2026-10-28: 166 routes of the SQLite stand).
|
|
1335
|
+
*
|
|
1336
|
+
* Same decision as task:4094, taken on the selection: `node:sqlite` reports no declared column
|
|
1337
|
+
* type, and the result hook of a Kysely plugin gets no query shape, so the cast rides the SQL —
|
|
1338
|
+
* the shape MssqlDecimalTexts already uses for the same disagreement (there: to text). Only the
|
|
1339
|
+
* reads the cell itself compiles are rewritten: a column list whose columns are canon-decimal.
|
|
1340
|
+
*/
|
|
1341
|
+
class SqliteDecimalNumbers {
|
|
1342
|
+
transformQuery({ node }) {
|
|
1343
|
+
if (node?.kind !== 'SelectQueryNode' || !node.selections?.length)
|
|
1344
|
+
return node;
|
|
1345
|
+
let rewritten = false;
|
|
1346
|
+
const selections = node.selections.map((selection) => {
|
|
1347
|
+
const reference = selection?.selection;
|
|
1348
|
+
if (reference?.kind !== 'ReferenceNode')
|
|
1349
|
+
return selection;
|
|
1350
|
+
const table = tableNameOf(reference.table);
|
|
1351
|
+
const column = reference.column?.column?.name;
|
|
1352
|
+
if (!isDecimal(table, column))
|
|
1353
|
+
return selection;
|
|
1354
|
+
rewritten = true;
|
|
1355
|
+
return SelectionNode.create(AliasNode.create(sql `cast(${sql.id(table, column)} as real)`.toOperationNode(), IdentifierNode.create(column)));
|
|
1356
|
+
});
|
|
1357
|
+
return rewritten ? { ...node, selections } : node;
|
|
1358
|
+
}
|
|
1359
|
+
transformResult({ result }) {
|
|
1360
|
+
return result;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* The dialect a DSN names — the one place the product reads a scheme.
|
|
1365
|
+
* Postgres/MariaDB keep their URI, MSSQL accepts the Prisma-era `sqlserver://` spelling
|
|
1366
|
+
* and the driver's own ADO form, SQLite the `file:` convention (`:memory:` included).
|
|
1367
|
+
*/
|
|
1368
|
+
export const dialectOf = (dsn) => {
|
|
1369
|
+
const text = String(dsn ?? '');
|
|
1370
|
+
if (text.startsWith(':memory:'))
|
|
1371
|
+
return 'sqlite';
|
|
1372
|
+
const scheme = text.split(':')[0]?.toLowerCase() ?? '';
|
|
1373
|
+
switch (scheme) {
|
|
1374
|
+
case 'postgres':
|
|
1375
|
+
case 'postgresql':
|
|
1376
|
+
return 'postgres';
|
|
1377
|
+
case 'mysql':
|
|
1378
|
+
case 'mariadb':
|
|
1379
|
+
return 'mariadb';
|
|
1380
|
+
case 'sqlserver':
|
|
1381
|
+
case 'mssql':
|
|
1382
|
+
return 'mssql';
|
|
1383
|
+
case 'sqlite':
|
|
1384
|
+
case 'file':
|
|
1385
|
+
return 'sqlite';
|
|
1386
|
+
default:
|
|
1387
|
+
throw new Error(`db-provider: unsupported dialect in DSN "${scheme}:" (expected postgres://, mysql://, sqlserver:// or file:)`);
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
/**
|
|
1391
|
+
* `sqlserver://host:1433;database=db;user=u;password=p` (the Prisma-era URL a deployment
|
|
1392
|
+
* still carries) → the driver's ADO spelling. Only the first segment differs: host and port
|
|
1393
|
+
* live in the URL authority.
|
|
1394
|
+
*/
|
|
1395
|
+
const mssqlUrlToAdo = (dsn) => {
|
|
1396
|
+
const [authority, ...settings] = String(dsn).replace(/^(sqlserver|mssql):\/\//i, '').split(';');
|
|
1397
|
+
// The Prisma-era URL carries credentials in the authority (`user:password@host:port`);
|
|
1398
|
+
// what follows the last `@` is the address, and the password may itself hold colons.
|
|
1399
|
+
const at = authority.lastIndexOf('@');
|
|
1400
|
+
const credentials = at === -1 ? '' : authority.slice(0, at);
|
|
1401
|
+
const [host, port] = (at === -1 ? authority : authority.slice(at + 1)).split(':');
|
|
1402
|
+
const colon = credentials.indexOf(':');
|
|
1403
|
+
const user = colon === -1 ? credentials : credentials.slice(0, colon);
|
|
1404
|
+
const password = colon === -1 ? '' : credentials.slice(colon + 1);
|
|
1405
|
+
return [
|
|
1406
|
+
`Server=${host}`,
|
|
1407
|
+
port ? `Port=${port}` : '',
|
|
1408
|
+
user ? `User Id=${decodeURIComponent(user)}` : '',
|
|
1409
|
+
user ? `Password=${decodeURIComponent(password)}` : '',
|
|
1410
|
+
...settings,
|
|
1411
|
+
]
|
|
1412
|
+
.filter(Boolean)
|
|
1413
|
+
.join(';');
|
|
1414
|
+
};
|
|
1415
|
+
/**
|
|
1416
|
+
* The string the driver of `dialect` expects, from any spelling the deployment may hand
|
|
1417
|
+
* over. Applications pass the DSN through and never reshape it (requirements:67 R1.3).
|
|
1418
|
+
*/
|
|
1419
|
+
export const normalizeDsn = (dialect, dsn) => {
|
|
1420
|
+
switch (dialect) {
|
|
1421
|
+
case 'mssql':
|
|
1422
|
+
return /^(sqlserver|mssql):\/\//i.test(String(dsn)) ? mssqlUrlToAdo(dsn) : dsn;
|
|
1423
|
+
case 'sqlite':
|
|
1424
|
+
// `file:`/`sqlite:` are our DSN conventions; node:sqlite wants the path itself.
|
|
1425
|
+
return String(dsn).replace(/^(file|sqlite):(\/\/)?/i, '');
|
|
1426
|
+
default:
|
|
1427
|
+
return dsn;
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* Load an optional dialect driver.
|
|
1432
|
+
*
|
|
1433
|
+
* The drivers live in `optionalDependencies`: a cell installs the one dialect it uses, and the
|
|
1434
|
+
* other three are dead weight (~8 MB plus the @azure tree behind `tedious`). An optional
|
|
1435
|
+
* dependency may be absent, so the absence has to be tellable apart from a broken install:
|
|
1436
|
+
* ERR_MODULE_NOT_FOUND from a lazy import deep inside the provider is not (task:4042).
|
|
1437
|
+
*/
|
|
1438
|
+
const RESOLUTION_CODES = new Set(['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND']);
|
|
1439
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1440
|
+
// LIKE pattern for a prefix filter: the escape CHARACTER is `!`, escaping itself and the two
|
|
1441
|
+
// wildcards. A backslash is the obvious choice and the wrong one: MariaDB reads `escape '\'`
|
|
1442
|
+
// as an unterminated literal (ER_PARSE_ERROR 1064) — `!` is unambiguous on every dialect.
|
|
1443
|
+
const LIKE_ESCAPE = '!';
|
|
1444
|
+
const likePrefix = (text) => String(text).toLowerCase().replace(/[!%_]/g, (ch) => `${LIKE_ESCAPE}${ch}`);
|
|
1445
|
+
// "the driver itself is absent" — not "the driver is installed but something inside it is
|
|
1446
|
+
// broken". Both arrive as a resolution error, and only the first one is ours to explain.
|
|
1447
|
+
const namesMissingTopLevel = (error, specifier) => {
|
|
1448
|
+
if (!RESOLUTION_CODES.has(error?.code))
|
|
1449
|
+
return false;
|
|
1450
|
+
const message = String(error?.message ?? '');
|
|
1451
|
+
return new RegExp(`Cannot find (?:package|module) ['"]${escapeRegExp(specifier)}(?:[/'"])`).test(message);
|
|
1452
|
+
};
|
|
1453
|
+
export const loadDriver = async (specifier, dialect) => {
|
|
1454
|
+
try {
|
|
1455
|
+
return await import(__rewriteRelativeImportExtension(specifier));
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
if (!namesMissingTopLevel(error, specifier))
|
|
1459
|
+
throw error;
|
|
1460
|
+
const missing = new Error(`db-provider: the ${dialect} driver is not installed � '${specifier}' is an optional dependency. ` +
|
|
1461
|
+
`Install it in the package that declares this deployment profile (yarn add ${specifier}), ` +
|
|
1462
|
+
'or install without omitting optional dependencies.', { cause: error });
|
|
1463
|
+
missing.code = 'ERR_DIALECT_DRIVER_MISSING';
|
|
1464
|
+
throw missing;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
/**
|
|
1468
|
+
* A UUID reaches the caller in the canon's one form — lowercase (task:4000, 4129).
|
|
1469
|
+
*
|
|
1470
|
+
* `tedious` hands a `uniqueidentifier` back UPPERCASE while every canon identifier (generated
|
|
1471
|
+
* types, `PAGE_TEMPLATE_IDS`, sitemap keys) is lowercase, so a consumer that compares ids takes
|
|
1472
|
+
* its fallback branch: the site lost the CMS title/description on 13 routes of the MSSQL stand
|
|
1473
|
+
* while MariaDB/Postgres/SQLite rendered them (measured 2026-10-29). The other two dialects hand
|
|
1474
|
+
* back lowercase by themselves, so this lives on the MSSQL seam only.
|
|
1475
|
+
*
|
|
1476
|
+
* The result hook of a Kysely plugin carries no query shape here, so the test is the value's
|
|
1477
|
+
* own shape: a driver UUID matches exactly, and text that merely contains one is left alone.
|
|
1478
|
+
*/
|
|
1479
|
+
const UUID_TEXT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1480
|
+
const lowerUuidValues = (rows) => {
|
|
1481
|
+
for (const row of rows ?? []) {
|
|
1482
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1483
|
+
if (typeof value === 'string' && value !== value.toLowerCase() && UUID_TEXT.test(value)) {
|
|
1484
|
+
row[key] = value.toLowerCase();
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
return rows;
|
|
1489
|
+
};
|
|
1490
|
+
const mssqlDialect = async (dsn) => {
|
|
1491
|
+
const [tarnModule, tediousModule] = await Promise.all([loadDriver('tarn', 'mssql'), loadDriver('tedious', 'mssql')]);
|
|
1492
|
+
const Tarn = tarnModule.default ?? tarnModule;
|
|
1493
|
+
const Tedious = tediousModule.default ?? tediousModule;
|
|
1494
|
+
const config = parseMssqlDsn(dsn);
|
|
1495
|
+
return new PagingMssqlDialect({
|
|
1496
|
+
tarn: { ...Tarn, options: { min: 0, max: 5 } },
|
|
1497
|
+
tedious: { ...Tedious, connectionFactory: () => new Tedious.Connection(config) },
|
|
1498
|
+
});
|
|
1499
|
+
};
|
|
1500
|
+
/**
|
|
1501
|
+
* A decimal column is money, and money has one JS type on every cell (task:4094).
|
|
1502
|
+
*
|
|
1503
|
+
* The drivers disagree by default: `tedious` (MSSQL) and `node:sqlite` return a number,
|
|
1504
|
+
* `pg` and `mysql2` return the DECIMAL as text. The production engine ran on MSSQL, so every
|
|
1505
|
+
* consumer in the ecosystem (`typeof price === 'number'` in the site's cards, sums in the
|
|
1506
|
+
* loader) was written against numbers — on a MariaDB/Postgres cell those values fall out of
|
|
1507
|
+
* the filter and the price silently disappears from the page.
|
|
1508
|
+
*
|
|
1509
|
+
* Only NUMERIC/DECIMAL (oid 1700) is coerced. BIGINT (20) stays text on purpose: a 64-bit
|
|
1510
|
+
* integer does not survive `Number`, and no canon column of that shape is a price.
|
|
1511
|
+
*
|
|
1512
|
+
* The result is a JS double, the same representation the MSSQL source handed out — at
|
|
1513
|
+
* `decimal(18,2)` it holds every value the column can store for display and for the
|
|
1514
|
+
* arithmetic the site does (a price times a volume).
|
|
1515
|
+
*/
|
|
1516
|
+
const NUMERIC_OID = 1700;
|
|
1517
|
+
const numericTypes = (pg) => ({
|
|
1518
|
+
getTypeParser: (oid, format) => oid === NUMERIC_OID
|
|
1519
|
+
? (value) => (value === null ? null : Number(value))
|
|
1520
|
+
: pg.types.getTypeParser(oid, format),
|
|
1521
|
+
});
|
|
1522
|
+
/**
|
|
1523
|
+
* The parts of a mysql DSN as a connection config.
|
|
1524
|
+
*
|
|
1525
|
+
* `mysql2` reads a URL only when the whole config *is* that string, which leaves no room for
|
|
1526
|
+
* an option next to it (`decimalNumbers`). The query string of the DSN is forwarded as
|
|
1527
|
+
* options, so an unusual `?charset=`/`?timezone=` keeps working.
|
|
1528
|
+
*/
|
|
1529
|
+
const mysqlDsnParts = (dsn) => {
|
|
1530
|
+
const url = new URL(dsn);
|
|
1531
|
+
if (!/^(mysql|mariadb):$/.test(url.protocol)) {
|
|
1532
|
+
throw new Error(`db-provider: a mariadb DSN must be mysql:// or mariadb://, got ${url.protocol}//`);
|
|
1533
|
+
}
|
|
1534
|
+
const parts = {
|
|
1535
|
+
host: url.hostname,
|
|
1536
|
+
port: url.port ? Number(url.port) : 3306,
|
|
1537
|
+
user: decodeURIComponent(url.username),
|
|
1538
|
+
password: decodeURIComponent(url.password),
|
|
1539
|
+
};
|
|
1540
|
+
const database = url.pathname.replace(/^\//, '');
|
|
1541
|
+
if (database)
|
|
1542
|
+
parts.database = decodeURIComponent(database);
|
|
1543
|
+
for (const [key, value] of url.searchParams)
|
|
1544
|
+
parts[key] = value;
|
|
1545
|
+
return parts;
|
|
1546
|
+
};
|
|
1547
|
+
const buildDialect = async (name, dsn) => {
|
|
1548
|
+
switch (name) {
|
|
1549
|
+
case 'postgres': {
|
|
1550
|
+
const pg = await loadDriver('pg', 'postgres');
|
|
1551
|
+
return new PostgresDialect({ pool: new pg.Pool({ connectionString: dsn, types: numericTypes(pg) }) });
|
|
1552
|
+
}
|
|
1553
|
+
case 'mariadb': {
|
|
1554
|
+
// Kysely's MySQL driver speaks the callback API of mysql2, not the promise one.
|
|
1555
|
+
// `decimalNumbers` is the same decision as Postgres' NUMERIC parser above: a decimal
|
|
1556
|
+
// column is money (task:4094), and `mysql2` hands it over as text unless asked.
|
|
1557
|
+
const mysql = (await loadDriver('mysql2', 'mariadb')).default;
|
|
1558
|
+
return new MysqlDialect({ pool: mysql.createPool({ ...mysqlDsnParts(dsn), decimalNumbers: true }) });
|
|
1559
|
+
}
|
|
1560
|
+
// sqlite has no dialect-only path: `openSqlite` opens the file eagerly, so the
|
|
1561
|
+
// handle it locks must be owned by something that can close it on destroy —
|
|
1562
|
+
// `openDb` routes sqlite to `sqliteKysely` before reaching here.
|
|
1563
|
+
case 'sqlite':
|
|
1564
|
+
throw new Error('db-provider: sqlite must go through openDb (the handle needs an owner that closes it)');
|
|
1565
|
+
case 'mssql':
|
|
1566
|
+
return mssqlDialect(dsn);
|
|
1567
|
+
default:
|
|
1568
|
+
throw new Error(`db-provider: unsupported dialect "${name}" (${DIALECTS.join(', ')})`);
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
/**
|
|
1572
|
+
* A raw Kysely handle from a DSN — the tooling seam (site transfer, task:3826).
|
|
1573
|
+
*
|
|
1574
|
+
* The provider below owns a tenant (`siteId` is mandatory: deny-by-default); the
|
|
1575
|
+
* transfer core takes an open handle and applies the guard itself, so opening a
|
|
1576
|
+
* dialect must be reachable without one. Same dialect list, same DSN syntax — the
|
|
1577
|
+
* cell config stays one contract whether the caller is a cell or a migration tool.
|
|
1578
|
+
*/
|
|
1579
|
+
export const openDb = async ({ dialect, dsn } = {}) => {
|
|
1580
|
+
if (!dialect || !dsn)
|
|
1581
|
+
throw new Error('db-provider: openDb requires a dialect and a dsn');
|
|
1582
|
+
const normalized = normalizeDsn(dialect, dsn);
|
|
1583
|
+
// Loaded on demand: `node:sqlite` is a builtin the bundlers' externalizer lists do not
|
|
1584
|
+
// know, and a Postgres/MSSQL cell must not depend on it being resolvable.
|
|
1585
|
+
if (dialect === 'sqlite') {
|
|
1586
|
+
const { sqliteKysely } = await import('./sqliteDialect.js');
|
|
1587
|
+
// decimals are text in this dialect's DDL and numbers for every caller (task:4140)
|
|
1588
|
+
return sqliteKysely(normalized, [new SqliteDecimalNumbers()]);
|
|
1589
|
+
}
|
|
1590
|
+
const db = new Kysely({ dialect: await buildDialect(dialect, normalized) });
|
|
1591
|
+
// decimals are text in the canon and floats in the driver: the seam restores the canon
|
|
1592
|
+
return dialect === 'mssql'
|
|
1593
|
+
? db.withPlugin({
|
|
1594
|
+
transformQuery: ({ node }) => new MssqlDecimalTexts().transformNode(node),
|
|
1595
|
+
// canon forms on the way out: a decimal is text here, a uuid is lowercase
|
|
1596
|
+
transformResult: ({ result }) => {
|
|
1597
|
+
lowerUuidValues(result.rows);
|
|
1598
|
+
return result;
|
|
1599
|
+
},
|
|
1600
|
+
})
|
|
1601
|
+
: db;
|
|
1602
|
+
};
|
|
1603
|
+
export class DbDataProvider {
|
|
1604
|
+
#db;
|
|
1605
|
+
#raw;
|
|
1606
|
+
#siteId;
|
|
1607
|
+
#slices;
|
|
1608
|
+
#ownsDb;
|
|
1609
|
+
#channel;
|
|
1610
|
+
/**
|
|
1611
|
+
* @param {object} options
|
|
1612
|
+
* @param {import('kysely').Kysely<any>} options.db an open Kysely instance (the cell's runtime)
|
|
1613
|
+
* @param {string} [options.siteId] the tenant every read is scoped to (site channel only)
|
|
1614
|
+
* @param {'site'|'control'} [options.channel] `site` (default) or the control-plane channel
|
|
1615
|
+
* that reads dictionaries and the site registry — and may not touch site-scoped tables
|
|
1616
|
+
* @param {object} [options.slices] slice declarations (tests may add their own)
|
|
1617
|
+
* @param {boolean} [options.ownsDb] true when the provider built the connection itself
|
|
1618
|
+
*/
|
|
1619
|
+
constructor({ db, siteId, channel = 'site', slices = SLICES, ownsDb = false } = {}) {
|
|
1620
|
+
if (!db)
|
|
1621
|
+
throw new Error('db-provider: an open Kysely instance ("db") is required');
|
|
1622
|
+
if (channel !== 'site' && channel !== 'control') {
|
|
1623
|
+
throw new Error(`db-provider: unknown channel "${channel}" (site, control)`);
|
|
1624
|
+
}
|
|
1625
|
+
if (channel === 'site' && !siteId) {
|
|
1626
|
+
throw new Error('db-provider: "siteId" is required — without it read queries are deny-by-default');
|
|
1627
|
+
}
|
|
1628
|
+
this.#raw = db;
|
|
1629
|
+
this.#channel = channel;
|
|
1630
|
+
// the provider is the writer path: it grants writes explicitly (task:3865 — a
|
|
1631
|
+
// site handle is read-only without the grant), while a report/export reader
|
|
1632
|
+
// builds its own handle and leaves the grant out
|
|
1633
|
+
this.#db = channel === 'site'
|
|
1634
|
+
? db.withPlugin(tenantGuard({ kind: 'site', siteId, frozen: false, writable: true }))
|
|
1635
|
+
: db.withPlugin(tenantGuard({ kind: 'control' }));
|
|
1636
|
+
this.#siteId = siteId;
|
|
1637
|
+
this.#slices = slices;
|
|
1638
|
+
this.#ownsDb = ownsDb;
|
|
1639
|
+
}
|
|
1640
|
+
/** Build a provider from a cell config; connections stay lazy until the first query. */
|
|
1641
|
+
static async fromCell(cell, { slices } = {}) {
|
|
1642
|
+
const required = cell?.channel === 'control' ? ['dialect', 'dsn'] : CELL_REQUIRED_KEYS;
|
|
1643
|
+
const missing = required.filter((key) => !cell?.[key]);
|
|
1644
|
+
if (missing.length)
|
|
1645
|
+
throw new Error(`db-provider: cell config is missing ${missing.join(', ')}`);
|
|
1646
|
+
// MSSQL is a cell target since task:3994: the canon renders its DDL like any other
|
|
1647
|
+
// dialect, so a cell on it reads a schema the canon describes. `openDb` already
|
|
1648
|
+
// serves the dialect (it used to be reached only through the migration tool).
|
|
1649
|
+
return new DbDataProvider({
|
|
1650
|
+
db: await openDb({ dialect: cell.dialect, dsn: cell.dsn }),
|
|
1651
|
+
siteId: cell.siteId,
|
|
1652
|
+
channel: cell.channel,
|
|
1653
|
+
slices,
|
|
1654
|
+
ownsDb: true,
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
get siteId() {
|
|
1658
|
+
return this.#siteId;
|
|
1659
|
+
}
|
|
1660
|
+
get channel() {
|
|
1661
|
+
return this.#channel;
|
|
1662
|
+
}
|
|
1663
|
+
#spec(slice) {
|
|
1664
|
+
const spec = this.#slices[slice];
|
|
1665
|
+
if (!spec) {
|
|
1666
|
+
throw new Error(`db-provider: unknown slice "${slice}" (known: ${Object.keys(this.#slices).join(', ')})`);
|
|
1667
|
+
}
|
|
1668
|
+
return spec;
|
|
1669
|
+
}
|
|
1670
|
+
#scope(spec, query) {
|
|
1671
|
+
const canon = CANON_TABLES.get(spec.table);
|
|
1672
|
+
if (this.#channel === 'control') {
|
|
1673
|
+
// the control channel reads what has no tenant: dictionaries and the registry.
|
|
1674
|
+
// A site-scoped table is refused here rather than silently unscoped (the guard
|
|
1675
|
+
// would refuse it too — this just says which table and why).
|
|
1676
|
+
if (canon && isSiteScoped(canon)) {
|
|
1677
|
+
throw new Error(`db-provider: slice "${spec.table}" is site-scoped — a control provider cannot read it (build a site provider)`);
|
|
1678
|
+
}
|
|
1679
|
+
return query;
|
|
1680
|
+
}
|
|
1681
|
+
// The R1 guard scopes every SITE-scoped table on its own (it injects the
|
|
1682
|
+
// predicate at compile time). A control-plane table (the Sites registry) is
|
|
1683
|
+
// deliberately out of the guard's reach — so a site handle must pin its own
|
|
1684
|
+
// row here, or the registry would be read unscoped. Deny-by-default: a control
|
|
1685
|
+
// table with no SiteId column cannot be served by a site handle at all.
|
|
1686
|
+
if (canon && !isSiteScoped(canon)) {
|
|
1687
|
+
if (!canon.columns.SiteId) {
|
|
1688
|
+
throw new Error(`db-provider: slice "${spec.table}" is control-plane without a SiteId column — a site handle cannot be scoped to it (build a control provider)`);
|
|
1689
|
+
}
|
|
1690
|
+
return query.where(`${spec.table}.SiteId`, '=', this.#siteId);
|
|
1691
|
+
}
|
|
1692
|
+
return query;
|
|
1693
|
+
}
|
|
1694
|
+
/** A qualified canon column — `Column` of the slice's own table, or `Table.Column` when the
|
|
1695
|
+
* slice declares that join. A typo is refused, not compiled. */
|
|
1696
|
+
#column(spec, column) {
|
|
1697
|
+
const dot = column.indexOf('.');
|
|
1698
|
+
const table = dot > 0 ? column.slice(0, dot) : spec.table;
|
|
1699
|
+
const name = dot > 0 ? column.slice(dot + 1) : column;
|
|
1700
|
+
if (table !== spec.table && table !== spec.join?.table) {
|
|
1701
|
+
throw new Error(`db-provider: filter on "${column}" — the slice "${spec.table}" declares no join to "${table}"`);
|
|
1702
|
+
}
|
|
1703
|
+
const canon = CANON_TABLES.get(table);
|
|
1704
|
+
if (canon && !canon.columns[name]) {
|
|
1705
|
+
throw new Error(`db-provider: slice "${spec.table}" has no column "${column}" — options must name canon columns`);
|
|
1706
|
+
}
|
|
1707
|
+
return `${table}.${name}`;
|
|
1708
|
+
}
|
|
1709
|
+
/** Equality filters (`null` → IS NULL, an array → IN) plus three case-folded string
|
|
1710
|
+
* operators (`{ startsWith }`, `{ equals }`, `{ notLike }`). Join columns are out of reach
|
|
1711
|
+
* by design: a filter that needs the joined row is a slice that needs its own declaration. */
|
|
1712
|
+
#filter(spec, query, where) {
|
|
1713
|
+
if (!where)
|
|
1714
|
+
return query;
|
|
1715
|
+
let filtered = query;
|
|
1716
|
+
for (const [column, value] of Object.entries(where)) {
|
|
1717
|
+
const ref = this.#column(spec, column);
|
|
1718
|
+
if (Array.isArray(value)) {
|
|
1719
|
+
// an empty IN is a valid question with the answer "nothing" — spelled out, since
|
|
1720
|
+
// not every dialect accepts `in ()`
|
|
1721
|
+
filtered = value.length === 0
|
|
1722
|
+
? filtered.where(sql `1`, '=', 0)
|
|
1723
|
+
: filtered.where(ref, 'in', value);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (value !== null && typeof value === 'object') {
|
|
1727
|
+
// one filter may carry several operators (a path is a prefix AND not a grandchild)
|
|
1728
|
+
for (const [operator, operand] of Object.entries(value)) {
|
|
1729
|
+
// A prefix filter is a LIKE, and LIKE reserves two characters: a path holding
|
|
1730
|
+
// `_` would otherwise match any character, so the pattern is escaped. The compare
|
|
1731
|
+
// folds case because legacy relied on the MSSQL collation being case-insensitive
|
|
1732
|
+
// and the callers pass lowercased prefixes; postgres LIKE would not.
|
|
1733
|
+
// The escape CHARACTER is `!`, never a backslash: MariaDB parses `escape '\'`
|
|
1734
|
+
// as an unterminated literal and answers ER_PARSE_ERROR 1064 (`content-api`'s
|
|
1735
|
+
// page listing died on a MariaDB cell, measured 2026-10-26).
|
|
1736
|
+
// shortcut: a folded compare skips the plain Path index, add a functional index
|
|
1737
|
+
// if a prefix scan ever shows up in a slow query.
|
|
1738
|
+
if (operator === 'startsWith') {
|
|
1739
|
+
const prefix = likePrefix(operand);
|
|
1740
|
+
filtered = filtered.where(sql `lower(${sql.ref(ref)}) like ${`${prefix}%`} escape '!'`);
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
// `notLike` takes the legacy SQL pattern as-is (the grandchild test composes
|
|
1744
|
+
// `%/%` wildcards on purpose): the value is bound, never concatenated, so the
|
|
1745
|
+
// only thing the caller decides is the pattern.
|
|
1746
|
+
if (operator === 'notLike') {
|
|
1747
|
+
filtered = filtered.where(sql `lower(${sql.ref(ref)}) not like ${String(operand)} escape '!'`);
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
// Folded equality: the same case-insensitivity an exact lookup needs, for
|
|
1751
|
+
// columns whose slice has no `keyFold` (a non-key column compared by path).
|
|
1752
|
+
if (operator === 'equals') {
|
|
1753
|
+
filtered = filtered.where(sql `lower(${sql.ref(ref)})`, '=', String(operand).toLowerCase());
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
throw new Error(`db-provider: filter on "${column}" names no supported operator ("${operator}"; startsWith, notLike, equals)`);
|
|
1757
|
+
}
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
filtered = value === null ? filtered.where(ref, 'is', null) : filtered.where(ref, '=', value);
|
|
1761
|
+
}
|
|
1762
|
+
return filtered;
|
|
1763
|
+
}
|
|
1764
|
+
/** Deterministic ordering: the requested columns, then the key as the tiebreaker. */
|
|
1765
|
+
#ordered(spec, query, order) {
|
|
1766
|
+
const entries = order ? [...order] : [];
|
|
1767
|
+
const named = new Set(entries.map((entry) => (Array.isArray(entry) ? entry[0] : entry)));
|
|
1768
|
+
if (!named.has(spec.key))
|
|
1769
|
+
entries.push(spec.key);
|
|
1770
|
+
let ordered = query;
|
|
1771
|
+
for (const entry of entries) {
|
|
1772
|
+
const [column, direction] = Array.isArray(entry) ? entry : [entry, 'asc'];
|
|
1773
|
+
ordered = ordered.orderBy(this.#column(spec, column), direction === 'desc' ? 'desc' : 'asc');
|
|
1774
|
+
}
|
|
1775
|
+
return ordered;
|
|
1776
|
+
}
|
|
1777
|
+
/** The slice's FROM, its optional single join, and the tenant scope — the shape every read
|
|
1778
|
+
* and the count share, so a filter on a joined column narrows the same rows in both. */
|
|
1779
|
+
#from(spec) {
|
|
1780
|
+
let query = this.#db.selectFrom(spec.table);
|
|
1781
|
+
if (spec.join) {
|
|
1782
|
+
const [local, foreign] = spec.join.on;
|
|
1783
|
+
query = query.leftJoin(spec.join.table, `${spec.join.table}.${foreign}`, `${spec.table}.${local}`);
|
|
1784
|
+
}
|
|
1785
|
+
return this.#scope(spec, query);
|
|
1786
|
+
}
|
|
1787
|
+
#select(spec) {
|
|
1788
|
+
let query = this.#from(spec).select(spec.columns.map((column) => `${spec.table}.${column}`));
|
|
1789
|
+
if (spec.join) {
|
|
1790
|
+
query = query.select(spec.join.columns.map((column) => `${spec.join.table}.${column} as tpl_${column}`));
|
|
1791
|
+
}
|
|
1792
|
+
return query;
|
|
1793
|
+
}
|
|
1794
|
+
/** The key expression for a lookup: the raw column, or a folded one (`keyFold`). */
|
|
1795
|
+
#keyExpression(spec) {
|
|
1796
|
+
return spec.keyFold === 'lower'
|
|
1797
|
+
? sql `lower(${sql.ref(`${spec.table}.${spec.key}`)})`
|
|
1798
|
+
: sql.ref(`${spec.table}.${spec.key}`);
|
|
1799
|
+
}
|
|
1800
|
+
/** One entity by key (path/slug/id) — the seam contract of dataProvider.js. */
|
|
1801
|
+
async get(slice, key) {
|
|
1802
|
+
const spec = this.#spec(slice);
|
|
1803
|
+
const value = spec.keyFold === 'lower' ? String(key).toLowerCase() : key;
|
|
1804
|
+
const row = await this.#select(spec).where(this.#keyExpression(spec), '=', value).executeTakeFirst();
|
|
1805
|
+
return row ? spec.shape(row) : null;
|
|
1806
|
+
}
|
|
1807
|
+
/** All entities of a slice, ordered by the slice key so listing is deterministic. */
|
|
1808
|
+
async enumerate(slice, opts = {}) {
|
|
1809
|
+
const spec = this.#spec(slice);
|
|
1810
|
+
let query = this.#ordered(spec, this.#filter(spec, this.#select(spec), opts.where), opts.order);
|
|
1811
|
+
if (opts.offset)
|
|
1812
|
+
query = query.offset(opts.offset);
|
|
1813
|
+
if (opts.limit)
|
|
1814
|
+
query = query.limit(opts.limit);
|
|
1815
|
+
const rows = await query.execute();
|
|
1816
|
+
return rows.map((row) => spec.shape(row));
|
|
1817
|
+
}
|
|
1818
|
+
/** The count behind a listing — same tenant scope and filters as `enumerate`, no page window. */
|
|
1819
|
+
async count(slice, opts = {}) {
|
|
1820
|
+
const spec = this.#spec(slice);
|
|
1821
|
+
const filtered = this.#filter(spec, this.#from(spec), opts.where);
|
|
1822
|
+
const row = await filtered.select(sql `count(*)`.as('count')).executeTakeFirstOrThrow();
|
|
1823
|
+
return Number(row.count);
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* The write half of the seam (task:3933): a slice may be written, not only read.
|
|
1827
|
+
*
|
|
1828
|
+
* `values` names CANON columns, exactly like `where` does, and only columns the slice
|
|
1829
|
+
* declares: the declaration is the contract on both sides, so a write cannot reach a
|
|
1830
|
+
* column the reads never see. The tenant key is NOT the caller's to set — the guard
|
|
1831
|
+
* injects `SiteId` from the provider it belongs to and refuses a payload that carries one.
|
|
1832
|
+
*/
|
|
1833
|
+
async insert(slice, values = {}) {
|
|
1834
|
+
const spec = this.#spec(slice);
|
|
1835
|
+
const row = { ...values };
|
|
1836
|
+
// the legacy models generated the key client-side; the seam keeps that, so an insert
|
|
1837
|
+
// never depends on a dialect's RETURNING support
|
|
1838
|
+
if (row[spec.key] === undefined)
|
|
1839
|
+
row[spec.key] = randomUUID();
|
|
1840
|
+
for (const column of Object.keys(row))
|
|
1841
|
+
this.#writable(spec, column);
|
|
1842
|
+
await this.#db.insertInto(spec.table).values(row).execute();
|
|
1843
|
+
return this.get(slice, row[spec.key]);
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Change one row by key, within the tenant: the guard adds the site predicate to the UPDATE.
|
|
1847
|
+
*
|
|
1848
|
+
* `opts.where` makes the write conditional (a compare-and-set): the row is updated only while
|
|
1849
|
+
* the named columns still hold the named values. The read-back cannot tell a conditional miss
|
|
1850
|
+
* from a match, so a CAS caller compares the field it stamped — the search drainer does
|
|
1851
|
+
* exactly that (`SearchIndexCreationTimestamp` still null means a save re-dirtied the site).
|
|
1852
|
+
*/
|
|
1853
|
+
async update(slice, key, values = {}, opts = {}) {
|
|
1854
|
+
const spec = this.#spec(slice);
|
|
1855
|
+
const row = { ...values };
|
|
1856
|
+
delete row[spec.key];
|
|
1857
|
+
for (const column of Object.keys(row))
|
|
1858
|
+
this.#writable(spec, column);
|
|
1859
|
+
const query = this.#db
|
|
1860
|
+
.updateTable(spec.table)
|
|
1861
|
+
.set(row)
|
|
1862
|
+
.where(this.#keyExpression(spec), '=', spec.keyFold === 'lower' ? String(key).toLowerCase() : key);
|
|
1863
|
+
await this.#filter(spec, query, opts.where).execute();
|
|
1864
|
+
// No row-count check: the drivers disagree on how (and whether) they report one, and the
|
|
1865
|
+
// guard's predicate already makes another tenant's key a no-op. Reading the row back says
|
|
1866
|
+
// the truth in the one vocabulary the seam has — null when this tenant does not own it.
|
|
1867
|
+
return this.get(slice, key);
|
|
1868
|
+
}
|
|
1869
|
+
/** A column a write may set: a bare canon column the slice selects (no join, no key games). */
|
|
1870
|
+
#writable(spec, column) {
|
|
1871
|
+
if (column === 'SiteId') {
|
|
1872
|
+
throw new Error('db-provider: SiteId is the access layer\'s, not the payload\'s');
|
|
1873
|
+
}
|
|
1874
|
+
if (!spec.columns.includes(column)) {
|
|
1875
|
+
throw new Error(`db-provider: slice "${spec.table}" does not declare "${column}" — a write may only set what the read contract carries`);
|
|
1876
|
+
}
|
|
1877
|
+
const canon = CANON_TABLES.get(spec.table);
|
|
1878
|
+
if (canon && !canon.columns[column]) {
|
|
1879
|
+
throw new Error(`db-provider: slice "${spec.table}" has no column "${column}"`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Raw connectivity probe (the MSSQL source role). Deliberately runs on the
|
|
1884
|
+
* UNGUARDED handle: a raw statement cannot be scoped by the guard, so pretending
|
|
1885
|
+
* otherwise would be worse than saying it out loud. It is an operator/diagnostic
|
|
1886
|
+
* entry point — never feed it a query parameter (canon: raw SQL is an escape hatch).
|
|
1887
|
+
*/
|
|
1888
|
+
async probe(statement) {
|
|
1889
|
+
const { rows } = await sql `${sql.raw(statement)}`.execute(this.#raw);
|
|
1890
|
+
return rows;
|
|
1891
|
+
}
|
|
1892
|
+
/** Closes the connection only when the provider owns it; an injected db is the caller's. */
|
|
1893
|
+
async close() {
|
|
1894
|
+
if (this.#ownsDb)
|
|
1895
|
+
await this.#raw.destroy();
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
export const createDbProvider = (cell, options) => DbDataProvider.fromCell(cell, options);
|