PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.3
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / schema.php

schema.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.3, at includes/desktop-files/schema.php

659 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Files-on-the-Desktop schema.
4 *
5 * Five custom tables back the system:
6 *
7 * - `_desktop_mode_file_placements` — every (user, parent_folder,
8 * type, ref, x, y, sort) tuple. Indexed on `(owner_id, parent_id)`
9 * and `(file_type, file_ref)` for two queries we run constantly:
10 * "show me what's on user X's folder Y" and "where else does
11 * this entity appear" (used when an entity is deleted to clean
12 * up dangling placements).
13 *
14 * - `_desktop_mode_folders` — folder rows. Owned by one user, with
15 * a share mode (`private` | `users` | `roles` | `all`) and a
16 * JSON `share_meta` column carrying user/role lists. Folders
17 * live independently of where they're placed (a folder placed
18 * on user A's desktop root can also appear inside user B's
19 * "Projects" folder via a placement).
20 *
21 * - `_desktop_mode_file_tombstones` — id ledger of removals so the
22 * Heartbeat delta sync (Phase 6) can tell connected clients
23 * "this placement / folder is gone." Pruned daily.
24 *
25 * - `_desktop_mode_folder_shares` — one row per (folder, principal)
26 * grant: user- or role-principal, `read` | `write` capability,
27 * `pending` | `accepted` | `denied` state.
28 *
29 * - `_desktop_mode_share_user_decisions` — per-user opt-ins for
30 * role-principal shares (each member of the role accepts or
31 * denies individually; the shares row itself stays `pending`).
32 *
33 * dbDelta is the only safe path for schema migrations against the
34 * Core tables environment — Phase 6 re-uses this file by bumping
35 * `DESKTOP_MODE_FILES_SCHEMA_VERSION` and adding columns.
36 *
37 * @package WPDesktopMode
38 * @since 0.9.0
39 */
40
41 defined( 'ABSPATH' ) || exit;
42
43 define( 'DESKTOP_MODE_FILES_SCHEMA_VERSION', '12' );
44 define( 'DESKTOP_MODE_FILES_SCHEMA_OPTION', 'desktop_mode_files_schema_version' );
45
46 /**
47 * Returns the per-table names with the active prefix applied.
48 *
49 * @since 0.9.0
50 *
51 * @return array{ placements: string, folders: string, tombstones: string, shares: string, decisions: string }
52 */
53 function desktop_mode_files_table_names() {
54 global $wpdb;
55 return array(
56 'placements' => $wpdb->prefix . 'desktop_mode_file_placements',
57 'folders' => $wpdb->prefix . 'desktop_mode_folders',
58 'tombstones' => $wpdb->prefix . 'desktop_mode_file_tombstones',
59 'shares' => $wpdb->prefix . 'desktop_mode_folder_shares',
60 'decisions' => $wpdb->prefix . 'desktop_mode_share_user_decisions',
61 );
62 }
63
64 /**
65 * Idempotent `dbDelta` call. Hooked on plugin activation and on
66 * `admin_init` (gated by a version-option mismatch) so a manual
67 * file copy install still ends up with the tables.
68 *
69 * @since 0.9.0
70 */
71 function desktop_mode_files_install_schema() {
72 global $wpdb;
73
74 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
75
76 $tables = desktop_mode_files_table_names();
77 $charset_collate = $wpdb->get_charset_collate();
78
79 // Schema v2 (since 0.8.0): adds trash columns to both placements
80 // and folders so deleted shortcuts and folders land in the
81 // recycle bin instead of vanishing. `trashed_at_ms` is the
82 // epoch-ms timestamp of the trash event (NULL = active).
83 // `trashed_by` records the user that fired it (for permission
84 // checks on restore). `trashed_via_folder` on placements is the
85 // id of the folder whose trash cascaded the placement, so a
86 // folder restore knows exactly which children to bring back —
87 // precise round-trip with no time-window heuristics.
88 $placements_sql = "CREATE TABLE {$tables['placements']} (
89 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
90 owner_id BIGINT UNSIGNED NOT NULL,
91 parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
92 file_type VARCHAR(64) NOT NULL,
93 file_ref VARCHAR(255) NOT NULL DEFAULT '',
94 x INT NOT NULL DEFAULT 0,
95 y INT NOT NULL DEFAULT 0,
96 sort_order INT NOT NULL DEFAULT 0,
97 updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
98 meta LONGTEXT NULL,
99 trashed_at_ms BIGINT UNSIGNED NULL,
100 trashed_by BIGINT UNSIGNED NULL,
101 trashed_via_folder BIGINT UNSIGNED NULL,
102 trashed_meta LONGTEXT NULL,
103 PRIMARY KEY (id),
104 KEY owner_parent (owner_id, parent_id),
105 KEY type_ref (file_type, file_ref),
106 KEY updated_at_ms (updated_at_ms),
107 KEY trashed_at_ms (trashed_at_ms)
108 ) $charset_collate;";
109
110 $folders_sql = "CREATE TABLE {$tables['folders']} (
111 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
112 owner_id BIGINT UNSIGNED NOT NULL,
113 name VARCHAR(255) NOT NULL DEFAULT '',
114 share_mode VARCHAR(16) NOT NULL DEFAULT 'private',
115 share_meta LONGTEXT NULL,
116 updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
117 trashed_at_ms BIGINT UNSIGNED NULL,
118 trashed_by BIGINT UNSIGNED NULL,
119 trashed_meta LONGTEXT NULL,
120 PRIMARY KEY (id),
121 KEY owner_id (owner_id),
122 KEY share_mode (share_mode),
123 KEY updated_at_ms (updated_at_ms),
124 KEY trashed_at_ms (trashed_at_ms)
125 ) $charset_collate;";
126
127 $tombstones_sql = "CREATE TABLE {$tables['tombstones']} (
128 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
129 kind VARCHAR(16) NOT NULL,
130 ref_id BIGINT UNSIGNED NOT NULL,
131 removed_at_ms BIGINT UNSIGNED NOT NULL,
132 PRIMARY KEY (id),
133 KEY kind_removed (kind, removed_at_ms)
134 ) $charset_collate;";
135
136 // Schema v9 — per-principal grants (`folder_shares` table) +
137 // per-user opt-in decisions (`share_user_decisions` table).
138 // `share_meta` on the folders row stays as a diagnostic-only
139 // column; visibility is computed entirely from the shares
140 // table.
141 //
142 // Shares + decisions are intentionally NOT routed through
143 // dbDelta. Their `ensure_*_table()` helpers below are the sole
144 // creators. dbDelta uses `DESCRIBE` to detect existing tables
145 // and falls back to a bare `CREATE TABLE` (no IF NOT EXISTS)
146 // when DESCRIBE returns empty — under certain MySQL / MariaDB
147 // configurations (case-folding mismatches, transient connection
148 // states, the `lower_case_table_names` quirk on case-sensitive
149 // filesystems) DESCRIBE can fail on a table that physically
150 // exists, and dbDelta then issues a CREATE that blows up with
151 // "Table … already exists" (MySQL error 1050). The `ensure_*`
152 // helpers use INFORMATION_SCHEMA + explicit `CREATE TABLE IF NOT
153 // EXISTS`, which is bullet-proof; v9 → vN column additions are
154 // handled by `ALTER TABLE … ADD COLUMN` inside the same helper.
155
156 // v11: rename `placements.user_id` to `placements.owner_id` so
157 // the placements + folders tables use the same column name for
158 // the same concept. Must run BEFORE dbDelta — once the
159 // `$placements_sql` definition switches from `user_id` to
160 // `owner_id`, dbDelta on an existing v≤10 install would see
161 // `owner_id` as a missing column and ADD it (leaving the old
162 // `user_id` in place + the new `owner_id` NULL). Running the
163 // CHANGE COLUMN first means dbDelta sees the table already
164 // matches the desired shape.
165 desktop_mode_files_rename_user_id_to_owner_id();
166
167 dbDelta( $placements_sql );
168 dbDelta( $folders_sql );
169 dbDelta( $tombstones_sql );
170
171 // dbDelta has well-documented quirks with `NULL`-only columns
172 // (no DEFAULT) — under some MySQL/MariaDB combos it silently
173 // skips the ADD COLUMN. Verify the v2 trash columns are
174 // physically present and ALTER them in directly when not.
175 desktop_mode_files_ensure_trash_columns();
176
177 // v4: clean up duplicate placements created by sessions that
178 // hit the auto-orphan-placer while the v2 trash columns were
179 // missing — every `WHERE trashed_at_ms IS NULL` precheck
180 // returned empty, so each pageload re-inserted every
181 // registered shortcut. Collapse runs of identical
182 // `(owner_id, parent_id, file_type, file_ref)` rows down to
183 // the lowest id.
184 desktop_mode_files_dedupe_placements();
185
186 // v5: enforce uniqueness at the DB level so a future bug
187 // (or a racing pair of REST requests) can never re-create
188 // the duplicate shortcuts again. Must run AFTER dedupe —
189 // adding a unique key against duplicate rows would fail.
190 desktop_mode_files_ensure_unique_placement_index();
191
192 // v9: belt-and-suspenders existence check for the shares +
193 // decisions tables. The folder-sharing feature is the
194 // canonical source of truth for "who can see this folder" —
195 // the `share_meta` JSON column on the folders table remains
196 // for diagnostic purposes only and is not consulted by the
197 // visibility resolver.
198 desktop_mode_files_ensure_shares_table();
199 desktop_mode_files_ensure_decisions_table();
200
201 // v10: `updated_by` column on placements so the If-Match 409
202 // conflict toast names the SESSION that actually won the race,
203 // not just whoever currently owns the row. Critical for the
204 // shared-write scenario where User B (writer recipient) moves a
205 // placement and User C gets the conflict — without this column,
206 // the toast would blame User A (owner of the row).
207 desktop_mode_files_ensure_updated_by_column();
208
209 update_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, DESKTOP_MODE_FILES_SCHEMA_VERSION );
210
211 /**
212 * Fires after the files schema is installed / migrated.
213 *
214 * @since 0.9.0
215 *
216 * @param string $version The version that was installed.
217 */
218 do_action( 'desktop_mode_files_schema_installed', DESKTOP_MODE_FILES_SCHEMA_VERSION );
219 }
220
221 /**
222 * Belt-and-suspenders verifier for the v2 trash columns. Reads
223 * `INFORMATION_SCHEMA.COLUMNS` for the placements + folders tables
224 * and `ALTER`s in any column dbDelta missed. Idempotent: each
225 * `ALTER` only fires when the column is not already there.
226 *
227 * @since 0.8.0
228 * @internal
229 */
230 function desktop_mode_files_ensure_trash_columns() {
231 global $wpdb;
232 $tables = desktop_mode_files_table_names();
233
234 // Two-worker race protection: between the INFORMATION_SCHEMA
235 // check and the ALTER, a concurrent worker (cron + admin-init,
236 // REST + heartbeat) can run the same check, see the column
237 // missing, and both fire ALTER. The second hits MySQL error
238 // 1060 ("Duplicate column"). Suppressing wpdb errors around
239 // the ALTER swallows that benign log line. The column ends up
240 // present either way — we re-verify with a second
241 // INFORMATION_SCHEMA query and only surface an error when the
242 // column is genuinely missing after the attempt.
243 $ensure = static function ( $table, $column, $definition ) use ( $wpdb ) {
244 $col_exists = static function () use ( $wpdb, $table, $column ) {
245 return (int) $wpdb->get_var(
246 $wpdb->prepare(
247 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
248 WHERE TABLE_SCHEMA = DATABASE()
249 AND TABLE_NAME = %s
250 AND COLUMN_NAME = %s",
251 $table,
252 $column
253 )
254 );
255 };
256 if ( $col_exists() > 0 ) {
257 return;
258 }
259 $prev_suppress = $wpdb->suppress_errors( true );
260 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
261 $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}" );
262 $wpdb->suppress_errors( $prev_suppress );
263 // Belt-and-suspenders: if the column STILL isn't there
264 // after the ALTER (real schema error, not a race), retry
265 // once unsuppressed so WP_DEBUG users see the cause.
266 if ( $col_exists() === 0 ) {
267 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
268 $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}" );
269 }
270 };
271
272 $ensure( $tables['placements'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
273 $ensure( $tables['placements'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
274 $ensure( $tables['placements'], 'trashed_via_folder', 'BIGINT UNSIGNED NULL' );
275 // v6: ancestry snapshot — JSON capturing every folder in the
276 // parent chain at trash time so a restore can resurrect the
277 // chain even when a folder was hard-deleted in the meantime.
278 $ensure( $tables['placements'], 'trashed_meta', 'LONGTEXT NULL' );
279 $ensure( $tables['folders'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
280 $ensure( $tables['folders'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
281 $ensure( $tables['folders'], 'trashed_meta', 'LONGTEXT NULL' );
282 }
283
284 /**
285 * Collapse duplicate `(owner_id, parent_id, file_type, file_ref)`
286 * placement rows down to the lowest id, deleting the rest. The
287 * DELETE is restricted to `file_type IN ('shortcut','folder')` —
288 * the types where legacy duplicates were actually observed. Note
289 * that the v5 `placement_unique` index added right after this
290 * covers EVERY file type, so duplicates of any type within the
291 * same (owner, parent) are disallowed at the DB level; if legacy
292 * duplicates of another type exist, the (error-suppressed)
293 * `ADD UNIQUE` in
294 * `desktop_mode_files_ensure_unique_placement_index()` will fail
295 * and leave the index absent until those rows are cleaned up.
296 *
297 * @since 0.8.0
298 * @internal
299 */
300 function desktop_mode_files_dedupe_placements() {
301 global $wpdb;
302 $tables = desktop_mode_files_table_names();
303 $tbl = $tables['placements'];
304
305 // Once the unique index exists, MySQL prevents duplicate
306 // inserts at the DB level — dedupe is a no-op and the
307 // table-scanning DELETE is pure waste on every install_schema
308 // call. Skip in that case so the cost is paid exactly once,
309 // during the v4 → v5 migration.
310 $has_unique = (int) $wpdb->get_var(
311 $wpdb->prepare(
312 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
313 WHERE TABLE_SCHEMA = DATABASE()
314 AND TABLE_NAME = %s
315 AND INDEX_NAME = %s",
316 $tbl,
317 'placement_unique'
318 )
319 );
320 if ( $has_unique > 0 ) {
321 return;
322 }
323
324 // Self-join keeps the minimum id per (user_id, parent_id,
325 // file_type, file_ref) and deletes everything else. Restricted
326 // to shortcut + folder placements, where duplicates are never
327 // intentional.
328 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
329 $wpdb->query(
330 "DELETE p1 FROM `{$tbl}` p1
331 INNER JOIN `{$tbl}` p2
332 ON p1.owner_id = p2.owner_id
333 AND p1.parent_id = p2.parent_id
334 AND p1.file_type = p2.file_type
335 AND p1.file_ref = p2.file_ref
336 AND p1.id > p2.id
337 WHERE p1.file_type IN ( 'shortcut', 'folder' )"
338 );
339 }
340
341 /**
342 * Add a UNIQUE index on
343 * `(owner_id, parent_id, file_type, file_ref)` to make duplicate
344 * placements physically impossible. Skipped when the index is
345 * already present.
346 *
347 * Note: `file_ref` is `VARCHAR(255)` — combined with the three
348 * other columns this fits comfortably under MySQL's 3072-byte
349 * InnoDB index-key limit on `utf8mb4`.
350 *
351 * @since 0.8.0
352 * @internal
353 */
354 function desktop_mode_files_ensure_unique_placement_index() {
355 global $wpdb;
356 $tables = desktop_mode_files_table_names();
357 $tbl = $tables['placements'];
358
359 $exists = (int) $wpdb->get_var(
360 $wpdb->prepare(
361 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
362 WHERE TABLE_SCHEMA = DATABASE()
363 AND TABLE_NAME = %s
364 AND INDEX_NAME = %s",
365 $tbl,
366 'placement_unique'
367 )
368 );
369 if ( 0 === $exists ) {
370 // Suppress errors on the ADD KEY in case a concurrent
371 // worker won the same race (MySQL 1061: "Duplicate key
372 // name"). The index ends up present either way; the
373 // check-then-add pattern is benign under contention.
374 $prev_suppress = $wpdb->suppress_errors( true );
375 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
376 $wpdb->query(
377 "ALTER TABLE `{$tbl}`
378 ADD UNIQUE KEY `placement_unique`
379 (owner_id, parent_id, file_type, file_ref)"
380 );
381 $wpdb->suppress_errors( $prev_suppress );
382 }
383 }
384
385 /**
386 * Add the v10 `updated_by` column to the placements table.
387 *
388 * Tracks which user last mutated the row (created, moved, restored).
389 * Used by `desktop_mode_files_check_if_match()` so the If-Match 409
390 * conflict toast attributes the change to the SESSION that won the
391 * race rather than to the row's static owner — critical when a
392 * writer recipient of a shared folder rearranges placements and
393 * another viewer hits a stale `If-Match`.
394 *
395 * NULL on legacy rows (pre-v10). The conflict resolver falls back
396 * to `owner_id` when this column is NULL, matching the old behavior.
397 *
398 * @since 0.8.5 (schema v10)
399 * @internal
400 */
401 function desktop_mode_files_ensure_updated_by_column() {
402 global $wpdb;
403 $tables = desktop_mode_files_table_names();
404 $tbl = $tables['placements'];
405 $exists = (int) $wpdb->get_var(
406 $wpdb->prepare(
407 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
408 WHERE TABLE_SCHEMA = DATABASE()
409 AND TABLE_NAME = %s
410 AND COLUMN_NAME = %s",
411 $tbl,
412 'updated_by'
413 )
414 );
415 if ( 0 === $exists ) {
416 // Suppress errors so a concurrent worker that already won
417 // the same race doesn't fire a benign MySQL 1060
418 // ("Duplicate column"). The column ends up present either
419 // way.
420 $prev_suppress = $wpdb->suppress_errors( true );
421 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
422 $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `updated_by` BIGINT UNSIGNED NULL AFTER `owner_id`" );
423 $wpdb->suppress_errors( $prev_suppress );
424 }
425 }
426
427 /**
428 * Rename `placements.user_id` to `placements.owner_id` and the
429 * matching `user_parent` index to `owner_parent`. The two
430 * desktop-mode-owned tables historically used different names for
431 * the same "row's owner" concept — folders carried `owner_id` from
432 * day one, placements carried `user_id`. v11 unifies them so SQL
433 * and PHP read identically across the two tables.
434 *
435 * Idempotent: skips when the table doesn't exist (fresh install —
436 * dbDelta runs after this and creates the table with the new
437 * column name directly) and when the column is already renamed.
438 *
439 * Must run BEFORE `dbDelta( $placements_sql )` in
440 * `desktop_mode_files_install_schema()` — dbDelta does NOT rename
441 * columns, so against a v≤10 table whose definition says
442 * `owner_id` it would ADD a new `owner_id` column and leave the
443 * stale `user_id` in place. Running the CHANGE COLUMN first puts
444 * the table in the desired shape so dbDelta sees no diff.
445 *
446 * Schema version was bumped from 11 to 12 so any install that
447 * stamped 11 but never actually renamed the column (a silent
448 * partial run during dev iteration) gets a clean retry. The
449 * function is idempotent — early-returns when `user_id` is absent,
450 * so healthy v11 installs see a cheap no-op on the retry.
451 *
452 * @since 0.8.9 (schema v11, redelivered at v12)
453 * @internal
454 */
455 function desktop_mode_files_rename_user_id_to_owner_id() {
456 global $wpdb;
457 $tables = desktop_mode_files_table_names();
458 $tbl = $tables['placements'];
459
460 // Fresh install — the table doesn't exist yet; dbDelta creates
461 // it with `owner_id` directly. Nothing to migrate.
462 $table_exists = (int) $wpdb->get_var(
463 $wpdb->prepare(
464 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
465 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
466 $tbl
467 )
468 );
469 if ( 0 === $table_exists ) {
470 return;
471 }
472
473 $has_user_id = (int) $wpdb->get_var(
474 $wpdb->prepare(
475 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
476 WHERE TABLE_SCHEMA = DATABASE()
477 AND TABLE_NAME = %s
478 AND COLUMN_NAME = 'user_id'",
479 $tbl
480 )
481 );
482 if ( 0 === $has_user_id ) {
483 return; // Already renamed (or column was never there — fresh install via test factory).
484 }
485
486 // DELIBERATELY NOT suppressing errors here. An earlier draft
487 // wrapped the ALTER in `suppress_errors( true )` "in case of
488 // concurrent migration race" — there's no realistic race for
489 // this ALTER (schema migrations run inside one request) and the
490 // suppression hid a real failure mode on at least one local
491 // install: the option got stamped at v11 but the CHANGE COLUMN
492 // never landed, so every later placements query 500'd silently.
493 // Let any wpdb error surface to `debug.log` so the failure is
494 // visible the next time this code runs.
495 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
496 $wpdb->query(
497 "ALTER TABLE `{$tbl}` CHANGE COLUMN `user_id` `owner_id` BIGINT UNSIGNED NOT NULL"
498 );
499
500 // MySQL/MariaDB auto-update index column references on CHANGE
501 // COLUMN, but the index NAMES are baked in. Rename them too so
502 // `EXPLAIN`/`SHOW INDEX` output reads consistently with the
503 // column.
504 $has_user_parent = (int) $wpdb->get_var(
505 $wpdb->prepare(
506 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
507 WHERE TABLE_SCHEMA = DATABASE()
508 AND TABLE_NAME = %s
509 AND INDEX_NAME = 'user_parent'",
510 $tbl
511 )
512 );
513 if ( 0 < $has_user_parent ) {
514 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
515 $wpdb->query( "ALTER TABLE `{$tbl}` RENAME INDEX `user_parent` TO `owner_parent`" );
516 }
517 }
518
519 /**
520 * Belt-and-suspenders verifier for the v8 `shares` table. `dbDelta`
521 * has known edge cases where a brand-new table with `UNIQUE KEY`
522 * declarations on a non-`utf8mb4` collation gets silently skipped
523 * on some MySQL/MariaDB combos; we mirror the trash-columns
524 * pattern and `CREATE TABLE IF NOT EXISTS` the row explicitly.
525 *
526 * @since 0.8.5
527 * @internal
528 */
529 function desktop_mode_files_ensure_shares_table() {
530 global $wpdb;
531 $tables = desktop_mode_files_table_names();
532 $charset_collate = $wpdb->get_charset_collate();
533 $tbl = $tables['shares'];
534
535 $exists = (int) $wpdb->get_var(
536 $wpdb->prepare(
537 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
538 WHERE TABLE_SCHEMA = DATABASE()
539 AND TABLE_NAME = %s",
540 $tbl
541 )
542 );
543 if ( 0 === $exists ) {
544 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
545 $wpdb->query(
546 "CREATE TABLE IF NOT EXISTS `{$tbl}` (
547 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
548 target_type VARCHAR(32) NOT NULL DEFAULT 'folder',
549 folder_id BIGINT UNSIGNED NOT NULL,
550 principal_type VARCHAR(16) NOT NULL,
551 principal_ref VARCHAR(191) NOT NULL,
552 capability VARCHAR(8) NOT NULL DEFAULT 'read',
553 state VARCHAR(16) NOT NULL DEFAULT 'pending',
554 invited_by BIGINT UNSIGNED NOT NULL,
555 invited_at_ms BIGINT UNSIGNED NOT NULL,
556 decided_at_ms BIGINT UNSIGNED NULL,
557 PRIMARY KEY (id),
558 UNIQUE KEY uniq_principal (target_type, folder_id, principal_type, principal_ref),
559 KEY by_principal (principal_type, principal_ref, state),
560 KEY target (target_type, folder_id)
561 ) $charset_collate"
562 );
563 } else {
564 // Existing table — make sure `target_type` is there for
565 // installs that ran a pre-target_type build of v8.
566 $has_col = (int) $wpdb->get_var(
567 $wpdb->prepare(
568 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
569 WHERE TABLE_SCHEMA = DATABASE()
570 AND TABLE_NAME = %s
571 AND COLUMN_NAME = %s",
572 $tbl,
573 'target_type'
574 )
575 );
576 if ( 0 === $has_col ) {
577 // Same TOCTOU rationale as the other ensure_* helpers
578 // — concurrent worker that already added the column
579 // surfaces a benign MySQL 1060 we should swallow.
580 $prev_suppress = $wpdb->suppress_errors( true );
581 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
582 $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `target_type` VARCHAR(32) NOT NULL DEFAULT 'folder' AFTER `id`" );
583 $wpdb->suppress_errors( $prev_suppress );
584 }
585 }
586 }
587
588 /**
589 * Belt-and-suspenders verifier for the decisions table.
590 *
591 * @since 0.8.5
592 * @internal
593 */
594 function desktop_mode_files_ensure_decisions_table() {
595 global $wpdb;
596 $tables = desktop_mode_files_table_names();
597 $charset_collate = $wpdb->get_charset_collate();
598 $tbl = $tables['decisions'];
599
600 $exists = (int) $wpdb->get_var(
601 $wpdb->prepare(
602 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
603 WHERE TABLE_SCHEMA = DATABASE()
604 AND TABLE_NAME = %s",
605 $tbl
606 )
607 );
608 if ( 0 === $exists ) {
609 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
610 $wpdb->query(
611 "CREATE TABLE IF NOT EXISTS `{$tbl}` (
612 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
613 share_id BIGINT UNSIGNED NOT NULL,
614 user_id BIGINT UNSIGNED NOT NULL,
615 state VARCHAR(16) NOT NULL DEFAULT 'pending',
616 decided_at_ms BIGINT UNSIGNED NOT NULL,
617 PRIMARY KEY (id),
618 UNIQUE KEY uniq_share_user (share_id, user_id),
619 KEY by_user (user_id, state)
620 ) $charset_collate"
621 );
622 }
623 }
624
625 /**
626 * Lazy migrator — runs on `admin_init` when the stored schema
627 * version doesn't match the constant. Idempotent: `dbDelta`
628 * itself is a no-op when the table already matches.
629 *
630 * @since 0.9.0
631 */
632 function desktop_mode_files_maybe_install_schema() {
633 $installed = get_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, '' );
634 if ( $installed === DESKTOP_MODE_FILES_SCHEMA_VERSION ) {
635 return;
636 }
637 desktop_mode_files_install_schema();
638 }
639 add_action( 'admin_init', 'desktop_mode_files_maybe_install_schema' );
640 // REST + front-end requests never fire `admin_init` — without these
641 // hooks a session that hits a REST endpoint before any admin page
642 // load would query the placements / folders tables before the v2
643 // trash columns exist, throwing wpdb errors and blanking the desktop.
644 add_action( 'rest_api_init', 'desktop_mode_files_maybe_install_schema' );
645 add_action( 'init', 'desktop_mode_files_maybe_install_schema', 1 );
646 register_activation_hook( DESKTOP_MODE_FILE, 'desktop_mode_files_install_schema' );
647
648 /**
649 * Current epoch-ms timestamp. Centralized so the store and the
650 * tombstone writer stay in lock-step.
651 *
652 * @since 0.9.0
653 *
654 * @return int
655 */
656 function desktop_mode_files_now_ms() {
657 return (int) round( microtime( true ) * 1000 );
658 }
659