PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/desktop-files/schema.php +236 -99 0.8.71.1.10 View file →
@@ -1,12 +1,12 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — Files-on-the-Desktop schema.
3 + * OpenStation — Files-on-the-Desktop schema.
4 4 *
5 - * Three custom tables back the system:
5 + * Five custom tables back the system:
6 6 *
7 7 * - `_desktop_mode_file_placements` — every (user, parent_folder,
8 - * type, ref, x, y, sort) tuple. Indexed on `(user_id, parent_id)`
8 + * type, ref, x, y, sort) tuple. Indexed on `(owner_id, parent_id)`
9 9 * and `(file_type, file_ref)` for two queries we run constantly:
10 10 * "show me what's on user X's folder Y" and "where else does
11 11 * this entity appear" (used when an entity is deleted to clean
12 12 * up dangling placements).
@@ -21,36 +21,55 @@
21 21 * - `_desktop_mode_file_tombstones` — id ledger of removals so the
22 22 * Heartbeat delta sync (Phase 6) can tell connected clients
23 23 * "this placement / folder is gone." Pruned daily.
24 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 + *
25 33 * dbDelta is the only safe path for schema migrations against the
26 34 * Core tables environment — Phase 6 re-uses this file by bumping
27 - * `DESKTOP_MODE_FILES_SCHEMA_VERSION` and adding columns.
35 + * `OPENSTATION_FILES_SCHEMA_VERSION` and adding columns.
28 36 *
29 - * @package WPDesktopMode
30 - * @since 0.9.0
37 + * @package OpenStation
31 38 */
32 39
33 40 defined( 'ABSPATH' ) || exit;
34 41
35 -define( 'DESKTOP_MODE_FILES_SCHEMA_VERSION', '10' );
36 -define( 'DESKTOP_MODE_FILES_SCHEMA_OPTION', 'desktop_mode_files_schema_version' );
42 +define( 'OPENSTATION_FILES_SCHEMA_VERSION', '13' );
43 +/**
44 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
45 + * persisted or externally-visible identifier, so renaming it would
46 + * orphan data already written by live installs (or break a live
47 + * URL). The mismatch between this constant's name and its value is
48 + * deliberate — it is NOT a half-finished rename.
49 + */
50 +define( 'OPENSTATION_FILES_SCHEMA_OPTION', 'desktop_mode_files_schema_version' );
37 51
38 52 /**
39 53 * Returns the per-table names with the active prefix applied.
40 54 *
41 - * @since 0.9.0
55 + * The `desktop_mode_` segment is the pre-rebrand spelling and is frozen:
56 + * these are real tables holding real rows on live installs. Renaming
57 + * them silently creates a second, empty set and every desktop icon,
58 + * folder and uploaded file disappears. The mismatch against the
59 + * `openstation_*` function name is deliberate.
42 60 *
43 - * @return array{ placements: string, folders: string, tombstones: string }
61 + * @return array{ placements: string, folders: string, tombstones: string, shares: string, decisions: string }
44 62 */
45 -function desktop_mode_files_table_names() {
63 +function openstation_files_table_names() {
46 64 global $wpdb;
47 65 return array(
48 - 'placements' => $wpdb->prefix . 'desktop_mode_file_placements',
49 - 'folders' => $wpdb->prefix . 'desktop_mode_folders',
50 - 'tombstones' => $wpdb->prefix . 'desktop_mode_file_tombstones',
51 - 'shares' => $wpdb->prefix . 'desktop_mode_folder_shares',
52 - 'decisions' => $wpdb->prefix . 'desktop_mode_share_user_decisions',
66 + 'placements' => $wpdb->prefix . 'desktop_mode_file_placements',
67 + 'folders' => $wpdb->prefix . 'desktop_mode_folders',
68 + 'tombstones' => $wpdb->prefix . 'desktop_mode_file_tombstones',
69 + 'shares' => $wpdb->prefix . 'desktop_mode_folder_shares',
70 + 'decisions' => $wpdb->prefix . 'desktop_mode_share_user_decisions',
71 + 'stored_files' => $wpdb->prefix . 'desktop_mode_stored_files',
53 72 );
54 73 }
55 74
56 75 /**
@@ -56,20 +75,18 @@
56 75 /**
57 76 * Idempotent `dbDelta` call. Hooked on plugin activation and on
58 77 * `admin_init` (gated by a version-option mismatch) so a manual
59 78 * file copy install still ends up with the tables.
60 - *
61 - * @since 0.9.0
62 79 */
63 -function desktop_mode_files_install_schema() {
80 +function openstation_files_install_schema() {
64 81 global $wpdb;
65 82
66 83 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
67 84
68 - $tables = desktop_mode_files_table_names();
69 - $charset_collate = $wpdb->get_charset_collate();
85 + $tables = openstation_files_table_names();
86 + $charset_collate = $wpdb->get_charset_collate();
70 87
71 - // Schema v2 (since 0.8.0): adds trash columns to both placements
88 + // Schema v2: adds trash columns to both placements
72 89 // and folders so deleted shortcuts and folders land in the
73 90 // recycle bin instead of vanishing. `trashed_at_ms` is the
74 91 // epoch-ms timestamp of the trash event (NULL = active).
75 92 // `trashed_by` records the user that fired it (for permission
@@ -78,9 +95,9 @@
78 95 // folder restore knows exactly which children to bring back —
79 96 // precise round-trip with no time-window heuristics.
80 97 $placements_sql = "CREATE TABLE {$tables['placements']} (
81 98 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
82 - user_id BIGINT UNSIGNED NOT NULL,
99 + owner_id BIGINT UNSIGNED NOT NULL,
83 100 parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
84 101 file_type VARCHAR(64) NOT NULL,
85 102 file_ref VARCHAR(255) NOT NULL DEFAULT '',
86 103 x INT NOT NULL DEFAULT 0,
@@ -92,9 +109,9 @@
92 109 trashed_by BIGINT UNSIGNED NULL,
93 110 trashed_via_folder BIGINT UNSIGNED NULL,
94 111 trashed_meta LONGTEXT NULL,
95 112 PRIMARY KEY (id),
96 - KEY user_parent (user_id, parent_id),
113 + KEY owner_parent (owner_id, parent_id),
97 114 KEY type_ref (file_type, file_ref),
98 115 KEY updated_at_ms (updated_at_ms),
99 116 KEY trashed_at_ms (trashed_at_ms)
100 117 ) $charset_collate;";
@@ -124,8 +141,32 @@
124 141 PRIMARY KEY (id),
125 142 KEY kind_removed (kind, removed_at_ms)
126 143 ) $charset_collate;";
127 144
145 + // Schema v13: real per-user file storage. One row
146 + // per uploaded file; the bytes live flat on disk under
147 + // `uploads/desktop-mode-files/<owner_id>/<disk_name>` with a
148 + // server-generated extensionless `disk_name` (UUID) — hierarchy,
149 + // naming, and sharing are entirely DB concerns (folders +
150 + // placements + shares tables), the disk is a dumb blob store.
151 + // No UNIQUE keys beyond the PK on purpose: dbDelta's UNIQUE-KEY
152 + // quirks (see the shares-table comment below) don't apply, and
153 + // disk_name uniqueness is guaranteed by the UUID generator plus
154 + // a collision check at write time.
155 + $stored_files_sql = "CREATE TABLE {$tables['stored_files']} (
156 + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
157 + owner_id BIGINT UNSIGNED NOT NULL,
158 + display_name VARCHAR(255) NOT NULL DEFAULT '',
159 + disk_name VARCHAR(64) NOT NULL DEFAULT '',
160 + size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
161 + mime VARCHAR(100) NOT NULL DEFAULT '',
162 + created_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
163 + updated_at_ms BIGINT UNSIGNED NOT NULL DEFAULT 0,
164 + PRIMARY KEY (id),
165 + KEY owner_id (owner_id),
166 + KEY disk_name (disk_name)
167 + ) $charset_collate;";
168 +
128 169 // Schema v9 — per-principal grants (`folder_shares` table) +
129 170 // per-user opt-in decisions (`share_user_decisions` table).
130 171 // `share_meta` on the folders row stays as a diagnostic-only
131 172 // column; visibility is computed entirely from the shares
@@ -144,17 +185,29 @@
144 185 // helpers use INFORMATION_SCHEMA + explicit `CREATE TABLE IF NOT
145 186 // EXISTS`, which is bullet-proof; v9 → vN column additions are
146 187 // handled by `ALTER TABLE … ADD COLUMN` inside the same helper.
147 188
189 + // v11: rename `placements.user_id` to `placements.owner_id` so
190 + // the placements + folders tables use the same column name for
191 + // the same concept. Must run BEFORE dbDelta — once the
192 + // `$placements_sql` definition switches from `user_id` to
193 + // `owner_id`, dbDelta on an existing v≤10 install would see
194 + // `owner_id` as a missing column and ADD it (leaving the old
195 + // `user_id` in place + the new `owner_id` NULL). Running the
196 + // CHANGE COLUMN first means dbDelta sees the table already
197 + // matches the desired shape.
198 + openstation_files_rename_user_id_to_owner_id();
199 +
148 200 dbDelta( $placements_sql );
149 201 dbDelta( $folders_sql );
150 202 dbDelta( $tombstones_sql );
203 + dbDelta( $stored_files_sql );
151 204
152 205 // dbDelta has well-documented quirks with `NULL`-only columns
153 206 // (no DEFAULT) — under some MySQL/MariaDB combos it silently
154 207 // skips the ADD COLUMN. Verify the v2 trash columns are
155 208 // physically present and ALTER them in directly when not.
156 - desktop_mode_files_ensure_trash_columns();
209 + openstation_files_ensure_trash_columns();
157 210
158 211 // v4: clean up duplicate placements created by sessions that
159 212 // hit the auto-orphan-placer while the v2 trash columns were
160 213 // missing — every `WHERE trashed_at_ms IS NULL` precheck
@@ -159,17 +212,17 @@
159 212 // hit the auto-orphan-placer while the v2 trash columns were
160 213 // missing — every `WHERE trashed_at_ms IS NULL` precheck
161 214 // returned empty, so each pageload re-inserted every
162 215 // registered shortcut. Collapse runs of identical
163 - // `(user_id, parent_id, file_type, file_ref)` rows down to the
164 - // lowest id.
165 - desktop_mode_files_dedupe_placements();
216 + // `(owner_id, parent_id, file_type, file_ref)` rows down to
217 + // the lowest id.
218 + openstation_files_dedupe_placements();
166 219
167 220 // v5: enforce uniqueness at the DB level so a future bug
168 221 // (or a racing pair of REST requests) can never re-create
169 222 // the duplicate shortcuts again. Must run AFTER dedupe —
170 223 // adding a unique key against duplicate rows would fail.
171 - desktop_mode_files_ensure_unique_placement_index();
224 + openstation_files_ensure_unique_placement_index();
172 225
173 226 // v9: belt-and-suspenders existence check for the shares +
174 227 // decisions tables. The folder-sharing feature is the
175 228 // canonical source of truth for "who can see this folder" —
@@ -175,10 +228,10 @@
175 228 // canonical source of truth for "who can see this folder" —
176 229 // the `share_meta` JSON column on the folders table remains
177 230 // for diagnostic purposes only and is not consulted by the
178 231 // visibility resolver.
179 - desktop_mode_files_ensure_shares_table();
180 - desktop_mode_files_ensure_decisions_table();
232 + openstation_files_ensure_shares_table();
233 + openstation_files_ensure_decisions_table();
181 234
182 235 // v10: `updated_by` column on placements so the If-Match 409
183 236 // conflict toast names the SESSION that actually won the race,
184 237 // not just whoever currently owns the row. Critical for the
@@ -184,20 +237,18 @@
184 237 // not just whoever currently owns the row. Critical for the
185 238 // shared-write scenario where User B (writer recipient) moves a
186 239 // placement and User C gets the conflict — without this column,
187 240 // the toast would blame User A (owner of the row).
188 - desktop_mode_files_ensure_updated_by_column();
241 + openstation_files_ensure_updated_by_column();
189 242
190 - update_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, DESKTOP_MODE_FILES_SCHEMA_VERSION );
243 + update_option( OPENSTATION_FILES_SCHEMA_OPTION, OPENSTATION_FILES_SCHEMA_VERSION );
191 244
192 245 /**
193 246 * Fires after the files schema is installed / migrated.
194 247 *
195 - * @since 0.9.0
196 - *
197 248 * @param string $version The version that was installed.
198 249 */
199 - do_action( 'desktop_mode_files_schema_installed', DESKTOP_MODE_FILES_SCHEMA_VERSION );
250 + do_action( 'openstation_files_schema_installed', OPENSTATION_FILES_SCHEMA_VERSION );
200 251 }
201 252
202 253 /**
203 254 * Belt-and-suspenders verifier for the v2 trash columns. Reads
@@ -204,14 +255,13 @@
204 255 * `INFORMATION_SCHEMA.COLUMNS` for the placements + folders tables
205 256 * and `ALTER`s in any column dbDelta missed. Idempotent: each
206 257 * `ALTER` only fires when the column is not already there.
207 258 *
208 - * @since 0.8.0
209 259 * @internal
210 260 */
211 -function desktop_mode_files_ensure_trash_columns() {
261 +function openstation_files_ensure_trash_columns() {
212 262 global $wpdb;
213 - $tables = desktop_mode_files_table_names();
263 + $tables = openstation_files_table_names();
214 264
215 265 // Two-worker race protection: between the INFORMATION_SCHEMA
216 266 // check and the ALTER, a concurrent worker (cron + admin-init,
217 267 // REST + heartbeat) can run the same check, see the column
@@ -224,12 +274,12 @@
224 274 $ensure = static function ( $table, $column, $definition ) use ( $wpdb ) {
225 275 $col_exists = static function () use ( $wpdb, $table, $column ) {
226 276 return (int) $wpdb->get_var(
227 277 $wpdb->prepare(
228 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
278 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
229 279 WHERE TABLE_SCHEMA = DATABASE()
230 280 AND TABLE_NAME = %s
231 - AND COLUMN_NAME = %s",
281 + AND COLUMN_NAME = %s',
232 282 $table,
233 283 $column
234 284 )
235 285 );
@@ -249,34 +299,38 @@
249 299 $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}" );
250 300 }
251 301 };
252 302
253 - $ensure( $tables['placements'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
254 - $ensure( $tables['placements'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
303 + $ensure( $tables['placements'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
304 + $ensure( $tables['placements'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
255 305 $ensure( $tables['placements'], 'trashed_via_folder', 'BIGINT UNSIGNED NULL' );
256 306 // v6: ancestry snapshot — JSON capturing every folder in the
257 307 // parent chain at trash time so a restore can resurrect the
258 308 // chain even when a folder was hard-deleted in the meantime.
259 - $ensure( $tables['placements'], 'trashed_meta', 'LONGTEXT NULL' );
260 - $ensure( $tables['folders'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
261 - $ensure( $tables['folders'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
262 - $ensure( $tables['folders'], 'trashed_meta', 'LONGTEXT NULL' );
309 + $ensure( $tables['placements'], 'trashed_meta', 'LONGTEXT NULL' );
310 + $ensure( $tables['folders'], 'trashed_at_ms', 'BIGINT UNSIGNED NULL' );
311 + $ensure( $tables['folders'], 'trashed_by', 'BIGINT UNSIGNED NULL' );
312 + $ensure( $tables['folders'], 'trashed_meta', 'LONGTEXT NULL' );
263 313 }
264 314
265 315 /**
266 - * Collapse duplicate `(user_id, parent_id, file_type, file_ref)`
267 - * placement rows down to the lowest id, deleting the rest. Only
268 - * meaningful for `file_type IN ('shortcut','folder')` — those are
269 - * the types where two rows for the same ref are redundant. Other
270 - * types (post / page / attachment / …) might legitimately appear
271 - * twice on the same desktop, so the dedupe leaves them alone.
316 + * Collapse duplicate `(owner_id, parent_id, file_type, file_ref)`
317 + * placement rows down to the lowest id, deleting the rest. The
318 + * DELETE is restricted to `file_type IN ('shortcut','folder')` —
319 + * the types where legacy duplicates were actually observed. Note
320 + * that the v5 `placement_unique` index added right after this
321 + * covers EVERY file type, so duplicates of any type within the
322 + * same (owner, parent) are disallowed at the DB level; if legacy
323 + * duplicates of another type exist, the (error-suppressed)
324 + * `ADD UNIQUE` in
325 + * `openstation_files_ensure_unique_placement_index()` will fail
326 + * and leave the index absent until those rows are cleaned up.
272 327 *
273 - * @since 0.8.0
274 328 * @internal
275 329 */
276 -function desktop_mode_files_dedupe_placements() {
330 +function openstation_files_dedupe_placements() {
277 331 global $wpdb;
278 - $tables = desktop_mode_files_table_names();
332 + $tables = openstation_files_table_names();
279 333 $tbl = $tables['placements'];
280 334
281 335 // Once the unique index exists, MySQL prevents duplicate
282 336 // inserts at the DB level — dedupe is a no-op and the
@@ -284,12 +338,12 @@
284 338 // call. Skip in that case so the cost is paid exactly once,
285 339 // during the v4 → v5 migration.
286 340 $has_unique = (int) $wpdb->get_var(
287 341 $wpdb->prepare(
288 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
342 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
289 343 WHERE TABLE_SCHEMA = DATABASE()
290 344 AND TABLE_NAME = %s
291 - AND INDEX_NAME = %s",
345 + AND INDEX_NAME = %s',
292 346 $tbl,
293 347 'placement_unique'
294 348 )
295 349 );
@@ -304,9 +358,9 @@
304 358 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
305 359 $wpdb->query(
306 360 "DELETE p1 FROM `{$tbl}` p1
307 361 INNER JOIN `{$tbl}` p2
308 - ON p1.user_id = p2.user_id
362 + ON p1.owner_id = p2.owner_id
309 363 AND p1.parent_id = p2.parent_id
310 364 AND p1.file_type = p2.file_type
311 365 AND p1.file_ref = p2.file_ref
312 366 AND p1.id > p2.id
@@ -315,9 +369,9 @@
315 369 }
316 370
317 371 /**
318 372 * Add a UNIQUE index on
319 - * `(user_id, parent_id, file_type, file_ref)` to make duplicate
373 + * `(owner_id, parent_id, file_type, file_ref)` to make duplicate
320 374 * placements physically impossible. Skipped when the index is
321 375 * already present.
322 376 *
323 377 * Note: `file_ref` is `VARCHAR(255)` — combined with the three
@@ -323,22 +377,21 @@
323 377 * Note: `file_ref` is `VARCHAR(255)` — combined with the three
324 378 * other columns this fits comfortably under MySQL's 3072-byte
325 379 * InnoDB index-key limit on `utf8mb4`.
326 380 *
327 - * @since 0.8.0
328 381 * @internal
329 382 */
330 -function desktop_mode_files_ensure_unique_placement_index() {
383 +function openstation_files_ensure_unique_placement_index() {
331 384 global $wpdb;
332 - $tables = desktop_mode_files_table_names();
385 + $tables = openstation_files_table_names();
333 386 $tbl = $tables['placements'];
334 387
335 388 $exists = (int) $wpdb->get_var(
336 389 $wpdb->prepare(
337 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
390 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
338 391 WHERE TABLE_SCHEMA = DATABASE()
339 392 AND TABLE_NAME = %s
340 - AND INDEX_NAME = %s",
393 + AND INDEX_NAME = %s',
341 394 $tbl,
342 395 'placement_unique'
343 396 )
344 397 );
@@ -351,9 +404,9 @@
351 404 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
352 405 $wpdb->query(
353 406 "ALTER TABLE `{$tbl}`
354 407 ADD UNIQUE KEY `placement_unique`
355 - (user_id, parent_id, file_type, file_ref)"
408 + (owner_id, parent_id, file_type, file_ref)"
356 409 );
357 410 $wpdb->suppress_errors( $prev_suppress );
358 411 }
359 412 }
@@ -361,9 +414,9 @@
361 414 /**
362 415 * Add the v10 `updated_by` column to the placements table.
363 416 *
364 417 * Tracks which user last mutated the row (created, moved, restored).
365 - * Used by `desktop_mode_files_check_if_match()` so the If-Match 409
418 + * Used by `openstation_files_check_if_match()` so the If-Match 409
366 419 * conflict toast attributes the change to the SESSION that won the
367 420 * race rather than to the row's static owner — critical when a
368 421 * writer recipient of a shared folder rearranges placements and
369 422 * another viewer hits a stale `If-Match`.
@@ -368,23 +421,22 @@
368 421 * writer recipient of a shared folder rearranges placements and
369 422 * another viewer hits a stale `If-Match`.
370 423 *
371 424 * NULL on legacy rows (pre-v10). The conflict resolver falls back
372 - * to `user_id` when this column is NULL, matching the old behavior.
425 + * to `owner_id` when this column is NULL, matching the old behavior.
373 426 *
374 - * @since 0.18.x (schema v10)
375 427 * @internal
376 428 */
377 -function desktop_mode_files_ensure_updated_by_column() {
429 +function openstation_files_ensure_updated_by_column() {
378 430 global $wpdb;
379 - $tables = desktop_mode_files_table_names();
431 + $tables = openstation_files_table_names();
380 432 $tbl = $tables['placements'];
381 433 $exists = (int) $wpdb->get_var(
382 434 $wpdb->prepare(
383 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
435 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
384 436 WHERE TABLE_SCHEMA = DATABASE()
385 437 AND TABLE_NAME = %s
386 - AND COLUMN_NAME = %s",
438 + AND COLUMN_NAME = %s',
387 439 $tbl,
388 440 'updated_by'
389 441 )
390 442 );
@@ -394,14 +446,105 @@
394 446 // ("Duplicate column"). The column ends up present either
395 447 // way.
396 448 $prev_suppress = $wpdb->suppress_errors( true );
397 449 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
398 - $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `updated_by` BIGINT UNSIGNED NULL AFTER `user_id`" );
450 + $wpdb->query( "ALTER TABLE `{$tbl}` ADD COLUMN `updated_by` BIGINT UNSIGNED NULL AFTER `owner_id`" );
399 451 $wpdb->suppress_errors( $prev_suppress );
400 452 }
401 453 }
402 454
403 455 /**
456 + * Rename `placements.user_id` to `placements.owner_id` and the
457 + * matching `user_parent` index to `owner_parent`. The two
458 + * os-owned tables historically used different names for
459 + * the same "row's owner" concept — folders carried `owner_id` from
460 + * day one, placements carried `user_id`. v11 unifies them so SQL
461 + * and PHP read identically across the two tables.
462 + *
463 + * Idempotent: skips when the table doesn't exist (fresh install —
464 + * dbDelta runs after this and creates the table with the new
465 + * column name directly) and when the column is already renamed.
466 + *
467 + * Must run BEFORE `dbDelta( $placements_sql )` in
468 + * `openstation_files_install_schema()` — dbDelta does NOT rename
469 + * columns, so against a v≤10 table whose definition says
470 + * `owner_id` it would ADD a new `owner_id` column and leave the
471 + * stale `user_id` in place. Running the CHANGE COLUMN first puts
472 + * the table in the desired shape so dbDelta sees no diff.
473 + *
474 + * Schema version was bumped from 11 to 12 so any install that
475 + * stamped 11 but never actually renamed the column (a silent
476 + * partial run during dev iteration) gets a clean retry. The
477 + * function is idempotent — early-returns when `user_id` is absent,
478 + * so healthy v11 installs see a cheap no-op on the retry.
479 + *
480 + * @internal
481 + */
482 +function openstation_files_rename_user_id_to_owner_id() {
483 + global $wpdb;
484 + $tables = openstation_files_table_names();
485 + $tbl = $tables['placements'];
486 +
487 + // Fresh install — the table doesn't exist yet; dbDelta creates
488 + // it with `owner_id` directly. Nothing to migrate.
489 + $table_exists = (int) $wpdb->get_var(
490 + $wpdb->prepare(
491 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
492 + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
493 + $tbl
494 + )
495 + );
496 + if ( 0 === $table_exists ) {
497 + return;
498 + }
499 +
500 + $has_user_id = (int) $wpdb->get_var(
501 + $wpdb->prepare(
502 + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
503 + WHERE TABLE_SCHEMA = DATABASE()
504 + AND TABLE_NAME = %s
505 + AND COLUMN_NAME = 'user_id'",
506 + $tbl
507 + )
508 + );
509 + if ( 0 === $has_user_id ) {
510 + return; // Already renamed (or column was never there — fresh install via test factory).
511 + }
512 +
513 + // DELIBERATELY NOT suppressing errors here. An earlier draft
514 + // wrapped the ALTER in `suppress_errors( true )` "in case of
515 + // concurrent migration race" — there's no realistic race for
516 + // this ALTER (schema migrations run inside one request) and the
517 + // suppression hid a real failure mode on at least one local
518 + // install: the option got stamped at v11 but the CHANGE COLUMN
519 + // never landed, so every later placements query 500'd silently.
520 + // Let any wpdb error surface to `debug.log` so the failure is
521 + // visible the next time this code runs.
522 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
523 + $wpdb->query(
524 + "ALTER TABLE `{$tbl}` CHANGE COLUMN `user_id` `owner_id` BIGINT UNSIGNED NOT NULL"
525 + );
526 +
527 + // MySQL/MariaDB auto-update index column references on CHANGE
528 + // COLUMN, but the index NAMES are baked in. Rename them too so
529 + // `EXPLAIN`/`SHOW INDEX` output reads consistently with the
530 + // column.
531 + $has_user_parent = (int) $wpdb->get_var(
532 + $wpdb->prepare(
533 + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
534 + WHERE TABLE_SCHEMA = DATABASE()
535 + AND TABLE_NAME = %s
536 + AND INDEX_NAME = 'user_parent'",
537 + $tbl
538 + )
539 + );
540 + if ( 0 < $has_user_parent ) {
541 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
542 + $wpdb->query( "ALTER TABLE `{$tbl}` RENAME INDEX `user_parent` TO `owner_parent`" );
543 + }
544 +}
545 +
546 +/**
404 547 * Belt-and-suspenders verifier for the v8 `shares` table. `dbDelta`
405 548 * has known edge cases where a brand-new table with `UNIQUE KEY`
406 549 * declarations on a non-`utf8mb4` collation gets silently skipped
407 550 * on some MySQL/MariaDB combos; we mirror the trash-columns
@@ -406,22 +549,21 @@
406 549 * declarations on a non-`utf8mb4` collation gets silently skipped
407 550 * on some MySQL/MariaDB combos; we mirror the trash-columns
408 551 * pattern and `CREATE TABLE IF NOT EXISTS` the row explicitly.
409 552 *
410 - * @since 0.18.0
411 553 * @internal
412 554 */
413 -function desktop_mode_files_ensure_shares_table() {
555 +function openstation_files_ensure_shares_table() {
414 556 global $wpdb;
415 - $tables = desktop_mode_files_table_names();
557 + $tables = openstation_files_table_names();
416 558 $charset_collate = $wpdb->get_charset_collate();
417 559 $tbl = $tables['shares'];
418 560
419 561 $exists = (int) $wpdb->get_var(
420 562 $wpdb->prepare(
421 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
563 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
422 564 WHERE TABLE_SCHEMA = DATABASE()
423 - AND TABLE_NAME = %s",
565 + AND TABLE_NAME = %s',
424 566 $tbl
425 567 )
426 568 );
427 569 if ( 0 === $exists ) {
@@ -448,12 +590,12 @@
448 590 // Existing table — make sure `target_type` is there for
449 591 // installs that ran a pre-target_type build of v8.
450 592 $has_col = (int) $wpdb->get_var(
451 593 $wpdb->prepare(
452 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
594 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
453 595 WHERE TABLE_SCHEMA = DATABASE()
454 596 AND TABLE_NAME = %s
455 - AND COLUMN_NAME = %s",
597 + AND COLUMN_NAME = %s',
456 598 $tbl,
457 599 'target_type'
458 600 )
459 601 );
@@ -471,22 +613,21 @@
471 613
472 614 /**
473 615 * Belt-and-suspenders verifier for the decisions table.
474 616 *
475 - * @since 0.18.0
476 617 * @internal
477 618 */
478 -function desktop_mode_files_ensure_decisions_table() {
619 +function openstation_files_ensure_decisions_table() {
479 620 global $wpdb;
480 - $tables = desktop_mode_files_table_names();
621 + $tables = openstation_files_table_names();
481 622 $charset_collate = $wpdb->get_charset_collate();
482 623 $tbl = $tables['decisions'];
483 624
484 625 $exists = (int) $wpdb->get_var(
485 626 $wpdb->prepare(
486 - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
627 + 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
487 628 WHERE TABLE_SCHEMA = DATABASE()
488 - AND TABLE_NAME = %s",
629 + AND TABLE_NAME = %s',
489 630 $tbl
490 631 )
491 632 );
492 633 if ( 0 === $exists ) {
@@ -509,34 +650,30 @@
509 650 /**
510 651 * Lazy migrator — runs on `admin_init` when the stored schema
511 652 * version doesn't match the constant. Idempotent: `dbDelta`
512 653 * itself is a no-op when the table already matches.
513 - *
514 - * @since 0.9.0
515 654 */
516 -function desktop_mode_files_maybe_install_schema() {
517 - $installed = get_option( DESKTOP_MODE_FILES_SCHEMA_OPTION, '' );
518 - if ( $installed === DESKTOP_MODE_FILES_SCHEMA_VERSION ) {
655 +function openstation_files_maybe_install_schema() {
656 + $installed = get_option( OPENSTATION_FILES_SCHEMA_OPTION, '' );
657 + if ( OPENSTATION_FILES_SCHEMA_VERSION === $installed ) {
519 658 return;
520 659 }
521 - desktop_mode_files_install_schema();
660 + openstation_files_install_schema();
522 661 }
523 -add_action( 'admin_init', 'desktop_mode_files_maybe_install_schema' );
662 +add_action( 'admin_init', 'openstation_files_maybe_install_schema' );
524 663 // REST + front-end requests never fire `admin_init` — without these
525 664 // hooks a session that hits a REST endpoint before any admin page
526 665 // load would query the placements / folders tables before the v2
527 666 // trash columns exist, throwing wpdb errors and blanking the desktop.
528 -add_action( 'rest_api_init', 'desktop_mode_files_maybe_install_schema' );
529 -add_action( 'init', 'desktop_mode_files_maybe_install_schema', 1 );
530 -register_activation_hook( DESKTOP_MODE_FILE, 'desktop_mode_files_install_schema' );
667 +add_action( 'rest_api_init', 'openstation_files_maybe_install_schema' );
668 +add_action( 'init', 'openstation_files_maybe_install_schema', 1 );
669 +register_activation_hook( OPENSTATION_FILE, 'openstation_files_install_schema' );
531 670
532 671 /**
533 672 * Current epoch-ms timestamp. Centralized so the store and the
534 673 * tombstone writer stay in lock-step.
535 674 *
536 - * @since 0.9.0
537 - *
538 675 * @return int
539 676 */
540 -function desktop_mode_files_now_ms() {
677 +function openstation_files_now_ms() {
541 678 return (int) round( microtime( true ) * 1000 );
542 679 }