@memberjunction/connector-wordpress 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ import { type UserInfo } from '@memberjunction/core';
2
+ import type { MJCompanyIntegrationEntity, MJIntegrationObjectEntity } from '@memberjunction/core-entities';
3
+ import { BaseRESTIntegrationConnector, type RESTAuthContext, type RESTResponse, type PaginationState, type PaginationType, type ConnectionTestResult, type ExternalObjectSchema, type ExternalFieldSchema, type FetchContext, type FetchBatchResult, type SourceSchemaInfo, type SyncErrorCode, type ErrorSeverity, type DeleteRecordContext, type CRUDResult } from '@memberjunction/integration-engine';
4
+ /** A classified WordPress failure — derived from the error ENVELOPE, not from the status alone. */
5
+ export interface WordPressErrorClassification {
6
+ /** Engine-level sync error code (the `SyncErrorCode` union the run artifact reports). */
7
+ Code: SyncErrorCode;
8
+ /** Engine-level severity. */
9
+ Severity: ErrorSeverity;
10
+ /** The vendor's stable machine code (`rest_no_route`, `woocommerce_rest_authentication_error`, …), when present. */
11
+ VendorCode: string | null;
12
+ /** Whether the engine should retry (429/503/transport), per `IsRetryableError` semantics. */
13
+ Retryable: boolean;
14
+ /** Short machine-ish reason naming WHY this classification was chosen. */
15
+ Reason: string;
16
+ }
17
+ /**
18
+ * WordPress connector — extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
19
+ *
20
+ * Serves BOTH in-scope namespaces (`wp/v2` core and `wc/v3` WooCommerce) off ONE self-hosted site root.
21
+ * Pagination, template-var (per-parent) read traversal and the generic metadata-driven CRUD are
22
+ * inherited; this class supplies the WordPress-specific protocol surface plus the §7/§10 sync-efficiency
23
+ * hooks the frozen contract actually evidences.
24
+ */
25
+ export declare class WordPressConnector extends BaseRESTIntegrationConnector {
26
+ /** Resolved auth per CompanyIntegration.ID — Application Passwords and Woo keys never expire. */
27
+ private authCache;
28
+ /** Route index per API root, fetched once per connector lifetime (656 routes on a stock install). */
29
+ private routeIndexCache;
30
+ /** Response headers keyed by the PARSED BODY OBJECT — the race-free way to get `X-WP-TotalPages`
31
+ * into `ExtractPaginationInfo`, whose signature only receives the body. Weak so nothing is retained. */
32
+ private headersByBody;
33
+ /** Route paths that answered 401/403 to `context=edit` and have been degraded to `context=view`. */
34
+ private contextDegraded;
35
+ /** Per-object query params for the ACTIVE fetch, consumed by AppendDefaultQueryParams. Keyed by object name. */
36
+ private activeFetchParams;
37
+ /** Objects whose capability warnings have already been logged (keep the log honest, not noisy). */
38
+ private warnedOnce;
39
+ /** Last non-2xx READ outcome per route path. The base's paginated loop SWALLOWS a 403 into an empty
40
+ * result; without this the connector could not tell "forbidden" from "genuinely no records". */
41
+ private lastReadFailureByPath;
42
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: T1 compares this === the metadata Name. */
43
+ get IntegrationName(): string;
44
+ get SupportsCreate(): boolean;
45
+ get SupportsUpdate(): boolean;
46
+ get SupportsDelete(): boolean;
47
+ /**
48
+ * FALSE, and deliberately so — the two levels of authority differ and one boolean cannot express both.
49
+ * OBJECT level: the route index enumerates every route the site REGISTERED for that request, but a
50
+ * namespace can be absent because of a feature flag (`wc/v4` needs `rest-api-v4`) or WooCommerce's
51
+ * per-request lazy-load filter, so absence does not mean the vendor dropped it. FIELD level: schema
52
+ * visibility is capability-gated (`context=edit` + the matching capability), so an under-privileged
53
+ * credential legitimately receives a THINNER schema. Deactivating on either would wipe real metadata.
54
+ */
55
+ get DiscoveryIsAuthoritative(): boolean;
56
+ /**
57
+ * NULL on purpose. Neither `wp/v2` nor `wc/v3` documents ANY rate limit — WordPress core imposes none
58
+ * and the WooCommerce v3 docs have no rate-limit section (`Configuration.RateLimitPolicy.vendorDocumented
59
+ * = false`, and the metadata records that no number may be emitted). Real limits are HOST/CDN-imposed and
60
+ * per-tenant. Publishing a `TokensPerSec` here would fabricate a vendor commitment that does not exist;
61
+ * the engine derives a conservative rate instead and `ExtractRetryAfterMs` + `MaxConcurrencyHint` below
62
+ * carry the obligations that ARE real.
63
+ */
64
+ get RateLimitPolicy(): null;
65
+ /**
66
+ * Deliberately LOW. The thing being loaded is the TENANT'S OWN WEBSITE — the same PHP workers that serve
67
+ * their visitors — and deep offset paging is O(offset) in their database. Two in flight is the
68
+ * conservative default the metadata's `connectorObligation` calls for.
69
+ */
70
+ get MaxConcurrencyHint(): number;
71
+ /**
72
+ * Honours `429` + `Retry-After` (and `503`) ADAPTIVELY: parses both the delta-seconds and the HTTP-date
73
+ * forms of the header off the error the transport threw, so the engine's AIMD bucket backs off by the
74
+ * host's actual instruction rather than a guess.
75
+ */
76
+ ExtractRetryAfterMs(error: unknown): number | undefined;
77
+ /**
78
+ * Keyset resume for the objects with NO usable server-side watermark — read from the IO metadata's
79
+ * `StableOrderingKey` (`id` for most collections, `slug`/`code`/`name`/`instance_id` where that is the
80
+ * declared key), never guessed. Null when the object declares no stable key.
81
+ *
82
+ * DELIBERATELY NULL for every object that DOES declare a usable server-side date filter. That is the
83
+ * ENGINE'S OWN CONTRACT, not a preference: `IntegrationEngine`'s §8a keyset block treats "has a
84
+ * StableOrderingKey" and "uses a timestamp watermark" as MUTUALLY EXCLUSIVE — `isKeysetConnector`
85
+ * forces `initialWatermark = null` on every run, and a clean scan then CLEARS the keyset marker instead
86
+ * of saving a timestamp (its own comment: a connector whose object has a usable server-side date
87
+ * incremental MUST NOT declare a StableOrderingKey for that object). Declaring a key for a
88
+ * watermark-capable object therefore (a) never hands `FetchContext.WatermarkValue` back to the
89
+ * connector, so the `modified_after` filter is built but never issued, and (b) never persists a
90
+ * watermark at all — a DEAD incremental that silently full-re-lists forever. Which of the two applies
91
+ * is a PER-OBJECT metadata fact, so it is READ from metadata here rather than declaring both and
92
+ * getting neither.
93
+ *
94
+ * The metadata `StableOrderingKey` COLUMN is untouched and still drives the `orderby=id&order=asc`
95
+ * stable sort in {@link buildObjectQueryParams}; only the engine-facing keyset signal is withheld.
96
+ */
97
+ StableOrderingKey(objectName: string): string | null;
98
+ /**
99
+ * Whether this object has a LIVE server-side incremental filter — all three metadata facts present:
100
+ * the `SupportsIncrementalSync` flag, the `IncrementalWatermarkField` the max-seen is read from, and a
101
+ * `Configuration.incrementalWatermark.filterParam` to actually put on the wire. Anything less is a
102
+ * DECORATIVE watermark and the object is treated as full-scan-only.
103
+ *
104
+ * FALSE, correctly, for `wp/v2/users` and `wc/v3/customers` (their controllers inherit no date params
105
+ * at all) and for the six objects that register `modified_after` but expose no modified column
106
+ * (MenuItem, GlobalStyle, FontFamily, FontFamilyFontFace, OrderRefund, Refund) — every one of which
107
+ * carries a null `incrementalWatermark` plus an `incrementalNote` in the metadata.
108
+ */
109
+ private hasLiveIncrementalWatermark;
110
+ /**
111
+ * DYNAMIC discovery. The Declared metadata is the stock-install FLOOR — never the ceiling — so this
112
+ * reads the CONNECTION's OWN route index (`GET <apiRoot>`) and UNIONS the per-site remainder on top:
113
+ * custom post types, custom taxonomies and third-party plugin namespaces, all of which flow through the
114
+ * same core controllers with a different `rest_base` and are therefore invisible to any pinned source.
115
+ *
116
+ * A route becomes a candidate object when it is a GET collection route (no path capture in its own last
117
+ * segment) that registers `per_page` — i.e. a LISTABLE collection, the discriminator that separates a
118
+ * record set from the RPC routes (`/wp/v2/block-renderer/…`, `/oembed/1.0/proxy`).
119
+ *
120
+ * Namespaces the OPERATOR scoped out are skipped by READING the metadata's own structured
121
+ * `Configuration.OutOfScopeObjectFamilies[].kind` — first-party RPC/admin/legacy/transport surfaces are
122
+ * not record collections. Third-party plugin namespaces are NOT skipped: the metadata's own reason text
123
+ * says they are "reachable at runtime via route-index discovery", which is exactly this path.
124
+ *
125
+ * A discovery failure NEVER removes the declared floor — the union degrades to the floor with a warning.
126
+ */
127
+ DiscoverObjects(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ExternalObjectSchema[]>;
128
+ /**
129
+ * Field discovery via `OPTIONS <route>` — WordPress's self-describing endpoint schema. The result is
130
+ * UNIONED over the Declared field set and NEVER shrinks it: field visibility is capability-gated, so an
131
+ * under-privileged credential legitimately sees a thinner schema and its ABSENCES prove nothing.
132
+ *
133
+ * A templated collection path (`/wc/v3/orders/{order_id}/notes`) has no literal URL to OPTIONS without a
134
+ * real parent id, so those objects return their declared fields unchanged rather than a fabricated set.
135
+ */
136
+ DiscoverFields(companyIntegration: MJCompanyIntegrationEntity, objectName: string, contextUser: UserInfo): Promise<ExternalFieldSchema[]>;
137
+ /**
138
+ * Union introspection — the layer where the per-site truth actually lands in the schema:
139
+ * 1. `super.IntrospectSchema` returns the persisted DECLARED objects (the stock-install floor).
140
+ * 2. Objects the ROUTE INDEX exposes but the floor never declared are APPENDED (per-site customs).
141
+ * 3. Every object's field set is UNIONED with the live `OPTIONS` schema and with a live record SAMPLE
142
+ * (`DiscoverFieldsViaFetch` → `mergeDeclaredWithSampledFields`, never-shrink / declared-wins /
143
+ * capacities widened), so a tenant's registered `meta` keys and plugin-added properties reach the
144
+ * schema instead of being silently dropped at field-mapping time.
145
+ * Every step is best-effort and additive — a failure leaves the declared set exactly as it was.
146
+ */
147
+ IntrospectSchema(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<SourceSchemaInfo>;
148
+ /**
149
+ * OVERRIDDEN for four things the generic path cannot express, then delegated:
150
+ * 1. DUAL-NAMESPACE CREDENTIAL GUARD — a Woo-only consumer key/secret cannot read `wp/v2` at ALL
151
+ * (`WC_REST_Authentication::is_request_to_rest_api()` matches only `wc/`/`wc-` URIs), so those
152
+ * objects surface an explicit capability warning instead of an empty-but-green sync.
153
+ * 2. PER-OBJECT QUERY PARAMS — the stable sort and the metadata-declared incremental filter, staged
154
+ * for `AppendDefaultQueryParams` (which the base calls for every page of every request).
155
+ * 3. WATERMARK — max-seen over the object's declared `IncrementalWatermarkField`, persisted ONLY on a
156
+ * fully-drained pass so a partial batch never advances it.
157
+ * 4. GRACEFUL DEGRADES — an unregistered route (a gated Woo feature such as order fulfillments) and a
158
+ * capability-forbidden collection become a WARNED zero-record result, not a failed sync.
159
+ *
160
+ * `ctx.RequestedSourceFields` is deliberately IGNORED: WordPress's `_fields` param would truncate the
161
+ * record to whatever the connector thought to ask for, which is exactly what breaks the framework's
162
+ * custom/overflow capture of per-site `meta` and plugin-added properties.
163
+ */
164
+ FetchChanges(ctx: FetchContext): Promise<FetchBatchResult>;
165
+ /**
166
+ * Resolves the per-connection credential AND the DERIVED REST API root. Cached per CompanyIntegration —
167
+ * Application Passwords and Woo consumer keys are long-lived with no refresh endpoint, so there is
168
+ * nothing to renew. A credential-free connection is LEGAL and does not throw: WordPress's route index
169
+ * and `OPTIONS` are public, which is what lets discovery work without a secret.
170
+ */
171
+ protected Authenticate(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<RESTAuthContext>;
172
+ /** Static request headers plus the resolved Basic credential (absent on a credential-free connection). */
173
+ protected BuildHeaders(auth: RESTAuthContext): Record<string, string>;
174
+ /**
175
+ * The single wire choke point. Beyond the raw transport it owns four WordPress-specific concerns:
176
+ * 1. `?rest_route=` rewriting for a site without pretty permalinks.
177
+ * 2. The Woo consumer key/secret QUERY-PARAM fallback on `wc/*` URLs, for hosts that strip
178
+ * `Authorization` (Woo gives query params precedence over the header).
179
+ * 3. `context=edit` injection on reads, with a ONE-SHOT graceful degrade to `context=view` on 401/403
180
+ * that LOGS the fields that will now be missing rather than downgrading silently.
181
+ * 4. Throwing `429`/`503` as a typed error carrying the response headers, so the engine's adaptive
182
+ * limiter can honour `Retry-After` instead of seeing an opaque message.
183
+ */
184
+ protected MakeHTTPRequest(auth: RESTAuthContext, url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
185
+ /**
186
+ * Strips the WordPress collection envelope. Three declared response shapes, discriminated STRUCTURALLY
187
+ * from the body itself (the signature receives no object row, and the shape is unambiguous in the bytes):
188
+ * - `array` → a bare JSON array of records (73 of the declared objects).
189
+ * - `object-map` → `{ "<slug>": {…}, … }` (types, statuses, taxonomies, menu-locations) → its values.
190
+ * - `single-object` → one document (settings, system status) → a one-element list.
191
+ * A `responseDataKey` still wins when the metadata declares one.
192
+ */
193
+ protected NormalizeResponse(rawBody: unknown, responseDataKey: string | null): Record<string, unknown>[];
194
+ /**
195
+ * WordPress pagination is OFFSET-based page numbering whose termination signal lives in the RESPONSE
196
+ * HEADERS, not the body — so the headers recorded against this exact body object are consulted first:
197
+ * `X-WP-TotalPages` (authoritative), else the RFC-5988 `Link rel="next"`, else a short page.
198
+ * `X-WP-Total` is surfaced as the expected count. Completeness on a deep scan is BEST-EFFORT: page
199
+ * numbering is offset arithmetic, so concurrent writes shift rows across page boundaries; the
200
+ * `orderby=id&order=asc` stable sort reduces but does not eliminate that drift, and exactness is never claimed.
201
+ */
202
+ protected ExtractPaginationInfo(rawBody: unknown, paginationType: PaginationType, currentPage: number, _currentOffset: number, pageSize: number): PaginationState;
203
+ /** The DERIVED per-connection REST API root (resolved in Authenticate — never `siteUrl + '/wp-json'`). */
204
+ protected GetBaseURL(_companyIntegration: MJCompanyIntegrationEntity, auth: RESTAuthContext): string;
205
+ /**
206
+ * `page` + `per_page`, with `per_page` CLAMPED to the cap the object's own metadata declares
207
+ * (`Configuration.pagination.maxPageSize`, 100 on every in-scope collection). WordPress REJECTS a
208
+ * request above the cap rather than clamping it server-side, so the clamp has to happen here.
209
+ */
210
+ protected BuildPaginatedURL(basePath: string, obj: MJIntegrationObjectEntity, page: number, offset: number, cursor?: string, effectivePageSize?: number): string;
211
+ /**
212
+ * Appends the DECLARED default query params (base behaviour) and then the per-object params this fetch
213
+ * staged: the stable sort and the metadata-declared incremental filter. Params already present in the
214
+ * URL are never duplicated.
215
+ */
216
+ protected AppendDefaultQueryParams(url: string, obj: MJIntegrationObjectEntity): string;
217
+ /** Reads the vendor's `message` out of the `{ code, message, data:{ status } }` envelope. */
218
+ protected ExtractErrorMessage(response: RESTResponse): string | undefined;
219
+ /**
220
+ * OVERRIDDEN for the one genuinely idiosyncratic verb: WordPress DELETE semantics are PER-OBJECT and
221
+ * parameterised, which the generic path (a bare `DELETE <path>`) cannot express.
222
+ * - `requiresForce` (revisions, terms, users, widgets, Woo terms/notes/webhooks…) → `?force=true`;
223
+ * without it those routes reject the request outright.
224
+ * - `requiresReassign` (wp/v2/users) → `&reassign=<user id>`; WordPress requires it because deleting a
225
+ * user must say where their content goes. The id comes from the connection's
226
+ * `Configuration.userDeleteReassignID` and is NEVER guessed — an unset value is a loud error, because
227
+ * inventing one would silently reassign a customer's content to an arbitrary account.
228
+ * - Everything else keeps the vendor default, which for post types is a SOFT delete to `status=trash`.
229
+ * All of this is READ FROM METADATA (`Configuration.deleteSemantics`), not decided here.
230
+ */
231
+ DeleteRecord(ctx: DeleteRecordContext): Promise<CRUDResult>;
232
+ /**
233
+ * Two-part test, because "reachable" and "authenticated" are different facts:
234
+ * 1. The site's REST root must answer with a route index (proves it IS a WordPress REST API, and that
235
+ * the derived API root — Link header / `/wp-json/` / `?rest_route=/` — was resolved correctly).
236
+ * 2. The supplied credential must actually authenticate: `wp/v2/users/me` for an Application Password,
237
+ * or a `wc/v3` read for a Woo-only key pair.
238
+ * A credential-free connection reports failure with an explicit message — discovery works without a
239
+ * secret, but a connection is not "successful" when nothing can be authorised.
240
+ */
241
+ TestConnection(companyIntegration: MJCompanyIntegrationEntity, contextUser: UserInfo): Promise<ConnectionTestResult>;
242
+ /**
243
+ * Classifies from the ERROR ENVELOPE, not the status alone. WordPress serialises every failure across
244
+ * wp/v2, wc/v3 and /batch/v1 as `{ code, message, data:{ status } }`, and `code` is the stable
245
+ * machine-readable discriminator (`message` is localised and must never be parsed).
246
+ *
247
+ * The distinction that matters operationally: a `403` carrying a WordPress JSON envelope is the API
248
+ * refusing a capability (fix the credential), while a `403` carrying an HTML body never reached the API
249
+ * at all — it is a WAF / host / mod_security block, and retrying it is pointless. Anything unrecognised
250
+ * falls through to the engine's own `ClassifyError`.
251
+ */
252
+ ClassifyWordPressResponse(status: number, body: unknown): WordPressErrorClassification;
253
+ /** The raw HTTP call. Isolated so test subclasses can capture the wire without losing the WP behaviours above. */
254
+ protected rawRequest(url: string, method: string, headers: Record<string, string>, body?: unknown): Promise<RESTResponse>;
255
+ /** Rewrites `<site>/__mj_rest_route__/wp/v2/posts?x=1` → `<site>/?rest_route=/wp/v2/posts&x=1`. */
256
+ private rewriteForRestRoute;
257
+ /**
258
+ * WooCommerce accepts `?consumer_key=&consumer_secret=` and gives them PRECEDENCE over the
259
+ * `Authorization` header — the one hard functional advantage of the Woo key pair, for hosts that strip
260
+ * `Authorization`. Applied ONLY to `wc/` routes and ONLY when the connection opts in.
261
+ */
262
+ private appendWooQueryAuth;
263
+ /**
264
+ * Derives the REST API root the way WordPress itself advertises it — NEVER by concatenating
265
+ * `siteUrl + '/wp-json'`. The REST prefix is filterable via `rest_get_url_prefix()`, so a site can serve
266
+ * it from anywhere, and a site without pretty permalinks answers only at `?rest_route=/`. Order:
267
+ * 1. an explicit `apiRoot` on the connection (sandbox/mock redirection by DATA);
268
+ * 2. the `Link: <…>; rel="https://api.w.org/"` response header on the site root (HEAD, then GET);
269
+ * 3. the same `<link>` element in the homepage HTML;
270
+ * 4. `{siteUrl}/wp-json/`, verified by an actual route index;
271
+ * 5. `{siteUrl}/?rest_route=/`, verified the same way.
272
+ */
273
+ private deriveApiRoot;
274
+ /** Reads the advertised REST root out of a `Link` response header or a homepage `<link>` element. */
275
+ private readAdvertisedApiRoot;
276
+ /** Extracts the `<url>` whose `rel` is the WordPress API rel from an RFC-5988 Link header value. */
277
+ private matchApiLink;
278
+ /** A body is a route index when it carries the `routes` map (and usually `namespaces`). */
279
+ private looksLikeRouteIndex;
280
+ /** Fetches (and caches per API root) the site's own route index. `context=view` is explicit so the
281
+ * `context=edit` injector leaves this public discovery call alone. */
282
+ private loadRouteIndex;
283
+ /**
284
+ * Derives LISTABLE COLLECTIONS from the route index. A route qualifies when it is readable, carries no
285
+ * path capture of its own, is not a namespace root, and registers `per_page` — the discriminator that
286
+ * separates a record collection from the RPC routes WordPress also registers.
287
+ */
288
+ private deriveCollectionRoutes;
289
+ /**
290
+ * Namespaces the OPERATOR scoped out of 1.0.0, read from the Integration row's own
291
+ * `Configuration.OutOfScopeObjectFamilies[].kind`. First-party RPC / admin / analytics / legacy /
292
+ * transport / alias namespaces are not record collections and stay out. `third-party-plugin` and the
293
+ * per-site remainder are NOT excluded — the metadata's own reasons say those are "reachable at runtime
294
+ * via route-index discovery", which is precisely this path.
295
+ */
296
+ private readScopedOutNamespaces;
297
+ /** Issues `OPTIONS <route>` and maps the endpoint's JSON Schema properties to ExternalFieldSchema. */
298
+ private describeRoute;
299
+ /**
300
+ * Maps ONE JSON Schema property to a field schema, provable-only throughout: `IsPrimaryKey` is set only
301
+ * when the property is the one the route's own ITEM path addresses records by (Tier-1 addressing-path
302
+ * evidence, the same class the extractor used); `AllowsNull` only when the declared type union contains
303
+ * `null`; `IsForeignKey` is never inferred, because WordPress publishes no machine-readable FK model.
304
+ */
305
+ private schemaPropertyToField;
306
+ /**
307
+ * UNION of declared × described, keyed by field name. Declared WINS on every attribute (it is the
308
+ * docs-provable maximum a fully-privileged credential sees); a described-only field is APPENDED as a
309
+ * per-site custom. Nothing is ever removed — a thinner runtime schema is a capability artefact.
310
+ */
311
+ private unionFieldSchemas;
312
+ /** The literal (untemplated) collection path to OPTIONS for an object, or null when it is parent-templated. */
313
+ private optionsRoutePathFor;
314
+ /** Name of the key an item route addresses records by, e.g. `/wp/v2/posts` → `id` via its `{id}` sibling. */
315
+ private itemRouteKeyName;
316
+ /**
317
+ * The per-object read parameters, every one of them READ FROM METADATA:
318
+ * - STABLE SORT (`orderby=id&order=asc`) on the page-numbered collections whose declared
319
+ * `StableOrderingKey` is `id`, to minimise the offset drift page arithmetic is prone to.
320
+ * - The INCREMENTAL filter, and ONLY where the object declares one. The six objects that register
321
+ * `modified_after` but expose no modified column, plus `wp/v2/users` and `wc/v3/customers` (whose
322
+ * controllers inherit no date params at all), carry a null watermark in metadata and therefore get
323
+ * NO delta path here. The connector never synthesises one, and never rounds an insert-only
324
+ * high-water up to "incremental supported".
325
+ */
326
+ private buildObjectQueryParams;
327
+ /**
328
+ * Renders the stored watermark into the exact string the object's declared filter param compares
329
+ * against, with ROUND-TRIP FIDELITY as the first rule: the value normally came FROM this object's own
330
+ * watermark field, so it is already in the vendor's own representation and goes back verbatim.
331
+ *
332
+ * THE ONE CASE THAT IS NOT A ROUND TRIP: after a CLEAN FULL sync the engine deliberately replaces the
333
+ * connector's max-seen value with wall-clock `new Date().toISOString()` — an INSTANT, in UTC, carrying
334
+ * a `Z`. For a `dates_are_gmt` object that is exactly right: WooCommerce compares against the `_gmt`
335
+ * columns, and this connector already sends `dates_are_gmt=true` alongside. For a SITE-LOCAL object it
336
+ * is not: `WP_REST_Posts_Controller` date_query's `modified_after` against `post_modified`, the
337
+ * site-local column, so on a site behind UTC a UTC instant is LATER than the same moment's local wall
338
+ * clock and the next incremental would silently SKIP everything modified inside the offset window.
339
+ * Project it into the site's own wall clock using the `gmt_offset` the REST index publishes.
340
+ *
341
+ * When the site publishes no usable offset the value is passed through UNSHIFTED and the gap is said
342
+ * out loud — a guessed offset would fabricate a vendor fact, and an unshifted filter is no worse than
343
+ * the value the engine stored.
344
+ */
345
+ private watermarkFilterValue;
346
+ /**
347
+ * The site's UTC offset in MINUTES, read from the `gmt_offset` the public REST index advertises
348
+ * (hours, possibly fractional — India is 5.5, Chatham is 12.75). Null when the site does not publish
349
+ * one or the index is unreachable; callers must then decline to shift rather than assume UTC.
350
+ */
351
+ private siteGmtOffsetMinutes;
352
+ /**
353
+ * Normalises a stored watermark to the ISO-8601 form WordPress's date params accept — with ROUND-TRIP
354
+ * FIDELITY as the first rule. The watermark came FROM the object's own `modified` / `date_modified_gmt`
355
+ * field, which WordPress renders WITHOUT a timezone designator and interprets in the SITE's timezone
356
+ * (unless `dates_are_gmt` is set). Re-projecting such a value through UTC would silently shift the
357
+ * filter by the site's offset and skip or re-fetch records, so an already-ISO value is handed back
358
+ * verbatim; only a non-ISO representation is converted.
359
+ */
360
+ private toWordPressDate;
361
+ /** Max value of the object's declared watermark field across a batch — max-SEEN, never most-recent. */
362
+ private maxWatermark;
363
+ /**
364
+ * A WooCommerce consumer key/secret CANNOT read `wp/v2` — `WC_REST_Authentication::is_request_to_rest_api()`
365
+ * matches only URIs whose path after the REST prefix begins `wc/` or `wc-`. So a Woo-only connection must
366
+ * report every core object as an explicit CAPABILITY GAP rather than sync it empty and green.
367
+ */
368
+ private namespaceCredentialGuard;
369
+ /**
370
+ * Turns the two failures that are FACTS ABOUT THE SITE (rather than sync faults) into a warned
371
+ * zero-record result:
372
+ * - 404 / `rest_no_route` — the route is not registered on THIS site. WooCommerce order fulfillments
373
+ * sit behind `FeaturesUtil::feature_is_enabled('fulfillments')`, and any gated or plugin-provided
374
+ * route can be absent the same way. That is correct behaviour for the site, not a broken sync.
375
+ * - 401 / 403 with a WordPress envelope — the credential lacks the capability for this collection.
376
+ * Everything else (5xx, transport, throttling) propagates so the engine retries or fails loudly.
377
+ */
378
+ private gracefulFetchFailure;
379
+ /**
380
+ * A NESTED collection (`/wc/v3/orders/{order_id}/notes`, `/wp/v2/posts/{parent}/revisions`, …) fans out
381
+ * one request per parent, and the base resolves those parent ids from the ALREADY-SYNCED rows in the MJ
382
+ * target database. When no metadata provider is reachable — the parent object has not been mapped or
383
+ * synced yet, or the connector is running outside a database context — that is a DAG-ordering fact, not
384
+ * a connector fault: surface it as a named warning so the run artifact shows WHY the object is empty,
385
+ * instead of failing the whole sync or reporting a silent zero.
386
+ */
387
+ private parentResolutionUnavailable;
388
+ /** Clears any read failure remembered for this object's route, so a retry starts from a clean slate. */
389
+ private clearReadFailures;
390
+ /** The non-2xx read outcome recorded for this object's route during the pass just completed. */
391
+ private recordedReadFailure;
392
+ /**
393
+ * WordPress post types SOFT-delete: `DELETE` moves the row to `status=trash` and trashed rows stay
394
+ * listable via `status=trash`. This sweep captures them so a soft delete is visible to the engine.
395
+ *
396
+ * HONEST LIMITS, stated rather than papered over: there is NO deleted-records feed anywhere in wp/v2 or
397
+ * wc/v3, and a HARD delete (`?force=true`) leaves no tombstone at all — so full deletion reconciliation
398
+ * remains a declared KEY SWEEP, not detection. The sweep runs only on a fully-drained pass, only for
399
+ * objects whose metadata declares trash semantics in `wp/v2`, and only for an AUTHENTICATED connection
400
+ * (an anonymous caller can never see trash, and `status` is itself capability-gated).
401
+ */
402
+ private fetchTrashedRecords;
403
+ /** Flags any record carrying WordPress's `trash` status so the engine applies the connection's DeleteBehavior. */
404
+ private markTrashedAsDeleted;
405
+ /** True for the one declared object whose collection route WordPress never registers. */
406
+ private isGlobalStylesObject;
407
+ /**
408
+ * WordPress core registers NO collection route for global styles — only `/wp/v2/global-styles/{id}` and
409
+ * `/revisions` — so the ids cannot be listed and the object would otherwise sync zero rows while looking
410
+ * healthy (the declared metadata records this in `KnownGaps: "wp/v2 global styles enumeration"`).
411
+ *
412
+ * The ids ARE reachable through WordPress's own HAL links: each theme record advertises its user global
413
+ * styles post as `_links["wp:user-global-styles"]`. This walks that link — the source's own model, not a
414
+ * guessed URL — and fetches each id. When the link is absent the object reports an EXPLICIT warning
415
+ * rather than a silent empty success.
416
+ */
417
+ private fetchGlobalStyles;
418
+ /** Extracts global-styles ids from a theme record's HAL `_links`. */
419
+ private userGlobalStyleIDs;
420
+ /** The honest, named warning for an unenumerable global-styles object. */
421
+ private globalStylesWarning;
422
+ /**
423
+ * Builds an ExternalRecord with the FULL source record in `Fields` — never a narrow literal. The
424
+ * framework's custom-column capture diffs `keys(Fields)` against the active field maps, so anything
425
+ * dropped here (per-site `meta`, plugin-added properties) would be invisible and unrecoverable.
426
+ */
427
+ private buildRecord;
428
+ /** Reads the credential from the linked `MJ: Credentials` row, merged over the connection Configuration JSON. */
429
+ private loadCredentials;
430
+ /** Extracts the WordPress credential fields from a credential/Configuration JSON string. */
431
+ private parseCredentialJson;
432
+ /** Whether this connection opted into Woo's query-param credential fallback (hosts that strip Authorization). */
433
+ private wooQueryParamAuthEnabled;
434
+ /** Reads a trimmed string value from the connection Configuration JSON. */
435
+ private readConnectionConfigString;
436
+ /** All ACTIVE IntegrationObjects for the integration; `[]` when the engine cache is unavailable. */
437
+ protected getCachedObjects(integrationID: string): MJIntegrationObjectEntity[];
438
+ /** The Integration row itself (for the Integration-level Configuration blob); null when unavailable. */
439
+ protected tryGetIntegration(_integrationID: string): {
440
+ Configuration?: string | null;
441
+ } | null;
442
+ /** Non-throwing IntegrationObject lookup by integration + name. */
443
+ private tryGetCachedObject;
444
+ /** Non-throwing IntegrationObject lookup by name alone (used by StableOrderingKey, called early). */
445
+ private tryGetCachedObjectByName;
446
+ /** This connector's own `MJ: Integrations.ID`; null when the engine cache is not loaded yet. */
447
+ protected tryGetIntegrationID(): string | null;
448
+ /** The declared collection path for an object (`Configuration.listPath`, else its APIPath). */
449
+ private declaredListPath;
450
+ /** Typed view of an IntegrationObject's Configuration JSON. */
451
+ private objectConfig;
452
+ /** The `context=edit`-gated field names declared for whichever object owns this request path. */
453
+ private contextGatedFieldsForPath;
454
+ /** Canonical form of a route path with its captures collapsed, so `{id}` and `(?P<id>[\\d]+)` compare equal. */
455
+ private canonicalRoutePath;
456
+ /** A readable label for a discovered route, e.g. `/wp/v2/my-events` → `My Events (wp/v2)`. */
457
+ private humanLabelForRoute;
458
+ /** Builds a SourceObjectInfo for a route-index-discovered object. */
459
+ private toSourceObjectInfo;
460
+ /** Parses the `{ code, message, data:{ status } }` envelope out of a response body. */
461
+ private errorEnvelope;
462
+ /** The vendor's stable machine code from a response body, when it carries one. */
463
+ private vendorCodeOf;
464
+ /**
465
+ * Reads the HTTP status back out of a failure. `WordPressHTTPError` carries it directly; the base
466
+ * class's paginated loop raises `HTTP <status> from <url>: <body preview>`, which is our own package's
467
+ * stable message format.
468
+ */
469
+ private statusFromError;
470
+ /**
471
+ * Recovers the response body from a failure so it can be classified from the WordPress error ENVELOPE
472
+ * rather than the bare status. The base class's paginated loop raises
473
+ * `HTTP <status> from <url>: <body preview>`, so the preview is parsed back out; an HTML body (a WAF
474
+ * block) is returned as the raw string, which is exactly what the classifier keys on.
475
+ */
476
+ private bodyFromError;
477
+ /** Best-effort header extraction from an arbitrary thrown value (for ExtractRetryAfterMs). */
478
+ private headersFromUnknownError;
479
+ /** Parses a JSON string into a plain object; null for absent/invalid/non-object input. */
480
+ private parseJsonObject;
481
+ /** First present, non-empty string among the given keys. */
482
+ private firstString;
483
+ /** Coerces a header/metadata value to a positive integer, or null. */
484
+ private toPositiveInt;
485
+ /** Path component of a URL (no query), tolerant of a non-absolute input. */
486
+ private pathOf;
487
+ /** Splits a URL into `[beforeQuery, query]`. */
488
+ private splitQuery;
489
+ /** Whether the URL already carries a query param of this name (case-insensitive). */
490
+ private hasQueryParam;
491
+ /** Appends a query param (URL-encoded), preserving anything already present. */
492
+ private withQueryParam;
493
+ /** Removes every occurrence of a query param from a URL. */
494
+ private stripQueryParam;
495
+ /** Logs a capability/diagnostic warning exactly once per key, so an honest signal never becomes noise. */
496
+ private warnOnce;
497
+ /** Message text of an arbitrary thrown value. */
498
+ private errText;
499
+ }