@memberjunction/connector-wordpress 1.1.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memberjunction/connector-wordpress",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MemberJunction WordPress connector (wp/v2 + WooCommerce wc/v3).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  <?php
2
2
  /**
3
3
  * Plugin Name: MJ WP Activity Log Bridge
4
- * Description: Exposes WP Activity Log (WSAL) events over the WordPress REST API, read-only, so the MemberJunction WordPress connector can discover and sync them like any other collection. Registers no writes and stores no data of its own.
5
- * Version: 1.0.0
4
+ * Description: Exposes the WP Activity Log (WSAL) tables over the WordPress REST API, read-only, so the MemberJunction WordPress connector can discover and sync them like any other collection. Registers no writes and stores no data of its own.
5
+ * Version: 1.1.0
6
6
  * Requires PHP: 7.4
7
7
  * Author: MemberJunction
8
8
  * License: GPL-2.0-or-later
@@ -75,6 +75,49 @@ if ( ! class_exists( 'MJ_WSAL_Bridge' ) ) {
75
75
  )
76
76
  );
77
77
 
78
+ // Introspection. Reports which WP Activity Log tables this site actually HAS, with their
79
+ // columns and row counts. It exists because table presence is not a constant: the free
80
+ // plugin creates only wsal_occurrences and wsal_metadata, while sessions, notifications and
81
+ // the two report tables arrive with premium extensions — and their columns vary by version.
82
+ // Deciding what to support by GUESSING which tables exist would be a guess about someone
83
+ // else's install; this asks the site.
84
+ register_rest_route(
85
+ self::REST_NAMESPACE,
86
+ '/tables',
87
+ array(
88
+ array(
89
+ 'methods' => WP_REST_Server::READABLE,
90
+ 'callback' => array( __CLASS__, 'get_tables' ),
91
+ 'permission_callback' => array( __CLASS__, 'permission_check' ),
92
+ 'args' => array(),
93
+ ),
94
+ )
95
+ );
96
+
97
+ // The remaining WP Activity Log tables, each behind the same generic handler. Registered
98
+ // unconditionally so the route index is stable across sites; a site missing the table gets
99
+ // a 503 naming it when the route is called, which is a far clearer signal than the route
100
+ // silently not existing.
101
+ foreach ( array_keys( self::generic_tables() ) as $mj_wsal_key ) {
102
+ register_rest_route(
103
+ self::REST_NAMESPACE,
104
+ '/' . $mj_wsal_key,
105
+ array(
106
+ array(
107
+ 'methods' => WP_REST_Server::READABLE,
108
+ 'callback' => static function ( WP_REST_Request $request ) use ( $mj_wsal_key ) {
109
+ return self::get_generic( $mj_wsal_key, $request );
110
+ },
111
+ 'permission_callback' => array( __CLASS__, 'permission_check' ),
112
+ 'args' => self::get_basic_collection_params(),
113
+ ),
114
+ 'schema' => static function () use ( $mj_wsal_key ) {
115
+ return self::generic_schema( $mj_wsal_key );
116
+ },
117
+ )
118
+ );
119
+ }
120
+
78
121
  register_rest_route(
79
122
  self::REST_NAMESPACE,
80
123
  '/event-types',
@@ -630,6 +673,315 @@ if ( ! class_exists( 'MJ_WSAL_Bridge' ) ) {
630
673
  return $response;
631
674
  }
632
675
 
676
+
677
+ // ─── /tables (introspection) ──────────────────────────────────────────────
678
+
679
+ /**
680
+ * GET /mj-wsal/v1/tables
681
+ *
682
+ * Every `<base_prefix>wsal_*` table on this site, with its columns, types and row count.
683
+ *
684
+ * Discovered by PREFIX rather than from a fixed list, so a table this build has never heard of
685
+ * — a newer premium extension, a future version — still shows up instead of being invisible.
686
+ * Reports structure and counts only: no row content is read, so nothing in the activity log
687
+ * itself can leak through this route.
688
+ *
689
+ * @param WP_REST_Request $request Request.
690
+ * @return WP_REST_Response|WP_Error
691
+ */
692
+ public static function get_tables( WP_REST_Request $request ) {
693
+ global $wpdb;
694
+
695
+ $prefix = $wpdb->base_prefix . 'wsal_';
696
+ // LIKE pattern: esc_like then the wildcard, so an underscore in the prefix stays literal.
697
+ $like = $wpdb->esc_like( $prefix ) . '%';
698
+
699
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- schema name and pattern are placeholders.
700
+ $names = $wpdb->get_col(
701
+ $wpdb->prepare(
702
+ 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s ORDER BY TABLE_NAME',
703
+ DB_NAME,
704
+ $like
705
+ )
706
+ );
707
+
708
+ $known = array(
709
+ 'wsal_occurrences' => 'Activity events. Supported today as ActivityLogEvent.',
710
+ 'wsal_metadata' => 'Per-event name/value detail. Supported today, pivoted into ActivityLogEvent.meta.',
711
+ 'wsal_sessions' => 'Live logged-in sessions. Rows are DELETED on logout, so this is a snapshot, not a history.',
712
+ 'wsal_custom_notifications' => 'Notification rules. Plugin configuration, not user activity.',
713
+ 'wsal_generated_reports' => 'History of report runs. Plugin configuration, not user activity.',
714
+ 'wsal_periodic_reports' => 'Scheduled report definitions. Plugin configuration, not user activity.',
715
+ );
716
+
717
+ $out = array();
718
+ foreach ( (array) $names as $full ) {
719
+ $suffix = substr( $full, strlen( $wpdb->base_prefix ) );
720
+
721
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- values are placeholders.
722
+ $cols = $wpdb->get_results(
723
+ $wpdb->prepare(
724
+ 'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION',
725
+ DB_NAME,
726
+ $full
727
+ ),
728
+ ARRAY_A
729
+ );
730
+
731
+ // Table identifiers cannot be parameterised; this one came from information_schema for
732
+ // this exact schema and prefix, never from input, and is backtick-quoted.
733
+ $safe = str_replace( '`', '', $full );
734
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
735
+ $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$safe}`" );
736
+
737
+ $out[] = array(
738
+ 'table' => $full,
739
+ 'suffix' => $suffix,
740
+ 'rows' => $rows,
741
+ 'supported' => in_array( $suffix, array( 'wsal_occurrences', 'wsal_metadata' ), true ),
742
+ 'note' => isset( $known[ $suffix ] ) ? $known[ $suffix ] : 'Not documented by this build — discovered by prefix.',
743
+ 'columns' => array_map(
744
+ static function ( $c ) {
745
+ return array(
746
+ 'name' => $c['COLUMN_NAME'],
747
+ 'type' => $c['COLUMN_TYPE'],
748
+ 'nullable' => 'YES' === $c['IS_NULLABLE'],
749
+ 'key' => $c['COLUMN_KEY'],
750
+ );
751
+ },
752
+ (array) $cols
753
+ ),
754
+ );
755
+ }
756
+
757
+ // Name the documented tables that are ABSENT. Silence about a missing table reads as "we
758
+ // looked and it was empty", which is a different fact from "this site never had it".
759
+ $present = wp_list_pluck( $out, 'suffix' );
760
+ $missing = array();
761
+ foreach ( $known as $suffix => $note ) {
762
+ if ( ! in_array( $suffix, $present, true ) ) {
763
+ $missing[] = array( 'suffix' => $suffix, 'note' => $note );
764
+ }
765
+ }
766
+
767
+ return new WP_REST_Response(
768
+ array(
769
+ 'base_prefix' => $wpdb->base_prefix,
770
+ 'present' => $out,
771
+ 'missing' => $missing,
772
+ )
773
+ );
774
+ }
775
+
776
+
777
+ /**
778
+ * The remaining WP Activity Log tables, served by ONE generic handler.
779
+ *
780
+ * These four are not created by the free plugin — they arrive with premium extensions — so every
781
+ * route below is PRESENCE-GATED: a site without the table gets a clear 503 naming it, never a
782
+ * 500 and never an empty-but-successful page that reads as "there is no activity".
783
+ *
784
+ * `epoch` names columns holding a Unix timestamp; each gains an `<name>_at` ISO-8601 sibling,
785
+ * for the same reason ActivityLogEvent has `created_at`: a bare epoch is ambiguous to date
786
+ * parsers, and seconds read as milliseconds land every row in 1970.
787
+ *
788
+ * `json` names columns holding a JSON document in a text column. They are decoded so a consumer
789
+ * receives a real object rather than a string it has to parse a second time.
790
+ */
791
+ private static function generic_tables() {
792
+ return array(
793
+ 'sessions' => array(
794
+ 'suffix' => 'wsal_sessions',
795
+ 'pk' => 'id',
796
+ 'epoch' => array( 'created_on', 'expires_on' ),
797
+ 'json' => array(),
798
+ 'title' => 'mj_wsal_session',
799
+ 'note' => 'Live logged-in sessions. Rows are DELETED on logout, so this is a snapshot of who is signed in now, never a history.',
800
+ ),
801
+ 'notifications' => array(
802
+ 'suffix' => 'wsal_custom_notifications',
803
+ 'pk' => 'id',
804
+ 'epoch' => array( 'created_on' ),
805
+ 'json' => array( 'notification_settings', 'notification_template', 'notification_sms_template', 'notification_slack_template', 'notification_query' ),
806
+ 'title' => 'mj_wsal_notification',
807
+ 'note' => 'Notification rules configured in the plugin.',
808
+ ),
809
+ 'generated-reports' => array(
810
+ 'suffix' => 'wsal_generated_reports',
811
+ 'pk' => 'id',
812
+ 'epoch' => array( 'created_on' ),
813
+ 'json' => array( 'generated_report_filters', 'generated_report_filters_normalized', 'generated_report_header_columns' ),
814
+ 'title' => 'mj_wsal_generated_report',
815
+ 'note' => 'History of report runs.',
816
+ ),
817
+ 'periodic-reports' => array(
818
+ 'suffix' => 'wsal_periodic_reports',
819
+ 'pk' => 'id',
820
+ 'epoch' => array( 'created_on', 'last_sent' ),
821
+ 'json' => array( 'report_data' ),
822
+ 'title' => 'mj_wsal_periodic_report',
823
+ 'note' => 'Scheduled report definitions.',
824
+ ),
825
+ );
826
+ }
827
+
828
+ /**
829
+ * Columns of one table, from information_schema. Returns an empty array when the table is absent.
830
+ *
831
+ * @param string $full Fully-qualified table name.
832
+ * @return array<int,array<string,string>>
833
+ */
834
+ private static function columns_of( $full ) {
835
+ global $wpdb;
836
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- values are placeholders.
837
+ return (array) $wpdb->get_results(
838
+ $wpdb->prepare(
839
+ 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION',
840
+ DB_NAME,
841
+ $full
842
+ ),
843
+ ARRAY_A
844
+ );
845
+ }
846
+
847
+ /**
848
+ * One page of a generic table.
849
+ *
850
+ * @param string $key Key into {@see generic_tables()}.
851
+ * @param WP_REST_Request $request Request.
852
+ * @return WP_REST_Response|WP_Error
853
+ */
854
+ private static function get_generic( $key, WP_REST_Request $request ) {
855
+ global $wpdb;
856
+
857
+ $spec = self::generic_tables()[ $key ];
858
+ $full = self::table( $spec['suffix'] );
859
+ $cols = self::columns_of( $full );
860
+
861
+ if ( empty( $cols ) ) {
862
+ return new WP_Error(
863
+ 'mj_wsal_table_missing',
864
+ sprintf(
865
+ /* translators: %s: database table name */
866
+ __( 'The table "%s" does not exist on this site. It is created by a WP Activity Log premium extension; without that extension there is nothing to read here.', 'mj-wsal-bridge' ),
867
+ $full
868
+ ),
869
+ array( 'status' => 503 )
870
+ );
871
+ }
872
+
873
+ $per_page = (int) $request->get_param( 'per_page' );
874
+ $page = (int) $request->get_param( 'page' );
875
+ $pk = $spec['pk'];
876
+
877
+ // The PK comes from this file, never from input, and is validated against the real column
878
+ // list before it reaches SQL — so an ordering clause can never be attacker-controlled.
879
+ $names = wp_list_pluck( $cols, 'COLUMN_NAME' );
880
+ $order = in_array( $pk, $names, true ) ? $pk : $names[0];
881
+
882
+ $safe = str_replace( '`', '', $full );
883
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- identifier from information_schema, values are placeholders.
884
+ $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$safe}`" );
885
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
886
+ $rows = $wpdb->get_results(
887
+ $wpdb->prepare( "SELECT * FROM `{$safe}` ORDER BY `{$order}` ASC LIMIT %d OFFSET %d", $per_page, ( $page - 1 ) * $per_page ),
888
+ ARRAY_A
889
+ );
890
+
891
+ $data = array();
892
+ foreach ( (array) $rows as $row ) {
893
+ foreach ( $spec['json'] as $col ) {
894
+ if ( isset( $row[ $col ] ) && is_string( $row[ $col ] ) && '' !== $row[ $col ] ) {
895
+ $decoded = json_decode( $row[ $col ], true );
896
+ if ( JSON_ERROR_NONE === json_last_error() ) {
897
+ $row[ $col ] = $decoded;
898
+ }
899
+ }
900
+ }
901
+ foreach ( $spec['epoch'] as $col ) {
902
+ if ( isset( $row[ $col ] ) && is_numeric( $row[ $col ] ) && (float) $row[ $col ] > 0 ) {
903
+ $row[ $col . '_at' ] = self::to_iso8601( (float) $row[ $col ] );
904
+ } elseif ( array_key_exists( $col, $row ) ) {
905
+ $row[ $col . '_at' ] = null; // 0 means "never", which is not 1970.
906
+ }
907
+ }
908
+ $data[] = $row;
909
+ }
910
+
911
+ $response = new WP_REST_Response( $data );
912
+ $response->header( 'X-WP-Total', (string) $total );
913
+ $response->header( 'X-WP-TotalPages', (string) ( $per_page > 0 ? (int) ceil( $total / $per_page ) : 0 ) );
914
+
915
+ return $response;
916
+ }
917
+
918
+ /**
919
+ * JSON Schema for a generic table, derived from the LIVE columns rather than a frozen list —
920
+ * these tables' shapes vary by premium version, so a hardcoded schema would misdescribe some
921
+ * sites. Absent table: an empty property set, and the route reports 503 when actually called.
922
+ *
923
+ * @param string $key Key into {@see generic_tables()}.
924
+ * @return array
925
+ */
926
+ private static function generic_schema( $key ) {
927
+ $spec = self::generic_tables()[ $key ];
928
+ $cols = self::columns_of( self::table( $spec['suffix'] ) );
929
+ $props = array();
930
+
931
+ foreach ( $cols as $c ) {
932
+ $props[ $c['COLUMN_NAME'] ] = array(
933
+ 'description' => $c['COLUMN_NAME'] . ' (' . $c['DATA_TYPE'] . ')',
934
+ 'type' => self::json_type_for( $c['DATA_TYPE'], in_array( $c['COLUMN_NAME'], $spec['json'], true ) ),
935
+ 'readonly' => true,
936
+ );
937
+ }
938
+ foreach ( $spec['epoch'] as $col ) {
939
+ if ( isset( $props[ $col ] ) ) {
940
+ $props[ $col . '_at' ] = array(
941
+ 'description' => $col . ' as an ISO-8601 UTC datetime; null when unset.',
942
+ 'type' => array( 'string', 'null' ),
943
+ 'format' => 'date-time',
944
+ 'readonly' => true,
945
+ );
946
+ }
947
+ }
948
+
949
+ return array(
950
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
951
+ 'title' => $spec['title'],
952
+ 'type' => 'object',
953
+ 'properties' => $props,
954
+ );
955
+ }
956
+
957
+ /**
958
+ * MySQL data type to JSON Schema type.
959
+ *
960
+ * @param string $data_type MySQL DATA_TYPE.
961
+ * @param bool $is_json Whether this build decodes the column as JSON.
962
+ * @return string|array
963
+ */
964
+ private static function json_type_for( $data_type, $is_json ) {
965
+ if ( $is_json ) {
966
+ return array( 'object', 'array', 'string', 'null' );
967
+ }
968
+ switch ( strtolower( $data_type ) ) {
969
+ case 'tinyint':
970
+ case 'smallint':
971
+ case 'mediumint':
972
+ case 'int':
973
+ case 'integer':
974
+ case 'bigint':
975
+ return array( 'integer', 'null' );
976
+ case 'decimal':
977
+ case 'float':
978
+ case 'double':
979
+ return array( 'number', 'null' );
980
+ default:
981
+ return array( 'string', 'null' );
982
+ }
983
+ }
984
+
633
985
  // ─── Schemas (what OPTIONS returns, and what the connector reads fields from) ──
634
986
 
635
987
  /**
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * inspect-tables.mjs — what WP Activity Log data does THIS site actually hold?
4
+ *
5
+ * Answers the question that has to come before extending the connector: which `wsal_*` tables exist
6
+ * on a given install, what columns do they carry, and how many rows are in them. It authenticates
7
+ * exactly the way the MJ WordPress connector does — the REST root derived from the site's own
8
+ * `Link rel="https://api.w.org/"` header, then HTTP Basic with an Application Password — so a pass
9
+ * here means the connector's own auth path reaches this data, not merely that some HTTP call worked.
10
+ *
11
+ * WHY ASK RATHER THAN ASSUME
12
+ * Table presence is not a constant. The FREE plugin creates only `wsal_occurrences` and
13
+ * `wsal_metadata`; `wsal_sessions`, `wsal_custom_notifications`, `wsal_generated_reports` and
14
+ * `wsal_periodic_reports` arrive with premium extensions, and their columns vary by version.
15
+ * Declaring objects for tables a site does not have ships a catalog that silently returns nothing.
16
+ *
17
+ * Requires the MJ WSAL Bridge plugin (this directory) to be installed and activated on the site:
18
+ * WP Activity Log publishes no REST surface of its own, so without the bridge there is nothing to ask.
19
+ *
20
+ * Usage:
21
+ * WP_URL=https://example.org WP_USER=svc-mj WP_APP_PASSWORD='xxxx xxxx …' \
22
+ * node Platform/WordPress/wp-plugin/mj-wsal-bridge/test/inspect-tables.mjs
23
+ *
24
+ * Read-only: GET only. Reports structure and counts; no activity-log row content is read.
25
+ */
26
+
27
+ // People paste the URL they were looking at, which is usually wp-admin. Strip the well-known
28
+ // WordPress entry points so the site ROOT is what gets probed — the REST root is derived from the
29
+ // root's own Link header, and /wp-admin does not carry one.
30
+ const SITE = (process.env.WP_URL || '')
31
+ .trim()
32
+ .replace(/\/+$/, '')
33
+ .replace(/\/wp-admin(?:\/.*)?$/i, '')
34
+ .replace(/\/wp-login\.php.*$/i, '')
35
+ .replace(/\/wp-json\/?$/i, '')
36
+ .replace(/\/+$/, '');
37
+ const USER = process.env.WP_USER;
38
+ const PASS = process.env.WP_APP_PASSWORD;
39
+
40
+ if (!SITE || !USER || !PASS) {
41
+ console.error('Set WP_URL, WP_USER and WP_APP_PASSWORD (a WordPress Application Password).');
42
+ process.exit(2);
43
+ }
44
+
45
+ const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
46
+ const redact = (s) => String(s ?? '').split(PASS).join('«redacted»').replace(/(Basic\s+)[A-Za-z0-9+/=]+/g, '$1«redacted»');
47
+
48
+ async function get(url, withAuth = true) {
49
+ const res = await fetch(url, { headers: withAuth ? { Authorization: AUTH } : {} });
50
+ const text = await res.text();
51
+ let body = null;
52
+ try { body = JSON.parse(text); } catch { /* non-JSON */ }
53
+ return { status: res.status, headers: res.headers, text, body };
54
+ }
55
+
56
+ /** The REST root is DERIVED, exactly as the connector does it — the prefix is filterable. */
57
+ async function restRoot() {
58
+ try {
59
+ const head = await fetch(SITE, { method: 'HEAD' });
60
+ const m = /<([^>]+)>;\s*rel="https:\/\/api\.w\.org\/"/i.exec(head.headers.get('link') ?? '');
61
+ if (m) return m[1];
62
+ } catch { /* fall through */ }
63
+ for (const c of [`${SITE}/wp-json/`, `${SITE}/?rest_route=/`]) {
64
+ try {
65
+ const r = await get(c, false);
66
+ if (r.status === 200 && r.text.trim().startsWith('{')) return c;
67
+ } catch { /* next */ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ const root = await restRoot();
73
+ if (!root) {
74
+ console.error(`Could not reach the WordPress REST API at ${SITE}.`);
75
+ process.exit(1);
76
+ }
77
+ const join = (p) => (root.includes('rest_route=') ? `${root}${p.replace(/^\//, '')}` : `${root.replace(/\/$/, '')}${p}`);
78
+
79
+ console.log(`\nSite: ${SITE}`);
80
+ console.log(`REST root: ${root}`);
81
+
82
+ // The bridge must be present, and this is the same namespace check the connector's discovery makes.
83
+ // Match on the PARSED namespaces array. PHP's json_encode escapes forward slashes, so the raw body
84
+ // contains "mj-wsal\/v1" — a regex for the unescaped form silently never matches on real WordPress.
85
+ const index = await get(root, false);
86
+ const namespaces = Array.isArray(index.body?.namespaces) ? index.body.namespaces : [];
87
+ if (!namespaces.includes('mj-wsal/v1')) {
88
+ console.error('\nThe MJ WSAL Bridge plugin is NOT installed or not activated on this site.');
89
+ console.error('WP Activity Log publishes no REST routes of its own, so without the bridge there is');
90
+ console.error('nothing to inspect. Install Platform/WordPress/wp-plugin/mj-wsal-bridge and activate it.\n');
91
+ process.exit(1);
92
+ }
93
+
94
+ const res = await get(join('/mj-wsal/v1/tables'));
95
+ if (res.status === 401 || res.status === 403) {
96
+ console.error(`\nAuthentication failed (HTTP ${res.status}). The bridge requires an administrator.`);
97
+ console.error(redact(res.text).slice(0, 300));
98
+ process.exit(1);
99
+ }
100
+ if (res.status !== 200 || !res.body) {
101
+ console.error(`\nUnexpected HTTP ${res.status} from /mj-wsal/v1/tables.`);
102
+ console.error(redact(res.text).slice(0, 400));
103
+ process.exit(1);
104
+ }
105
+
106
+ const { base_prefix: prefix, present = [], missing = [] } = res.body;
107
+ console.log(`Prefix: ${prefix}\n`);
108
+
109
+ const SUPPORTED = new Set(['wsal_occurrences', 'wsal_metadata']);
110
+
111
+ console.log(`PRESENT — ${present.length} table(s)\n`);
112
+ for (const t of present) {
113
+ const mark = t.supported ? 'SUPPORTED ' : 'not yet ';
114
+ console.log(` ${mark}${t.suffix}`);
115
+ console.log(` ${t.rows.toLocaleString()} row(s), ${t.columns.length} column(s)`);
116
+ console.log(` ${t.note}`);
117
+ const cols = t.columns.map((c) => `${c.name}:${c.type}${c.key === 'PRI' ? ' [PK]' : ''}`);
118
+ // Wrap the column list rather than truncating it — the whole point is to see the real shape.
119
+ let line = ' ';
120
+ for (const c of cols) {
121
+ if (line.length + c.length > 110) { console.log(line); line = ' '; }
122
+ line += c + ' ';
123
+ }
124
+ if (line.trim()) console.log(line);
125
+ console.log('');
126
+ }
127
+
128
+ if (missing.length) {
129
+ console.log(`ABSENT — ${missing.length} documented table(s) this site does not have\n`);
130
+ for (const m of missing) console.log(` ${m.suffix}\n ${m.note}`);
131
+ console.log('');
132
+ }
133
+
134
+ const extendable = present.filter((t) => !t.supported);
135
+ console.log('─'.repeat(70));
136
+ console.log(` ${present.length} present · ${present.filter((t) => t.supported).length} already supported · ${extendable.length} could be added · ${missing.length} absent`);
137
+ if (extendable.length) {
138
+ console.log(` candidates: ${extendable.map((t) => `${t.suffix} (${t.rows.toLocaleString()} rows)`).join(', ')}`);
139
+ const empty = extendable.filter((t) => t.rows === 0).map((t) => t.suffix);
140
+ if (empty.length) console.log(` note: ${empty.join(', ')} exist but hold NO rows — supporting them would add empty objects.`);
141
+ } else {
142
+ console.log(' nothing beyond what the connector already supports is present on this site.');
143
+ }
144
+ console.log('');
145
+
146
+ // Unsupported-but-populated tables are the only ones where extending the connector buys anything.
147
+ process.exit(extendable.some((t) => t.rows > 0) ? 0 : 0);