@styleflow.app/flowpress-deploy 1.0.0-beta.0 → 1.0.0-beta.2

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,621 @@
1
+ <?php
2
+
3
+ if (! defined('ABSPATH')) {
4
+ exit;
5
+ }
6
+
7
+ function flowpress_deploy_queue_table(): string
8
+ {
9
+ global $wpdb;
10
+ return $wpdb->prefix . 'flowpress_deploy_queue';
11
+ }
12
+
13
+ function flowpress_deploy_state_table(): string
14
+ {
15
+ global $wpdb;
16
+ return $wpdb->prefix . 'flowpress_deploy_state';
17
+ }
18
+
19
+ function flowpress_deploy_batch_table(): string
20
+ {
21
+ global $wpdb;
22
+ return $wpdb->prefix . 'flowpress_deploy_batches';
23
+ }
24
+
25
+ function flowpress_deploy_install_schema(): void
26
+ {
27
+ if (! function_exists('flowpress_wordpress_ready') || ! flowpress_wordpress_ready()) {
28
+ return;
29
+ }
30
+ if (get_option('flowpress_deploy_schema') === '1') {
31
+ return;
32
+ }
33
+
34
+ global $wpdb;
35
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
36
+ $charset = $wpdb->get_charset_collate();
37
+ $queue = flowpress_deploy_queue_table();
38
+ $state = flowpress_deploy_state_table();
39
+ $batches = flowpress_deploy_batch_table();
40
+ dbDelta("CREATE TABLE {$queue} (
41
+ id bigint unsigned NOT NULL AUTO_INCREMENT,
42
+ target varchar(64) NOT NULL,
43
+ group_key varchar(191) NOT NULL,
44
+ resource_type varchar(64) NOT NULL,
45
+ resource_id bigint unsigned NOT NULL DEFAULT 0,
46
+ current_uris longtext NOT NULL,
47
+ previous_uris longtext NOT NULL,
48
+ fingerprint char(64) NOT NULL,
49
+ revision bigint unsigned NOT NULL DEFAULT 1,
50
+ force_full tinyint(1) NOT NULL DEFAULT 0,
51
+ batch_id varchar(64) NULL,
52
+ dispatched_revision bigint unsigned NULL,
53
+ dispatched_at datetime NULL,
54
+ created_at datetime NOT NULL,
55
+ updated_at datetime NOT NULL,
56
+ PRIMARY KEY (id),
57
+ UNIQUE KEY target_group (target, group_key),
58
+ KEY batch_id (batch_id),
59
+ KEY target_pending (target, batch_id)
60
+ ) {$charset};");
61
+ dbDelta("CREATE TABLE {$state} (
62
+ target varchar(64) NOT NULL,
63
+ group_key varchar(191) NOT NULL,
64
+ current_uris longtext NOT NULL,
65
+ fingerprint char(64) NOT NULL,
66
+ revision bigint unsigned NOT NULL DEFAULT 0,
67
+ updated_at datetime NOT NULL,
68
+ PRIMARY KEY (target, group_key)
69
+ ) {$charset};");
70
+ dbDelta("CREATE TABLE {$batches} (
71
+ batch_id varchar(64) NOT NULL,
72
+ target varchar(64) NOT NULL,
73
+ mode varchar(16) NOT NULL,
74
+ snapshot longtext NOT NULL,
75
+ status varchar(16) NOT NULL DEFAULT 'in_flight',
76
+ created_at datetime NOT NULL,
77
+ completed_at datetime NULL,
78
+ PRIMARY KEY (batch_id),
79
+ KEY status_created (status, created_at)
80
+ ) {$charset};");
81
+ update_option('flowpress_deploy_schema', '1', false);
82
+ flowpress_deploy_migrate_core_queue();
83
+ }
84
+ add_action('init', 'flowpress_deploy_install_schema', 0);
85
+
86
+ /** @return list<string> */
87
+ function flowpress_deploy_decode_uris(mixed $value): array
88
+ {
89
+ $decoded = is_array($value) ? $value : json_decode((string) $value, true);
90
+ if (! is_array($decoded)) {
91
+ return [];
92
+ }
93
+ $uris = [];
94
+ foreach ($decoded as $uri) {
95
+ if (! is_string($uri) || $uri === '') {
96
+ continue;
97
+ }
98
+ $normalized = function_exists('flowpress_normalize_uri')
99
+ ? flowpress_normalize_uri($uri)
100
+ : (string) (wp_parse_url($uri, PHP_URL_PATH) ?: '/');
101
+ $uris[$normalized] = true;
102
+ }
103
+ $uris = array_keys($uris);
104
+ sort($uris, SORT_STRING);
105
+ return $uris;
106
+ }
107
+
108
+ function flowpress_deploy_json(array $value): string
109
+ {
110
+ return (string) wp_json_encode(array_values($value), JSON_UNESCAPED_SLASHES);
111
+ }
112
+
113
+ /** @return array{paths:list<string>,deletedPaths:list<string>} */
114
+ function flowpress_deploy_route_delta(array $previous, array $current): array
115
+ {
116
+ $previous = flowpress_deploy_decode_uris($previous);
117
+ $current = flowpress_deploy_decode_uris($current);
118
+ return [
119
+ 'paths' => $current,
120
+ 'deletedPaths' => array_values(array_diff($previous, $current)),
121
+ ];
122
+ }
123
+
124
+ function flowpress_deploy_ack_action(string $status, int $current_revision, int $snapshot_revision): string
125
+ {
126
+ if ($status !== 'success') {
127
+ return 'release';
128
+ }
129
+ return $current_revision === $snapshot_revision ? 'delete' : 'release';
130
+ }
131
+
132
+ /** @return array{resourceType:string,resourceId:int,uri:string,operation:string,forceFull:bool,target:string} */
133
+ function flowpress_deploy_normalize_legacy_row(array $row): array
134
+ {
135
+ return [
136
+ 'resourceType' => sanitize_key((string) ($row['resource_type'] ?? 'legacy')) ?: 'legacy',
137
+ 'resourceId' => max(0, (int) ($row['resource_id'] ?? 0)),
138
+ 'uri' => (string) ($row['uri'] ?? '/'),
139
+ 'operation' => ($row['operation'] ?? '') === 'delete' ? 'delete' : 'update',
140
+ 'forceFull' => ! empty($row['force_full']),
141
+ 'target' => sanitize_key((string) ($row['target'] ?? '')),
142
+ ];
143
+ }
144
+
145
+ /** @return array{groupKey:string,resourceType:string,resourceId:int,uris:list<string>,fingerprint:string} */
146
+ function flowpress_deploy_describe_resource(string $resource_type, int $resource_id, string $fallback_uri): array
147
+ {
148
+ $resource_type = sanitize_key($resource_type);
149
+ $group_key = $resource_type . ':' . max(0, $resource_id);
150
+ $uris = flowpress_deploy_decode_uris([$fallback_uri]);
151
+ $fingerprint_fields = [
152
+ 'resourceType' => $resource_type,
153
+ 'resourceId' => $resource_id,
154
+ ];
155
+
156
+ $post = $resource_id > 0 ? get_post($resource_id) : null;
157
+ if ($post instanceof WP_Post) {
158
+ $element_type = 'post_' . $post->post_type;
159
+ $trid = apply_filters('wpml_element_trid', null, $resource_id, $element_type);
160
+ if (is_numeric($trid) && (int) $trid > 0) {
161
+ $group_key = 'wpml:' . $element_type . ':' . (int) $trid;
162
+ }
163
+ $translations = is_numeric($trid)
164
+ ? apply_filters('wpml_get_element_translations', [], (int) $trid, $element_type)
165
+ : [];
166
+ $post_ids = [$resource_id];
167
+ if (is_array($translations)) {
168
+ foreach ($translations as $translation) {
169
+ $translated_id = is_object($translation) ? ($translation->element_id ?? 0) : 0;
170
+ if ((int) $translated_id > 0) {
171
+ $post_ids[] = (int) $translated_id;
172
+ }
173
+ }
174
+ }
175
+ $post_ids = array_values(array_unique($post_ids));
176
+ sort($post_ids, SORT_NUMERIC);
177
+ $uris = [];
178
+ $posts = [];
179
+ foreach ($post_ids as $post_id) {
180
+ $translated = get_post($post_id);
181
+ if (! $translated instanceof WP_Post) {
182
+ continue;
183
+ }
184
+ if ($translated->post_status === 'publish') {
185
+ $permalink = get_permalink($translated);
186
+ if (is_string($permalink) && $permalink !== '') {
187
+ $uris[] = $permalink;
188
+ }
189
+ }
190
+ $meta = get_post_meta($post_id);
191
+ $excluded = (array) apply_filters('flowpress_deploy_volatile_meta_keys', [
192
+ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_page_template',
193
+ ], 'post', $post_id);
194
+ foreach ($excluded as $key) {
195
+ unset($meta[(string) $key]);
196
+ }
197
+ ksort($meta);
198
+ $terms = wp_get_object_terms($post_id, get_object_taxonomies($translated->post_type), ['fields' => 'tt_ids']);
199
+ if (! is_wp_error($terms)) {
200
+ $terms = array_map('intval', $terms);
201
+ sort($terms, SORT_NUMERIC);
202
+ }
203
+ $posts[] = [
204
+ 'id' => $post_id,
205
+ 'type' => $translated->post_type,
206
+ 'status' => $translated->post_status,
207
+ 'slug' => $translated->post_name,
208
+ 'title' => $translated->post_title,
209
+ 'content' => $translated->post_content,
210
+ 'excerpt' => $translated->post_excerpt,
211
+ 'parent' => (int) $translated->post_parent,
212
+ 'modified' => $translated->post_modified_gmt,
213
+ 'meta' => $meta,
214
+ 'terms' => is_wp_error($terms) ? [] : $terms,
215
+ ];
216
+ }
217
+ $fingerprint_fields['posts'] = $posts;
218
+ } elseif ($resource_id > 0) {
219
+ $term = get_term($resource_id, $resource_type);
220
+ if ($term instanceof WP_Term) {
221
+ $element_type = 'tax_' . $resource_type;
222
+ $trid = apply_filters('wpml_element_trid', null, $term->term_taxonomy_id, $element_type);
223
+ if (is_numeric($trid) && (int) $trid > 0) {
224
+ $group_key = 'wpml:' . $element_type . ':' . (int) $trid;
225
+ }
226
+ $translations = is_numeric($trid)
227
+ ? apply_filters('wpml_get_element_translations', [], (int) $trid, $element_type)
228
+ : [];
229
+ $translated_terms = [$term];
230
+ if (is_array($translations)) {
231
+ foreach ($translations as $translation) {
232
+ $tt_id = is_object($translation) ? (int) ($translation->element_id ?? 0) : 0;
233
+ $translated_term = $tt_id > 0
234
+ ? get_term_by('term_taxonomy_id', $tt_id, $resource_type)
235
+ : null;
236
+ if ($translated_term instanceof WP_Term) {
237
+ $translated_terms[] = $translated_term;
238
+ }
239
+ }
240
+ }
241
+ $term_map = [];
242
+ foreach ($translated_terms as $translated_term) {
243
+ $term_map[$translated_term->term_id] = $translated_term;
244
+ }
245
+ ksort($term_map, SORT_NUMERIC);
246
+ $uris = [];
247
+ $terms = [];
248
+ foreach ($term_map as $translated_term) {
249
+ $link = get_term_link($translated_term);
250
+ if (! is_wp_error($link) && is_string($link)) {
251
+ $uris[] = $link;
252
+ }
253
+ $meta = get_term_meta($translated_term->term_id);
254
+ $excluded = (array) apply_filters(
255
+ 'flowpress_deploy_volatile_meta_keys',
256
+ [],
257
+ 'term',
258
+ $translated_term->term_id
259
+ );
260
+ foreach ($excluded as $key) {
261
+ unset($meta[(string) $key]);
262
+ }
263
+ ksort($meta);
264
+ $terms[] = [
265
+ 'id' => $translated_term->term_id,
266
+ 'taxonomy' => $resource_type,
267
+ 'slug' => $translated_term->slug,
268
+ 'name' => $translated_term->name,
269
+ 'description' => $translated_term->description,
270
+ 'parent' => (int) $translated_term->parent,
271
+ 'meta' => $meta,
272
+ ];
273
+ }
274
+ $fingerprint_fields['terms'] = $terms;
275
+ }
276
+ }
277
+
278
+ $group_key = (string) apply_filters(
279
+ 'flowpress_deploy_group_key',
280
+ $group_key,
281
+ $resource_type,
282
+ $resource_id
283
+ );
284
+ $uris = flowpress_deploy_decode_uris((array) apply_filters(
285
+ 'flowpress_deploy_canonical_uris',
286
+ $uris,
287
+ $resource_type,
288
+ $resource_id,
289
+ $group_key
290
+ ));
291
+ $fingerprint_fields = (array) apply_filters(
292
+ 'flowpress_deploy_fingerprint_fields',
293
+ $fingerprint_fields,
294
+ $resource_type,
295
+ $resource_id,
296
+ $group_key
297
+ );
298
+ return [
299
+ 'groupKey' => sanitize_key(str_replace(':', '-', $group_key)),
300
+ 'resourceType' => $resource_type,
301
+ 'resourceId' => max(0, $resource_id),
302
+ 'uris' => $uris,
303
+ 'fingerprint' => hash('sha256', (string) wp_json_encode($fingerprint_fields)),
304
+ ];
305
+ }
306
+
307
+ /** @var array<string,array{resourceType:string,resourceId:int,uri:string,operation:string,forceFull:bool}> */
308
+ function &flowpress_deploy_collected_changes(): array
309
+ {
310
+ static $changes = [];
311
+ return $changes;
312
+ }
313
+
314
+ function flowpress_deploy_collect_change(
315
+ string $resource_type,
316
+ int $resource_id,
317
+ string $uri,
318
+ string $operation = 'update',
319
+ bool $force_full = false
320
+ ): void {
321
+ if (function_exists('flowpress_deploy_schedule_public_build')) flowpress_deploy_schedule_public_build();
322
+
323
+ if ($operation === 'delete') {
324
+ flowpress_deploy_enqueue_change($resource_type, $resource_id, $uri, 'delete', $force_full);
325
+ return;
326
+ }
327
+ $changes = &flowpress_deploy_collected_changes();
328
+ $changes[$resource_type . ':' . $resource_id] = [
329
+ 'resourceType' => $resource_type,
330
+ 'resourceId' => $resource_id,
331
+ 'uri' => $uri,
332
+ 'operation' => $operation,
333
+ 'forceFull' => $force_full,
334
+ ];
335
+ }
336
+
337
+ function flowpress_deploy_flush_changes(): void
338
+ {
339
+ $changes = &flowpress_deploy_collected_changes();
340
+ foreach ($changes as $change) {
341
+ flowpress_deploy_enqueue_change(
342
+ $change['resourceType'],
343
+ $change['resourceId'],
344
+ $change['uri'],
345
+ $change['operation'],
346
+ $change['forceFull']
347
+ );
348
+ }
349
+ $changes = [];
350
+ }
351
+ add_action('shutdown', 'flowpress_deploy_flush_changes', 0);
352
+
353
+ function flowpress_deploy_enqueue_change(
354
+ string $resource_type,
355
+ int $resource_id,
356
+ string $uri,
357
+ string $operation = 'update',
358
+ bool $force_full = false,
359
+ ?string $only_target = null
360
+ ): void {
361
+ global $wpdb;
362
+ $descriptor = flowpress_deploy_describe_resource($resource_type, $resource_id, $uri);
363
+ if ($operation === 'delete') {
364
+ $descriptor['uris'] = [];
365
+ $descriptor['fingerprint'] = hash('sha256', 'deleted:' . $descriptor['groupKey']);
366
+ }
367
+ $targets = $only_target !== null ? [$only_target] : flowpress_static_targets();
368
+ foreach ($targets as $target) {
369
+ $target = sanitize_key($target);
370
+ if ($target === '') {
371
+ continue;
372
+ }
373
+ $existing = $wpdb->get_row($wpdb->prepare(
374
+ 'SELECT * FROM ' . flowpress_deploy_queue_table() . ' WHERE target = %s AND group_key = %s',
375
+ $target,
376
+ $descriptor['groupKey']
377
+ ), ARRAY_A);
378
+ $state = $wpdb->get_row($wpdb->prepare(
379
+ 'SELECT * FROM ' . flowpress_deploy_state_table() . ' WHERE target = %s AND group_key = %s',
380
+ $target,
381
+ $descriptor['groupKey']
382
+ ), ARRAY_A);
383
+ if (
384
+ ! $force_full
385
+ && $operation !== 'delete'
386
+ && ! is_array($existing)
387
+ && is_array($state)
388
+ && hash_equals((string) $state['fingerprint'], $descriptor['fingerprint'])
389
+ && flowpress_deploy_decode_uris($state['current_uris']) === $descriptor['uris']
390
+ ) {
391
+ continue;
392
+ }
393
+ $previous = is_array($existing)
394
+ ? flowpress_deploy_decode_uris($existing['previous_uris'])
395
+ : (is_array($state) ? flowpress_deploy_decode_uris($state['current_uris']) : []);
396
+ if ($operation === 'delete' && $previous === []) {
397
+ $previous = flowpress_deploy_decode_uris([$uri]);
398
+ }
399
+ $now = current_time('mysql', true);
400
+ if (is_array($existing)) {
401
+ $wpdb->update(flowpress_deploy_queue_table(), [
402
+ 'resource_type' => $descriptor['resourceType'],
403
+ 'resource_id' => $descriptor['resourceId'],
404
+ 'current_uris' => flowpress_deploy_json($descriptor['uris']),
405
+ 'fingerprint' => $descriptor['fingerprint'],
406
+ 'revision' => (int) $existing['revision'] + 1,
407
+ 'force_full' => ($force_full || ! empty($existing['force_full'])) ? 1 : 0,
408
+ 'updated_at' => $now,
409
+ ], ['id' => (int) $existing['id']]);
410
+ } else {
411
+ $wpdb->insert(flowpress_deploy_queue_table(), [
412
+ 'target' => $target,
413
+ 'group_key' => $descriptor['groupKey'],
414
+ 'resource_type' => $descriptor['resourceType'],
415
+ 'resource_id' => $descriptor['resourceId'],
416
+ 'current_uris' => flowpress_deploy_json($descriptor['uris']),
417
+ 'previous_uris' => flowpress_deploy_json($previous),
418
+ 'fingerprint' => $descriptor['fingerprint'],
419
+ 'revision' => 1,
420
+ 'force_full' => $force_full ? 1 : 0,
421
+ 'created_at' => $now,
422
+ 'updated_at' => $now,
423
+ ]);
424
+ }
425
+ }
426
+ }
427
+
428
+ /** @return array<string,list<array<string,mixed>>> */
429
+ function &flowpress_deploy_payload_snapshots(): array
430
+ {
431
+ static $snapshots = [];
432
+ return $snapshots;
433
+ }
434
+
435
+ function flowpress_deploy_build_payload(string $target, bool $force_full = false): array
436
+ {
437
+ flowpress_deploy_flush_changes();
438
+ flowpress_deploy_recover_stale_batches();
439
+ global $wpdb;
440
+ $rows = (array) $wpdb->get_results($wpdb->prepare(
441
+ 'SELECT * FROM ' . flowpress_deploy_queue_table()
442
+ . ' WHERE target = %s AND batch_id IS NULL ORDER BY id ASC',
443
+ $target
444
+ ), ARRAY_A);
445
+ $paths = [];
446
+ $deleted = [];
447
+ $full = $force_full;
448
+ foreach ($rows as $row) {
449
+ $current = flowpress_deploy_decode_uris($row['current_uris']);
450
+ $previous = flowpress_deploy_decode_uris($row['previous_uris']);
451
+ $delta = flowpress_deploy_route_delta($previous, $current);
452
+ foreach ($delta['paths'] as $path) {
453
+ $paths[$path] = true;
454
+ unset($deleted[$path]);
455
+ }
456
+ foreach ($delta['deletedPaths'] as $path) {
457
+ if (! isset($paths[$path])) {
458
+ $deleted[$path] = true;
459
+ }
460
+ }
461
+ $full = $full || ! empty($row['force_full']);
462
+ }
463
+ $batch_id = wp_generate_uuid4();
464
+ $snapshots = &flowpress_deploy_payload_snapshots();
465
+ $snapshots[$batch_id] = $rows;
466
+ return [
467
+ 'batchId' => $batch_id,
468
+ 'target' => sanitize_key($target),
469
+ 'mode' => $full ? 'full' : 'partial',
470
+ 'paths' => array_keys($paths),
471
+ 'deletedPaths' => array_keys($deleted),
472
+ 'queueIds' => array_map('intval', array_column($rows, 'id')),
473
+ ];
474
+ }
475
+
476
+ function flowpress_deploy_mark_dispatched(array $payload): void
477
+ {
478
+ global $wpdb;
479
+ $batch_id = sanitize_text_field((string) ($payload['batchId'] ?? ''));
480
+ $snapshots = &flowpress_deploy_payload_snapshots();
481
+ $rows = $snapshots[$batch_id] ?? [];
482
+ unset($snapshots[$batch_id]);
483
+ $now = current_time('mysql', true);
484
+ $accepted = [];
485
+ foreach ($rows as $row) {
486
+ $updated = $wpdb->query($wpdb->prepare(
487
+ 'UPDATE ' . flowpress_deploy_queue_table()
488
+ . ' SET batch_id = %s, dispatched_revision = %d, dispatched_at = %s'
489
+ . ' WHERE id = %d AND revision = %d AND batch_id IS NULL',
490
+ $batch_id,
491
+ (int) $row['revision'],
492
+ $now,
493
+ (int) $row['id'],
494
+ (int) $row['revision']
495
+ ));
496
+ if ($updated) {
497
+ $accepted[] = $row;
498
+ }
499
+ }
500
+ $wpdb->replace(flowpress_deploy_batch_table(), [
501
+ 'batch_id' => $batch_id,
502
+ 'target' => sanitize_key((string) ($payload['target'] ?? '')),
503
+ 'mode' => ($payload['mode'] ?? '') === 'full' ? 'full' : 'partial',
504
+ 'snapshot' => (string) wp_json_encode($accepted, JSON_UNESCAPED_SLASHES),
505
+ 'status' => 'in_flight',
506
+ 'created_at' => $now,
507
+ ]);
508
+ }
509
+
510
+ function flowpress_deploy_complete_batch(string $batch_id, string $status): int
511
+ {
512
+ global $wpdb;
513
+ $batch = $wpdb->get_row($wpdb->prepare(
514
+ 'SELECT * FROM ' . flowpress_deploy_batch_table() . ' WHERE batch_id = %s',
515
+ $batch_id
516
+ ), ARRAY_A);
517
+ if (! is_array($batch)) {
518
+ return 0;
519
+ }
520
+ $rows = json_decode((string) $batch['snapshot'], true);
521
+ $rows = is_array($rows) ? $rows : [];
522
+ $affected = 0;
523
+ foreach ($rows as $snapshot) {
524
+ $current = $wpdb->get_row($wpdb->prepare(
525
+ 'SELECT * FROM ' . flowpress_deploy_queue_table() . ' WHERE id = %d',
526
+ (int) ($snapshot['id'] ?? 0)
527
+ ), ARRAY_A);
528
+ if (! is_array($current) || (string) $current['batch_id'] !== $batch_id) {
529
+ continue;
530
+ }
531
+ if ($status === 'success') {
532
+ $wpdb->replace(flowpress_deploy_state_table(), [
533
+ 'target' => (string) $snapshot['target'],
534
+ 'group_key' => (string) $snapshot['group_key'],
535
+ 'current_uris' => (string) $snapshot['current_uris'],
536
+ 'fingerprint' => (string) $snapshot['fingerprint'],
537
+ 'revision' => (int) $snapshot['revision'],
538
+ 'updated_at' => current_time('mysql', true),
539
+ ]);
540
+ }
541
+ $ack_action = flowpress_deploy_ack_action(
542
+ $status,
543
+ (int) $current['revision'],
544
+ (int) $snapshot['revision']
545
+ );
546
+ if ($ack_action === 'delete') {
547
+ $affected += (int) $wpdb->delete(flowpress_deploy_queue_table(), ['id' => (int) $current['id']]);
548
+ } else {
549
+ $affected += (int) $wpdb->update(flowpress_deploy_queue_table(), [
550
+ 'batch_id' => null,
551
+ 'dispatched_revision' => null,
552
+ 'dispatched_at' => null,
553
+ ], ['id' => (int) $current['id']]);
554
+ }
555
+ }
556
+ $wpdb->update(flowpress_deploy_batch_table(), [
557
+ 'status' => $status,
558
+ 'completed_at' => current_time('mysql', true),
559
+ ], ['batch_id' => $batch_id]);
560
+ return $affected;
561
+ }
562
+
563
+ function flowpress_deploy_pending_count(string $target): int
564
+ {
565
+ global $wpdb;
566
+ return (int) $wpdb->get_var($wpdb->prepare(
567
+ 'SELECT COUNT(*) FROM ' . flowpress_deploy_queue_table()
568
+ . ' WHERE target = %s AND batch_id IS NULL',
569
+ sanitize_key($target)
570
+ ));
571
+ }
572
+
573
+ function flowpress_deploy_recover_stale_batches(): int
574
+ {
575
+ global $wpdb;
576
+ $hours = max(1, (int) apply_filters('flowpress_deploy_stale_batch_hours', 6));
577
+ $cutoff = gmdate('Y-m-d H:i:s', time() - ($hours * HOUR_IN_SECONDS));
578
+ $batch_ids = (array) $wpdb->get_col($wpdb->prepare(
579
+ 'SELECT batch_id FROM ' . flowpress_deploy_batch_table()
580
+ . " WHERE status = 'in_flight' AND created_at < %s",
581
+ $cutoff
582
+ ));
583
+ $affected = 0;
584
+ foreach ($batch_ids as $batch_id) {
585
+ $affected += flowpress_deploy_complete_batch((string) $batch_id, 'failure');
586
+ }
587
+ return $affected;
588
+ }
589
+ add_action('flowpress_deploy_recover_stale_batches', 'flowpress_deploy_recover_stale_batches');
590
+ add_action('init', static function (): void {
591
+ if (! wp_next_scheduled('flowpress_deploy_recover_stale_batches')) {
592
+ wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'flowpress_deploy_recover_stale_batches');
593
+ }
594
+ }, 20);
595
+
596
+ function flowpress_deploy_migrate_core_queue(): void
597
+ {
598
+ if (get_option('flowpress_deploy_core_queue_migrated') === '1') {
599
+ return;
600
+ }
601
+ global $wpdb;
602
+ $core = $wpdb->prefix . 'flowpress_build_queue';
603
+ $like = method_exists($wpdb, 'esc_like') ? $wpdb->esc_like($core) : $core;
604
+ if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like)) !== $core) {
605
+ update_option('flowpress_deploy_core_queue_migrated', '1', false);
606
+ return;
607
+ }
608
+ $rows = (array) $wpdb->get_results("SELECT * FROM {$core} ORDER BY id ASC", ARRAY_A);
609
+ foreach ($rows as $row) {
610
+ $legacy = flowpress_deploy_normalize_legacy_row($row);
611
+ flowpress_deploy_enqueue_change(
612
+ $legacy['resourceType'],
613
+ $legacy['resourceId'],
614
+ $legacy['uri'],
615
+ $legacy['operation'],
616
+ $legacy['forceFull'],
617
+ $legacy['target']
618
+ );
619
+ }
620
+ update_option('flowpress_deploy_core_queue_migrated', '1', false);
621
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styleflow.app/flowpress-deploy",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Safe full and partial deployment primitives for FlowPress.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -16,13 +16,33 @@
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
+ },
20
+ "./frontend": {
21
+ "import": "./dist/frontend-links.js",
22
+ "types": "./dist/frontend-links.d.ts"
23
+ },
24
+ "./assets": {
25
+ "import": "./dist/frontend-assets.js",
26
+ "types": "./dist/frontend-assets.d.ts"
27
+ },
28
+ "./worker-assets": {
29
+ "import": "./dist/worker-assets.js",
30
+ "types": "./dist/worker-assets.d.ts"
19
31
  }
20
32
  },
21
- "files": ["dist", "flowpress-deploy.php", "composer.json", "README.md", "CHANGELOG.md"],
33
+ "files": [
34
+ "dist",
35
+ "includes",
36
+ "docs",
37
+ "flowpress-deploy.php",
38
+ "composer.json",
39
+ "README.md",
40
+ "CHANGELOG.md"
41
+ ],
22
42
  "scripts": {
23
43
  "build": "tsc -b",
24
44
  "typecheck": "tsc -b --pretty false",
25
- "test": "vitest run",
45
+ "test": "vitest run && node --test tests/*-regressions.mjs && php tests/php/queue-provider-test.php",
26
46
  "release:check": "npm run typecheck && npm test && npm pack --dry-run",
27
47
  "release:publish:next": "npm publish --access public --tag next",
28
48
  "prepublishOnly": "npm run typecheck && npm test"
@@ -32,6 +52,8 @@
32
52
  "typescript": "^5.9.3",
33
53
  "vitest": "^3.2.4"
34
54
  },
35
- "engines": {"node": ">=22.0.0"},
55
+ "engines": {
56
+ "node": ">=22.0.0"
57
+ },
36
58
  "packageManager": "npm@10.9.3"
37
59
  }