Setting the file. One moment.
Wix Wp Plugin V2 Client · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 239
function sourceReadCounts
— line 239
This file
Number 39.31
Position 31 of 92
Type JavaScript
Size 47 KB
Lines 845 lib/ wix-wp-plugin-v2-client.js
JavaScript · 845 lines · 47 KB
14 // Pulling real data through this for a specific entity is a per-entity decision, not
15 // implied by schema-discoverability -- e.g. gift-card-activity's balance/redemption
16 // ledger still needs its own masking/correlation call even though its
17 // schema is freely discoverable.
18
19 const { createHash } = require ( 'node:crypto' );
20
21 const SHARED_CORE_TABLES_REQUIRING_WHERE = new Set ([ 'posts' , 'terms' , 'term_taxonomy' , 'term_relationships' , 'comments' ]);
22 const MAX_IN_VALUES = 100 ;
23 const MAX_WHERE_CONDITIONS = 20 ;
24 const HANDLER_VERSION = '1.4.0' ; // 1.4.0: Application Password path only; the signed migration-key transport is removed.
25
26 function bodyOf ( response ) {
27 return response && response.data !== undefined ? response.data : response;
28 }
29
30 function assertColumn ( column , table ) {
31 if ( ! column || typeof column.name !== 'string' || column.name. length === 0 ) {
32 throw new Error ( `table ${ table }: column name is required` );
33 }
34 if ( typeof column.type !== 'string' || column.type. length === 0 ) {
35 throw new Error ( `table ${ table }: column ${ column . name } is missing a type` );
36 }
37 if ( typeof column.nullable !== 'boolean' ) {
38 throw new Error ( `table ${ table }: column ${ column . name } is missing nullable` );
39 }
40 if (column.key !== null && ! [ 'primary' , 'unique' , 'index' ]. includes (column.key)) {
41 throw new Error ( `table ${ table }: column ${ column . name } has an invalid key hint ${ JSON . stringify ( column . key ) }` );
42 }
43 }
44
45 const EAV_TIERS = [ 'key-discovery' , 'key-scoped' ];
46
47 /**
48 * `redactionMetadata`, required on every response and validated against the
49 * schema's actual contract: a NON-NEGATIVE INTEGER count and an array of STRINGS.
50 *
51 * The loose version accepted `-1.5` and `[7]`, which was not merely untidy. Those values flow
52 * straight into `sourceReadCounts()` and on to rp-telemetry, whose validator correctly rejects
53 * them -- so a malformed count silently discarded the whole accumulated telemetry batch, far
54 * from the response that caused it. Validate at the boundary, where the error can still name
55 * the table.
56 */
57 function assertRedactionMetadata ( redactionMetadata , table ) {
58 if ( ! redactionMetadata || typeof redactionMetadata !== 'object' || Array. isArray (redactionMetadata)) {
59 throw new Error (
60 `table ${ table }: response is missing redactionMetadata, which is required on every `
61 + 'query-structure response. The source site is running a wix-wp-plugin-v2 older than 0.4.0, which has no '
62 + 'secret-value guard -- upgrade the plugin rather than treating this as an empty redaction set.' ,
63 );
64 }
65 const { redactedValueCount , redactedColumns } = redactionMetadata;
66 if ( ! Number. isInteger (redactedValueCount) || redactedValueCount < 0 ) {
67 throw new Error ( `table ${ table }: redactionMetadata.redactedValueCount must be a non-negative integer (got ${ JSON . stringify ( redactedValueCount ) })` );
68 }
69 if ( ! Array. isArray (redactedColumns) || redactedColumns. some (( c ) => typeof c !== 'string' )) {
70 throw new Error ( `table ${ table }: redactionMetadata.redactedColumns must be an array of column names` );
71 }
72 if (redactedValueCount > 0 && redactedColumns. length === 0 ) {
73 throw new Error ( `table ${ table }: redactionMetadata reports ${ redactedValueCount } redaction(s) but names no column -- a count with no column is not actionable` );
74 }
75 return { redactedValueCount, redactedColumns: [ ... redactedColumns] };
76 }
77
78 /** A {keyColumn, valueColumn} pair, as reported by either route. */
79 function assertPairShape ( pair , table , where ) {
80 if ( ! pair || typeof pair.keyColumn !== 'string' || typeof pair.valueColumn !== 'string' ) {
81 throw new Error ( `table ${ table }: ${ where } must carry string keyColumn and valueColumn` );
82 }
83 return { keyColumn: pair.keyColumn, valueColumn: pair.valueColumn };
84 }
85
86 /**
87 * discover-structure's `eavPair`. Absent for an ordinary table; when present it names the
88 * column a query must pin. `additionalPairs` appears only for the rare schema carrying more
89 * than one recognized pair -- and it is propagated rather than dropped, because every one of
90 * those value columns is independently guarded and a caller that saw only the first would
91 * build a request the plugin refuses.
92 */
93 function assertEavPair ( eavPair , table ) {
94 if (eavPair === undefined || eavPair === null ) return null ;
95 const pair = assertPairShape (eavPair, table, 'eavPair' );
96 if (eavPair.additionalPairs !== undefined ) {
97 if ( ! Array. isArray (eavPair.additionalPairs)) throw new Error ( `table ${ table }: eavPair.additionalPairs must be an array` );
98 pair.additionalPairs = eavPair.additionalPairs. map (( p ) => assertPairShape (p, table, 'eavPair.additionalPairs[]' ));
99 }
100 return pair;
101 }
102
103 /**
104 * query-structure's `eavAccess`. Absent for a table with no key/value pair; present with a
105 * tier and the key names the request pinned otherwise. Validated rather than passed through
106 * so a caller can branch on `tier` without re-checking the shape.
107 */
108 function assertAccessPair ( pair , table , where ) {
109 const shape = assertPairShape (pair, table, where);
110 if ( ! EAV_TIERS . includes (pair.tier)) {
111 throw new Error ( `table ${ table }: ${ where }.tier ${ JSON . stringify ( pair . tier ) } is not one of ${ EAV_TIERS . join ( ', ' ) }` );
112 }
113 if ( ! Array. isArray (pair.pinnedKeys) || pair.pinnedKeys. some (( k ) => typeof k !== 'string' )) {
114 throw new Error ( `table ${ table }: ${ where }.pinnedKeys must be an array of key names` );
115 }
116 return { ... shape, tier: pair.tier, pinnedKeys: [ ... pair.pinnedKeys] };
117 }
118
119 /**
120 * query-structure's `eavAccess`. Absent for a table with no key/value pair.
121 *
122 * EVERY pair goes through the SAME validation, primary and additional alike. An earlier
123 * version validated the primary strictly and then copied an additional pair's `tier`
124 * unchecked while coercing a malformed `pinnedKeys` to `[]` — so a response claiming
125 * `tier: "full"` on a second pair was accepted, and a caller branching on tier could conclude
126 * a guarded column was freely readable. Validating one of N is not validating.
127 */
128 function assertEavAccess ( eavAccess , table ) {
129 if (eavAccess === undefined || eavAccess === null ) return null ;
130 const access = assertAccessPair (eavAccess, table, 'eavAccess' );
131 if (eavAccess.additionalPairs !== undefined ) {
132 if ( ! Array. isArray (eavAccess.additionalPairs)) throw new Error ( `table ${ table }: eavAccess.additionalPairs must be an array` );
133 access.additionalPairs = eavAccess.additionalPairs. map (( p ) => assertAccessPair (p, table, 'eavAccess.additionalPairs[]' ));
134 }
135 return access;
136 }
137
138 /** Every pair in an eavAccess, primary first — what any per-pair reasoning should iterate. */
139 function eavAccessPairs ( eavAccess ) {
140 if ( ! eavAccess) return [];
141 const { keyColumn , valueColumn , tier , pinnedKeys } = eavAccess;
142 return [{ keyColumn, valueColumn, tier, pinnedKeys }, ... (eavAccess.additionalPairs || [])];
143 }
144
145 /**
146 * Every value column this table guards -- the primary pair's plus any additional ones. What
147 * a caller needs to build a legal request: these columns are unreachable unless the matching
148 * key column is pinned.
149 */
150 function guardedValueColumns ( pairOrAccess ) {
151 if ( ! pairOrAccess) return [];
152 return [pairOrAccess.valueColumn, ... (pairOrAccess.additionalPairs || []). map (( p ) => p.valueColumn)];
153 }
154
155 /**
156 * Reports the live column list for one bare table name. Structure only, never row data --
157 * see modules/class-wmh2-structure-discovery.php's own contract. Throws on any
158 * malformed/incomplete response rather than returning a partial result silently.
159 *
160 * WordPress Application Password path only (GET, via the caller-supplied `httpClient`); the
161 * plugin accepts a user with `manage_woocommerce` or `manage_options`.
162 */
163 async function discoverStructure ({ httpClient , siteBaseUrl , auth , table } = {}) {
164 if ( ! siteBaseUrl || typeof siteBaseUrl !== 'string' ) throw new Error ( 'siteBaseUrl is required' );
165 if ( ! table || typeof table !== 'string' || ! / ^ [A-Za-z0-9_] +$ / . test (table)) {
166 throw new Error ( 'table is required and must be a bare identifier (^[A-Za-z0-9_]+$)' );
167 }
168
169 if ( ! httpClient || typeof httpClient.get !== 'function' ) throw new Error ( 'httpClient.get is required' );
170 const base = siteBaseUrl. replace ( / \/ $ / , '' );
171 const response = await httpClient. get ( `${ base }/wp-json/wix-wp-plugin/v2/structure` , {
172 params: { table },
173 auth,
174 });
175 const body = bodyOf (response);
176 if ( ! body || body.table !== table || ! Array. isArray (body.columns)) {
177 throw new Error ( `table ${ table }: response did not match discover-structure's response-payload contract` );
178 }
179 for ( const column of body.columns) assertColumn (column, table);
180
181 // `eavPair` MUST be propagated. Dropping it here was a real bug: it is the
182 // caller's only advance warning that this table is key-scoped, so without it a caller
183 // builds an unscoped "select every column" request and gets a 400 it cannot interpret.
184 return { table: body.table, columns: body.columns, eavPair: assertEavPair (body.eavPair, table) };
185 }
186
187 /**
188 * Runs one structure request. Returns one page of rows plus pagingMetadata -- the caller
189 * is responsible for looping on `hasNext`/`cursors.next` until a full read completes, same
190 * cursor-paging contract as any other WordPress source route this skill reads. This client
191 * does not re-validate the structure request's own shape (table/select/where/groupBy/orderBy)
192 * beyond requiring a `table` -- the plugin itself re-validates every identifier against a
193 * live DESCRIBE on every request, so duplicating that here would only create a second copy
194 * to keep in sync.
195 *
196 * WordPress Application Password path only (POST /query/admin-key, plain JSON, via the
197 * caller-supplied `httpClient`). An omitted `limit` lets the server default it.
198 *
199 * `onSourceRead` is the telemetry hook: called once per successful read with the
200 * counts for THAT read, for the caller to pass to rp-telemetry's `sourceRead()`. Optional, so
201 * a unit test or a one-off script need not wire telemetry, but every real reader should.
202 */
203 async function queryStructure ({ httpClient , siteBaseUrl , auth , structureRequest , cursor , limit , onSourceRead } = {}) {
204 if ( ! siteBaseUrl || typeof siteBaseUrl !== 'string' ) throw new Error ( 'siteBaseUrl is required' );
205 if ( ! structureRequest || typeof structureRequest !== 'object' || typeof structureRequest.table !== 'string' ) {
206 throw new Error ( 'structureRequest with a table is required' );
207 }
208
209 if ( ! httpClient || typeof httpClient.post !== 'function' ) throw new Error ( 'httpClient.post is required' );
210 const base = siteBaseUrl. replace ( / \/ $ / , '' );
211 const requestBody = { structureRequest };
212 if (cursor !== undefined ) requestBody.cursor = cursor;
213 if (limit !== undefined ) requestBody.limit = limit;
214 const response = await httpClient. post ( `${ base }/wp-json/wix-wp-plugin/v2/query/admin-key` , {
215 body: JSON . stringify (requestBody),
216 auth,
217 });
218 const body = bodyOf (response);
219 if ( ! body || ! Array. isArray (body.rows) || ! body.pagingMetadata) {
220 throw new Error ( `table ${ structureRequest . table }: response did not match query-structure's response-payload contract` );
221 }
222 const { pagingMetadata } = body;
223 if ( typeof pagingMetadata.hasNext !== 'boolean' || typeof pagingMetadata.total !== 'number'
224 || ! pagingMetadata.cursors || ! ( 'next' in pagingMetadata.cursors)) {
225 throw new Error ( `table ${ structureRequest . table }: pagingMetadata does not match query-structure's response-payload contract` );
226 }
227
228 // REQUIRED on every response, so its absence is a hard error rather than a
229 // defaulted zero. A plugin old enough not to send it is a plugin without the value guard
230 // at all, and silently treating that as "nothing was redacted" would report a guarded read
231 // that never happened. Refusing names the real problem: upgrade the plugin.
232 const redactionMetadata = assertRedactionMetadata (body.redactionMetadata, structureRequest.table);
233
234 const eavAccess = assertEavAccess (body.eavAccess, structureRequest.table);
235 if (onSourceRead) onSourceRead ( sourceReadCounts (structureRequest.table, eavAccess, redactionMetadata));
236 return { rows: body.rows, pagingMetadata, redactionMetadata, eavAccess };
237 }
238
239 function sourceReadCounts ( table , eavAccess , redactionMetadata ) {
240 const counts = { redacted_values: redactionMetadata.redactedValueCount };
241 const pairs = eavAccessPairs (eavAccess);
242 if (pairs. length > 0 ) {
243 // MIXED-TIER RULE, stated rather than left to whichever pair happens to be first: a
244 // request counts as SCOPED if ANY pair was pinned. The counts answer "how many reads
245 // returned key/value DATA versus only key names", and one pinned pair means values came
246 // back — so classifying by the primary pair alone would file a read that returned values
247 // as a name-only discovery read. Reading the first pair's tier was the earlier behaviour
248 // and it misclassified exactly this case.
249 const anyPinned = pairs. some (( p ) => p.tier === 'key-scoped' );
250 counts[anyPinned ? 'scoped_queries' : 'discovery_queries' ] = 1 ;
251 counts[anyPinned ? 'scoped_tables' : 'discovery_tables' ] = [table];
252 }
253 return counts;
254 }
255
256 /**
257 * Would this request violate the tier rule -- i.e. does it name a guarded value column without
258 * pinning that pair's key with `eq`/`in`?
259 *
260 * Decided from the request and the schema, NOT from the response, because the plugin answers a
261 * tier violation with the same 400 as a nonexistent column, a malformed request, or a bad
262 * cursor -- deliberately, so a refusal never discloses which column holds values. Treating
263 * every 400 on a key/value table as a tier refusal was the earlier behaviour and it
264 * misclassified the common case: a profile naming a column the site has since dropped is an
265 * outdated profile, and filing it as a security-control refusal makes the one number that
266 * should mean "something tried to reach values it had not named" mean nothing.
267 *
268 * Mirrors the plugin's own predicate, which is why it lives beside the client
269 * rather than at a call site.
270 *
271 * @param eavPair discover-structure's `eavPair` (with any `additionalPairs`), or null.
272 */
273 function violatesTierRule ( structureRequest , eavPair ) {
274 if ( ! eavPair) return false ;
275 const pairs = [{ keyColumn: eavPair.keyColumn, valueColumn: eavPair.valueColumn }, ... (eavPair.additionalPairs || [])];
276 const where = Array. isArray (structureRequest.where) ? structureRequest.where : [];
277 const names = new Set ([
278 ... (structureRequest.select || []). map (( item ) => item && item.column),
279 ... where. map (( condition ) => condition && condition.column),
280 ... (structureRequest.groupBy || []),
281 ... (structureRequest.orderBy || []). map (( item ) => item && item.column),
282 ]);
283 return pairs. some (({ keyColumn , valueColumn }) => {
284 if ( ! names. has (valueColumn)) return false ;
285 const pinned = where. some (( c ) => c && c.column === keyColumn && (c.op === 'eq' || c.op === 'in' ));
286 return ! pinned;
287 });
288 }
289
290 /** A tier-rule refusal, counted separately once `violatesTierRule` has established it is one. */
291 function recordTierRefusal ( onSourceRead ) {
292 if (onSourceRead) onSourceRead ({ tier_refusals: 1 });
293 }
294
295 // Two caps, both the plugin's, both load-bearing here.
296 const MAX_PINNED_KEYS = 50 ; // per scoped request
297 const MAX_PAGE_LIMIT = 200 ; // per page, WMH2_Contract::MAX_LIMIT
298 const MAX_SELECT_COLUMNS = 50 ; // WMH2_Structure_Query::MAX_SELECT_COLUMNS
299
300 /**
301 * Page the discovery tier to exhaustion and return EVERY distinct key.
302 *
303 * Exhaustion is the whole contract. A truncated key list reads as "these are all the keys" and
304 * sends a mapper down a wrong path, so this either returns a complete list or says plainly that
305 * it did not: `truncated` is true when `maxKeys` was hit, and a caller must treat that as an
306 * incomplete extraction rather than a slightly-short one. There is no silent cap.
307 */
308 function validateStructureRequest ( request ) {
309 const identifier = ( value ) => typeof value === 'string' && / ^ [A-Za-z0-9_] +$ / . test (value);
310 const keys = ( value , allowed ) => value && typeof value === 'object' && ! Array. isArray (value) && Object. keys (value). every (( key ) => allowed. includes (key));
311 if ( ! keys (request, [ 'table' , 'select' , 'where' , 'groupBy' , 'orderBy' ]) || ! identifier (request.table)) throw new Error ( 'invalid structure request/table' );
312 if ( ! Array. isArray (request.select) || ! request.select. length || request.select. length > MAX_SELECT_COLUMNS ) throw new Error ( 'structure select must contain 1..50 columns' );
313 for ( const field of request.select) {
314 if ( ! keys (field, [ 'column' , 'aggregate' , 'as' ]) || ! identifier (field.column)
315 || (field.aggregate !== undefined && ! [ 'sum' , 'count' , 'avg' , 'min' , 'max' ]. includes (field.aggregate))
316 || (field.as !== undefined && ( typeof field.as !== 'string' || ! field.as))) throw new Error ( 'invalid structure select field' );
317 }
318 if (request.where !== undefined && ! Array. isArray (request.where)) throw new Error ( 'structure where must be an array' );
319 validateEavScope (request.where || []);
320 if (request.groupBy !== undefined && ( ! Array. isArray (request.groupBy) || request.groupBy. some (( column ) => ! identifier (column)))) throw new Error ( 'invalid structure groupBy' );
321 if (request.groupBy?. length && request.select. some (( field ) => ! field.aggregate && ! request.groupBy. includes (field.column))) throw new Error ( 'nonaggregate fields must be grouped' );
322 if ( ! Array. isArray (request.orderBy) || ! request.orderBy. length || request.orderBy. some (( item ) => ! keys (item, [ 'column' , 'direction' ]) || ! identifier (item.column) || ! [ 'asc' , 'desc' ]. includes (item.direction))) throw new Error ( 'structure orderBy is required' );
323 return request;
324 }
325
326 function validateEavScope ( scope , reserved = 0 ) {
327 if ( ! Array. isArray (scope) || scope. length + reserved > MAX_WHERE_CONDITIONS ) throw new Error ( 'EAV scope exceeds where limit' );
328 const scalar = ( value , nullable ) => (nullable && value === null ) || [ 'string' , 'boolean' ]. includes ( typeof value) || ( typeof value === 'number' && Number. isFinite (value));
329 for ( const condition of scope) {
330 if ( ! condition || Object. keys (condition). some (( key ) => ! [ 'column' , 'op' , 'value' ]. includes (key)) || ! Object. hasOwn (condition, 'value' )) throw new Error ( 'invalid EAV scope condition' );
331 if ( ! condition || ! / ^ [A-Za-z0-9_] +$ / . test (condition.column)
332 || ! [ 'eq' , 'neq' , 'gt' , 'gte' , 'lt' , 'lte' , 'in' ]. includes (condition.op)) throw new Error ( 'invalid EAV scope' );
333 if (condition.op === 'in' && ( ! Array. isArray (condition.value) || condition.value. length < 1 || condition.value. length > MAX_IN_VALUES || condition.value. some (( value ) => ! scalar (value, false )))) throw new Error ( 'EAV scope IN must contain 1..100 scalar values' );
334 if (condition.op !== 'in' && ! scalar (condition.value, true )) throw new Error ( 'EAV scope comparison must use a scalar' );
335 }
336 return scope;
337 }
338
339 async function discoverEavKeys ({ httpClient , siteBaseUrl , auth , table , keyColumn , maxKeys = 10000 , onSourceRead , scope = [] } = {}) {
340 // `maxKeys` bounds how many more PAGES we will fetch, not the exact list length: the check
341 // runs after exhaustion, so a sweep can return up to one page beyond the cap while still
342 // being complete. That is the right trade -- the alternative reported a table with exactly
343 // `maxKeys` keys as truncated and blocked a complete extraction.
344 const keys = [];
345 const keyCounts = {};
346 let cursor = null ;
347 let pages = 0 ;
348 let truncated = false ;
349 for (;;) {
350 // eslint-disable-next-line no-await-in-loop
351 const page = await queryStructure ({
352 httpClient,
353 siteBaseUrl,
354 auth,
355 structureRequest: {
356 table,
357 // The COUNT is not decoration. It is what lets the plan state how many 200-row pages
358 // each batch will need, so "how expensive is this extraction" is answerable before
359 // running it rather than discovered while running it.
360 select: [{ column: keyColumn }, { column: keyColumn, aggregate: 'count' , as: 'n' }],
361 ... (scope. length ? { where: validateEavScope (scope) } : {}),
362 groupBy: [keyColumn],
363 orderBy: [{ column: keyColumn, direction: 'asc' }],
364 },
365 cursor,
366 limit: MAX_PAGE_LIMIT ,
367 onSourceRead,
368 });
369 pages += 1 ;
370 for ( const row of page.rows) {
371 if ( typeof row[keyColumn] !== 'string' ) continue ;
372 keys. push (row[keyColumn]);
373 const count = Number (row.n);
374 keyCounts[row[keyColumn]] = Number. isFinite (count) ? count : null ;
375 }
376 cursor = page.pagingMetadata.cursors.next;
377 // Exhausted: complete, whatever the count. Checking the cap FIRST reported a table with
378 // exactly `maxKeys` keys as truncated and blocked a complete extraction from promotion.
379 if ( ! cursor) break ;
380 if (keys. length >= maxKeys) { truncated = true ; break ; }
381 }
382 return { keys: [ ...new Set (keys)], keyCounts, pages, truncated };
383 }
384
385 /**
386 * Turn discovered key lists into the exact requests a generated reader runs to extract VALUES.
387 *
388 * This exists because "discover the keys, then read them" is two steps, and only the first was
389 * ever executable from a persisted artifact. Discovery's `structure-request.json` for a
390 * key/value table deliberately omits the guarded value columns, so a reader that runs it
391 * verbatim extracts key NAMES and nothing else. The values are the whole point, so the second
392 * step has to be an artifact too, not a paragraph telling a generator to improvise one.
393 *
394 * Three things this has to get right, each of which produced a broken plan when it did not:
395 *
396 * 1. **Carry the record identity.** A `postmeta` row is `{meta_key, meta_value}` plus the
397 * `post_id` it belongs to. Without that column the extraction is a bag of values that cannot
398 * be attributed to an order, which is worse than no extraction because it looks like data.
399 * Every non-key, non-value column rides along; once a key is pinned they are all readable.
400 * 2. **One pair at a time.** Selecting a second pair's value column while pinning only the
401 * first pair's key is a request the plugin REFUSES (each value answers only to its own key).
402 * So each pair gets its own key list, its own batches and its own select.
403 * 3. **Order by something unique.** Pagination here is offset-based, so ordering by a
404 * non-unique column (the key column, which repeats once per record) lets rows shift between
405 * pages. Prefer the primary key, then a unique column; fall back to the key column only when
406 * the table offers nothing better, and say so in the plan.
407 *
408 * @param keysByPair { [keyColumn]: string[] } — one discovered key list per pair.
409 */
410 function buildEavReadPlan ({ table , eavPair , schemaColumns = [], keysByPair , countsByPair = null , batchSize = MAX_PINNED_KEYS , truncated = false , scope = [] }) {
411 if ( ! eavPair) throw new Error ( `table ${ table }: buildEavReadPlan needs the eavPair from discover-structure` );
412 if ( ! keysByPair || typeof keysByPair !== 'object' ) throw new Error ( `table ${ table }: keysByPair must be an object of keyColumn -> keys[]` );
413 if (batchSize < 1 || batchSize > MAX_PINNED_KEYS ) {
414 throw new Error ( `table ${ table }: batchSize must be 1..${ MAX_PINNED_KEYS } (the plugin's pinned-key cap)` );
415 }
416
417 const pairs = [{ keyColumn: eavPair.keyColumn, valueColumn: eavPair.valueColumn }, ... (eavPair.additionalPairs || [])];
418 const keyColumns = new Set (pairs. map (( pair ) => pair.keyColumn));
419 const valueColumns = new Set (pairs. map (( pair ) => pair.valueColumn));
420
421 const named = schemaColumns. map (( column ) => ( typeof column === 'string' ? { name: column, key: null } : column));
422 const allIdentity = named. filter (( column ) => ! keyColumns. has (column.name) && ! valueColumns. has (column.name));
423
424 // Offset pagination needs a stable, unique sort. Anything else lets a row move between pages.
425 // ⚠️ MySQL permits MANY NULLs in a unique index, so a NULLABLE unique column can tie and is
426 // not a stable sort. Only a primary key, or a unique column the schema reports as NOT NULL,
427 // earns orderByIsUnique. A column whose nullability is unknown does not get the benefit of
428 // the doubt -- claiming stable pagination and not having it is the failure being avoided.
429 const uniqueColumn = named. find (( c ) => c.key === 'primary' )
430 || named. find (( c ) => c.key === 'unique' && c.nullable === false );
431 const orderColumn = uniqueColumn ? uniqueColumn.name : null ;
432
433 // ⚠️ Two slots are RESERVED for the pair's key and value columns before identity columns are
434 // taken. Appending them last and truncating the whole list to the select cap silently dropped
435 // the value column on a table with 49+ identity columns -- a plan that looks complete, runs
436 // without error, and extracts no values at all. When identity has to be trimmed, the columns
437 // most likely to identify a record survive: the sort column, then indexed ones, then the rest.
438 const identityBudget = MAX_SELECT_COLUMNS - 2 ;
439 const ranked = [ ... allIdentity]. sort (( a , b ) => rankIdentity (a, orderColumn) - rankIdentity (b, orderColumn));
440 const identityColumns = ranked. slice ( 0 , identityBudget). map (( column ) => column.name);
441 const droppedIdentityColumns = ranked. slice (identityBudget). map (( column ) => column.name);
442
443 let batchIndex = 0 ;
444 const planPairs = pairs. map (( pair ) => {
445 const counts = (countsByPair && countsByPair[pair.keyColumn]) || null ;
446 const keys = [ ...new Set ((keysByPair[pair.keyColumn] || []). filter (( k ) => typeof k === 'string' && k !== '' ))]. sort ();
447 // identity + this pair's key + this pair's value, capped at the plugin's select limit. The
448 // key column rides so a value can be attributed to its key without relying on request order,
449 // which breaks the moment a key has no rows.
450 const select = [ ... identityColumns, pair.keyColumn, pair.valueColumn]. map (( column ) => ({ column }));
451 const order = orderColumn || pair.keyColumn;
452 const batches = [];
453 for ( let i = 0 ; i < keys. length ; i += batchSize) {
454 const batchKeys = keys. slice (i, i + batchSize);
455 // Rows this batch will return, and therefore pages, when discovery supplied counts.
456 // null when it did not -- an unknown is reported as unknown, never as zero.
457 const expectedRows = counts ? batchKeys. reduce (( sum , key ) => sum + (Number. isFinite (counts[key]) ? counts[key] : 0 ), 0 ) : null ;
458 batches. push ({
459 batchIndex: batchIndex ++ ,
460 keyColumn: pair.keyColumn,
461 keys: batchKeys,
462 expectedRows,
463 expectedPages: expectedRows === null ? null : Math. max ( 1 , Math. ceil (expectedRows / MAX_PAGE_LIMIT )),
464 structureRequest: {
465 table,
466 select,
467 where: [ ... validateEavScope (scope, 1 ), { column: pair.keyColumn, op: 'in' , value: batchKeys }],
468 orderBy: [{ column: order, direction: 'asc' }],
469 },
470 });
471 }
472 return { ... pair, distinctKeyCount: keys. length , batches };
473 });
474
475 const plan = {
476 planVersion: 1 ,
477 table,
478 identityColumns,
479 // Non-empty means the table is wider than the select cap allows, so extracted rows carry
480 // less than the full record context. Reported, never silent.
481 droppedIdentityColumns,
482 orderColumn: orderColumn || null ,
483 // ⚠️ false means the sort column repeats, so offset pagination can shift rows between
484 // pages. A reader must treat that extraction as best-effort and say so.
485 orderByIsUnique: Boolean (orderColumn),
486 batchSize,
487 pageLimit: MAX_PAGE_LIMIT ,
488 keysTruncated: truncated,
489 distinctKeyCount: planPairs. reduce (( sum , pair ) => sum + pair.distinctKeyCount, 0 ),
490 expectedBatches: batchIndex,
491 // The real request cost, not a batch count: a single batch whose keys hold thousands of
492 // rows needs many 200-row pages. null when discovery could not supply counts.
493 expectedScopedRequests: planPairs. every (( pair ) => pair.batches. every (( b ) => b.expectedPages !== null ))
494 ? planPairs. reduce (( sum , pair ) => sum + pair.batches. reduce (( n , b ) => n + b.expectedPages, 0 ), 0 )
495 : null ,
496 pairs: planPairs,
497 };
498 // Identity for the checkpoint. A resume must be able to tell that the plan it is resuming
499 // into is the plan it started: discovery can rewrite the plan between runs (the site gained
500 // or lost keys), and continuing a half-finished extraction against a different batch layout
501 // silently mixes two reads.
502 plan.planHash = eavReadPlanHash (plan);
503 return plan;
504 }
505
506 // Excluded from the hash. `planHash` because a hash cannot cover itself -- including it made the
507 // embedded value un-recomputable from the persisted file, so nothing could ever verify it.
508 // `acknowledgedDroppedColumns` because it is a human annotation ABOUT the plan, not part of the
509 // read the plan describes; acknowledging an omission must not invalidate an in-flight resume.
510 //
511 // ⚠️ That exclusion has a consequence, and it is handled in the MANIFEST rather than here: the
512 // acknowledgement flips reconciliation from failure to success, so if it lived nowhere else,
513 // withdrawing it later would leave the plan hash unchanged and an incomplete extraction would
514 // still look reusable. `eavExtractionIsReusable()` below is where that is caught -- the
515 // approval is provenance, and provenance belongs with the artifact it approved.
516 const PLAN_HASH_EXCLUDED_KEYS = new Set ([ 'planHash' , 'acknowledgedDroppedColumns' ]);
517
518 /**
519 * The canonical hash of a read plan's CONTENT.
520 *
521 * One function, used in three places that must agree: stamping the plan at build time,
522 * re-deriving it from the persisted file before promotion, and checking a checkpoint on resume.
523 * Three call sites hashing "the plan" three slightly different ways is how an integrity check
524 * ends up unable to detect anything -- which is exactly what happened when the stamp was taken
525 * before `planHash` existed and codegen was told to hash the finished file.
526 *
527 * Keys are sorted at every level so serialization order cannot change the answer.
528 */
529 function eavReadPlanHash ( plan ) {
530 const canonical = ( value ) => {
531 if (Array. isArray (value)) return value. map (canonical);
532 if (value && typeof value === 'object' ) {
533 return Object. keys (value). sort ()
534 . filter (( key ) => ! PLAN_HASH_EXCLUDED_KEYS . has (key))
535 . reduce (( out , key ) => { out[key] = canonical (value[key]); return out; }, {});
536 }
537 return value;
538 };
539 return createHash ( 'sha256' ). update ( JSON . stringify ( canonical (plan))). digest ( 'hex' ). slice ( 0 , 16 );
540 }
541
542 /**
543 * Does this plan's embedded hash actually match its contents?
544 *
545 * Without this, `planHash` proves only that a checkpoint and a plan carry the same string --
546 * so editing a batch's request while leaving the hash alone reconciled clean, which is the
547 * tamper case an integrity check exists to catch.
548 */
549 function verifyEavReadPlanIntegrity ( plan ) {
550 const actual = eavReadPlanHash (plan);
551 if (plan.planHash !== actual) {
552 return `plan ${ plan . table } declares planHash ${ plan . planHash } but its contents hash to ${ actual } -- the plan file was edited after discovery wrote it; regenerate it rather than extracting from it` ;
553 }
554 return null ;
555 }
556
557 /** Sort key for identity columns when the select cap forces a trim: keep what identifies a row. */
558 function rankIdentity ( column , orderColumn ) {
559 if (column.name === orderColumn) return 0 ;
560 if (column.key === 'primary' ) return 1 ;
561 if (column.key === 'unique' ) return 2 ;
562 if (column.key === 'index' ) return 3 ;
563 return 4 ;
564 }
565
566 /** Every batch across every pair, in execution order. */
567 function eavReadPlanBatches ( plan ) {
568 return (plan.pairs || []). flatMap (( pair ) => pair.batches);
569 }
570
571 /**
572 * Did a plan's execution actually complete? Returns the problems; empty means clean.
573 *
574 * Separate from execution on purpose. "It did not throw" is not completeness, and the earlier
575 * version of this accepted any non-negative row count, so a one-row extraction of a five-batch
576 * plan passed. Completeness here means three things, and the third is the one that catches a
577 * page loop that stopped early:
578 *
579 * - every batch the plan expects was run, identified by index, not merely counted;
580 * - each batch read as many rows as its own first page said it would (`snapshotTotal`);
581 * - the rows actually written equal the sum of those totals.
582 *
583 * @param observed { batches: [{batchIndex, rowsRead, snapshotTotal}], rowsWritten }
584 */
585 function reconcileEavReadPlan ( plan , observed ) {
586 const problems = [];
587 const batches = (observed && Array. isArray (observed.batches)) ? observed.batches : null ;
588 if ( ! batches) return [ 'reconcileEavReadPlan needs observed.batches: [{batchIndex, rowsRead, snapshotTotal}]' ];
589
590 if (plan.keysTruncated) {
591 problems. push ( `key discovery hit its cap, so the key list is INCOMPLETE -- this extraction cannot be treated as whole-table` );
592 }
593 if (Array. isArray (plan.droppedIdentityColumns) && plan.droppedIdentityColumns. length > 0
594 && plan.acknowledgedDroppedColumns !== true ) {
595 problems. push (
596 `this table is wider than the ${ MAX_SELECT_COLUMNS }-column select cap, so ${ plan . droppedIdentityColumns . length } column(s) are absent from every extracted row `
597 + `(${ plan . droppedIdentityColumns . join ( ', ' ) }). The extraction is INCOMPLETE per record. Either narrow the table's mapping and regenerate the plan with an explicit column choice, `
598 + 'or set acknowledgedDroppedColumns: true on the plan to record that the omission was chosen deliberately' ,
599 );
600 }
601 if (plan.orderByIsUnique === false ) {
602 problems. push ( `this table has no primary or unique column, so batches were ordered by "${ plan . orderColumn }", which repeats -- offset pagination may have shifted rows between pages and the extraction is best-effort` );
603 }
604
605 // ⚠️ Duplicates and strangers are rejected BEFORE any total is computed. Collapsing them into
606 // a Map while summing the original array let a resume that re-ran batch 0 report double its
607 // rows and reconcile clean -- duplicated output, promoted as complete.
608 const expectedIndices = new Set ( eavReadPlanBatches (plan). map (( b ) => b.batchIndex));
609 // The plan must first be internally consistent: comparing a checkpoint against a plan whose
610 // own hash no longer describes it just confirms two strings match.
611 const tampered = verifyEavReadPlanIntegrity (plan);
612 if (tampered) return [tampered];
613
614 // ⚠️ REQUIRED, not optional. An optional integrity check is one the documented call path
615 // forgets to pass, and then two different plans reconcile against each other.
616 if (observed.planHash === undefined || observed.planHash === null ) {
617 return [ 'reconcileEavReadPlan requires observed.planHash -- without it a checkpoint from a different plan reconciles clean' ];
618 }
619 if (observed.planHash !== plan.planHash) {
620 return [ `this checkpoint was written against plan ${ observed . planHash }, but the plan on disk is ${ plan . planHash } -- discovery rewrote it, so the extraction must restart rather than resume into a different batch layout` ];
621 }
622
623 const seen = new Map ();
624 for ( const observation of batches) {
625 if ( ! expectedIndices. has (observation.batchIndex)) {
626 problems. push ( `observed batch ${ observation . batchIndex } is not in this plan -- the checkpoint belongs to a different plan` );
627 continue ;
628 }
629 if (seen. has (observation.batchIndex)) {
630 problems. push ( `batch ${ observation . batchIndex } was recorded more than once -- a resume re-ran it and its rows are duplicated in the output` );
631 continue ;
632 }
633 seen. set (observation.batchIndex, observation);
634 }
635 if (problems. some (( p ) => /recorded more than once | not in this plan/ . test (p))) return problems;
636
637 for ( const batch of eavReadPlanBatches (plan)) {
638 const ran = seen. get (batch.batchIndex);
639 if ( ! ran) {
640 problems. push ( `batch ${ batch . batchIndex } (${ batch . keyColumn }, ${ batch . keys . length } key(s)) never ran -- the extraction is partial` );
641 continue ;
642 }
643 if ( ! Number. isInteger (ran.snapshotTotal) || ! Number. isInteger (ran.rowsRead)) {
644 problems. push ( `batch ${ batch . batchIndex } reported a non-integer rowsRead/snapshotTotal` );
645 continue ;
646 }
647 if (ran.rowsRead !== ran.snapshotTotal) {
648 problems. push ( `batch ${ batch . batchIndex } read ${ ran . rowsRead } of ${ ran . snapshotTotal } rows -- its page loop stopped early` );
649 }
650 }
651
652 const expectedRows = [ ... seen. values ()]. reduce (( sum , b ) => sum + (Number. isInteger (b.rowsRead) ? b.rowsRead : 0 ), 0 );
653 if ( ! Number. isInteger (observed.rowsWritten) || observed.rowsWritten < 0 ) {
654 problems. push ( 'rowsWritten must be a non-negative integer' );
655 } else if (observed.rowsWritten !== expectedRows) {
656 problems. push ( `wrote ${ observed . rowsWritten } rows but the batches read ${ expectedRows } -- the output does not match what was fetched` );
657 }
658 return problems;
659 }
660
661 /**
662 * What the manifest must record about a completed EAV extraction, so a later run can decide
663 * whether the file on disk is still usable without re-reading the source.
664 */
665 function eavExtractionManifest ( plan , { planFile , completedBatches , rowsWritten , extractedAt , ndjsonDigest }) {
666 if ( typeof planFile !== 'string' || planFile === '' ) {
667 throw new Error ( `table ${ plan . table }: eavExtractionManifest needs the planFile it was extracted from` );
668 }
669 if ( typeof ndjsonDigest !== 'string' || ndjsonDigest === '' ) {
670 throw new Error ( `table ${ plan . table }: eavExtractionManifest needs the ndjsonDigest of the promoted file -- a manifest not bound to its data describes nothing` );
671 }
672 return {
673 kind: 'eav-read-plan' ,
674 // Which plan produced this file. The hash proves the CONTENT matches; this says which
675 // artifact on disk to go and re-hash, which a later run needs before it can compare.
676 planFile,
677 planHash: plan.planHash,
678 completedBatches,
679 rowsWritten,
680 // These three qualify what the file CONTAINS, so they travel with it. A consumer must be
681 // able to learn "rows in this file are missing columns" from the artifact rather than from
682 // whoever happened to run the extraction.
683 keysTruncated: Boolean (plan.keysTruncated),
684 droppedIdentityColumns: plan.droppedIdentityColumns || [],
685 orderByIsUnique: plan.orderByIsUnique !== false ,
686 // The APPROVAL that allowed an incomplete extraction to promote. Recorded because it is
687 // deliberately outside planHash: without it here, withdrawing the acknowledgement would
688 // change nothing a later run could see.
689 acknowledgedDroppedColumns: plan.acknowledgedDroppedColumns === true ,
690 // Binds the manifest to the ACTUAL FILE. Everything else here describes the read that was
691 // supposed to happen; this is the only field that says what landed on disk. Without it a
692 // truncated, replaced or half-written NDJSON sitting beside a perfectly valid manifest is
693 // accepted, and the manifest's authority is exactly what makes that dangerous.
694 ndjsonDigest,
695 extractedAt,
696 };
697 }
698
699 /**
700 * The digest recorded in a manifest and recomputed on reuse. One function so the two can never
701 * be computed differently -- the same reasoning as eavReadPlanHash().
702 */
703 function eavExtractionFileDigest ( contents ) {
704 return createHash ( 'sha256' ). update (contents). digest ( 'hex' );
705 }
706
707 const EAV_MANIFEST_REQUIRED_FIELDS = [
708 'planFile' , 'planHash' , 'completedBatches' , 'rowsWritten' , 'keysTruncated' ,
709 'droppedIdentityColumns' , 'orderByIsUnique' , 'acknowledgedDroppedColumns' , 'ndjsonDigest' ,
710 'extractedAt' ,
711 ];
712
713 /**
714 * Can an existing extraction be reused, or must it be re-read? Returns the reasons it cannot;
715 * empty means reuse is safe.
716 *
717 * **Re-verifies the recorded extraction rather than trusting that it happened.** An earlier
718 * version checked only `kind`, `planHash` and the acknowledgement, so a manifest carrying
719 * nothing but those two fields — or one recording zero completed batches, or a `rowsWritten`
720 * that matched nothing — was accepted, and a run would skip re-extraction on the strength of a
721 * manifest that described no extraction at all. A manifest is an assertion about a file; the
722 * point of reading it is to check the assertion, not to accept it.
723 *
724 * So the checks are, in order of what makes later ones meaningless:
725 * 1. shape — every field present and the right type;
726 * 2. identity — the plan hash still matches, and the plan matches its own hash;
727 * 3. completeness — the batches the manifest records must RECONCILE against the plan, the
728 * same reconciliation the extraction itself had to pass;
729 * 4. qualifiers — what the manifest says about the file must still be true of the plan;
730 * 5. approval — the dropped-column acknowledgement, which is outside the hash.
731 */
732 function eavExtractionIsReusable ( plan , manifest , observed ) {
733 if ( ! manifest || typeof manifest !== 'object' || Array. isArray (manifest) || manifest.kind !== 'eav-read-plan' ) {
734 return [ 'no EAV extraction manifest -- treat the existing file as stale' ];
735 }
736
737 // ⚠️ REQUIRED, and required for the same reason planHash is: a check the caller may omit is a
738 // check the caller omits. `observed` is what is actually on disk right now -- the sidecar path
739 // the caller itself resolved, and the digest and line count of the final file it is deciding
740 // whether to reuse. Everything else here reasons about a read that was SUPPOSED to happen.
741 if ( ! observed || typeof observed !== 'object'
742 || typeof observed.planFile !== 'string' || observed.planFile === ''
743 || typeof observed.ndjsonDigest !== 'string' || observed.ndjsonDigest === ''
744 || ! Number. isInteger (observed.ndjsonRowCount) || observed.ndjsonRowCount < 0 ) {
745 return [ 'eavExtractionIsReusable requires observed: { planFile, ndjsonDigest, ndjsonRowCount } read from disk -- without them the manifest is only checked against itself' ];
746 }
747
748 // 1. Shape. Reported all at once, and nothing else runs: reasoning about completeness from a
749 // manifest that is missing fields produces confident nonsense.
750 const shape = [];
751 for ( const field of EAV_MANIFEST_REQUIRED_FIELDS ) {
752 if (manifest[field] === undefined || manifest[field] === null ) shape. push ( `manifest is missing ${ field }` );
753 }
754 if (shape. length === 0 ) {
755 if ( typeof manifest.planFile !== 'string' || manifest.planFile === '' ) shape. push ( 'manifest planFile must be a non-empty string' );
756 if ( typeof manifest.ndjsonDigest !== 'string' || manifest.ndjsonDigest === '' ) shape. push ( 'manifest ndjsonDigest must be a non-empty string' );
757 if ( ! Array. isArray (manifest.completedBatches)) shape. push ( 'manifest completedBatches must be an array' );
758 if ( ! Number. isInteger (manifest.rowsWritten) || manifest.rowsWritten < 0 ) shape. push ( 'manifest rowsWritten must be a non-negative integer' );
759 if ( ! Array. isArray (manifest.droppedIdentityColumns)) shape. push ( 'manifest droppedIdentityColumns must be an array' );
760 if ( typeof manifest.keysTruncated !== 'boolean' ) shape. push ( 'manifest keysTruncated must be a boolean' );
761 if ( typeof manifest.orderByIsUnique !== 'boolean' ) shape. push ( 'manifest orderByIsUnique must be a boolean' );
762 if ( typeof manifest.acknowledgedDroppedColumns !== 'boolean' ) shape. push ( 'manifest acknowledgedDroppedColumns must be a boolean' );
763 }
764 if (shape. length > 0 ) return shape;
765
766 // 2. Identity.
767 const integrity = verifyEavReadPlanIntegrity (plan);
768 if (integrity) return [integrity];
769 if (manifest.planHash !== plan.planHash) {
770 return [ `the plan changed since this file was written (${ manifest . planHash } -> ${ plan . planHash }) -- re-extract` ];
771 }
772
773 const reasons = [];
774
775 // 2b. The manifest must describe THESE artifacts, not merely valid-looking ones.
776 //
777 // ⚠️ `manifest.planFile` is compared against the path the CALLER resolved; it is never opened.
778 // A manifest is untrusted input -- it can name `../../something-else.json` -- so treating its
779 // path as something to follow would let the file choose what it is validated against.
780 if (manifest.planFile !== observed.planFile) {
781 reasons. push ( `manifest names plan file ${ manifest . planFile } but this entity's plan is ${ observed . planFile } -- the manifest belongs to a different extraction` );
782 }
783 if (manifest.ndjsonDigest !== observed.ndjsonDigest) {
784 reasons. push ( 'the extracted file does not match the digest its manifest recorded -- it was truncated, replaced or half-written since promotion; re-extract' );
785 }
786 if (manifest.rowsWritten !== observed.ndjsonRowCount) {
787 reasons. push ( `the extracted file holds ${ observed . ndjsonRowCount } row(s) but its manifest recorded ${ manifest . rowsWritten } -- re-extract` );
788 }
789
790 // 3. Completeness -- the same bar the extraction had to clear, applied to what it recorded.
791 reasons. push ( ... reconcileEavReadPlan (plan, {
792 planHash: manifest.planHash,
793 batches: manifest.completedBatches,
794 rowsWritten: manifest.rowsWritten,
795 }). map (( problem ) => `the recorded extraction does not reconcile: ${ problem }` ));
796
797 // 4. Qualifiers. If the manifest says the file was paged on a tying sort key and the plan now
798 // says otherwise, one of them is describing a different read.
799 if (manifest.keysTruncated !== Boolean (plan.keysTruncated)) {
800 reasons. push ( `manifest records keysTruncated: ${ manifest . keysTruncated }, the plan says ${ Boolean ( plan . keysTruncated ) } -- they describe different reads` );
801 }
802 if (manifest.orderByIsUnique !== (plan.orderByIsUnique !== false )) {
803 reasons. push ( `manifest records orderByIsUnique: ${ manifest . orderByIsUnique }, the plan says ${ plan . orderByIsUnique !== false } -- they describe different reads` );
804 }
805 const dropped = plan.droppedIdentityColumns || [];
806 if ( JSON . stringify ([ ... manifest.droppedIdentityColumns]. sort ()) !== JSON . stringify ([ ... dropped]. sort ())) {
807 reasons. push ( 'manifest and plan disagree about which identity columns were dropped -- re-extract' );
808 }
809
810 // 5. Approval, which is deliberately outside the hash and so cannot be caught by step 2.
811 // Both sides are checked. The MANIFEST must record that this file's promotion was approved
812 // -- a file with missing columns and no recorded approval was never sanctioned, whatever the
813 // plan says today -- and the PLAN must still carry the approval, or it has been withdrawn.
814 if (dropped. length > 0 && manifest.acknowledgedDroppedColumns !== true ) {
815 reasons. push ( `this file has ${ dropped . length } column(s) missing from every row and its manifest does not record an approval for that -- it was promoted unapproved, so re-extract rather than reuse it` );
816 }
817 if (dropped. length > 0 && plan.acknowledgedDroppedColumns !== true ) {
818 reasons. push ( `this file was promoted with ${ dropped . length } column(s) missing from every row, and that omission is no longer acknowledged -- re-extract or re-acknowledge` );
819 }
820 if (manifest.acknowledgedDroppedColumns === true && dropped. length === 0 ) {
821 reasons. push ( 'the file was promoted under a dropped-column acknowledgement that the current plan no longer needs -- re-extract so the rows carry the full record' );
822 }
823 return reasons;
824 }
825
826 module . exports = {
827 SHARED_CORE_TABLES_REQUIRING_WHERE,
828 validateStructureRequest,
829 MAX_IN_VALUES,
830 MAX_WHERE_CONDITIONS,
831 MAX_PINNED_KEYS,
832 MAX_PAGE_LIMIT,
833 MAX_SELECT_COLUMNS,
834 discoverEavKeys,
835 validateEavScope,
836 buildEavReadPlan,
837 eavReadPlanHash,
838 verifyEavReadPlanIntegrity,
839 eavExtractionManifest,
840 eavExtractionFileDigest,
841 eavExtractionIsReusable,
842 EAV_MANIFEST_REQUIRED_FIELDS,
843 eavReadPlanBatches,
844 reconcileEavReadPlan, HANDLER_VERSION, discoverStructure, queryStructure, guardedValueColumns, eavAccessPairs, sourceReadCounts, recordTierRefusal, violatesTierRule,
845 };