@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.
- package/CHANGELOG.md +18 -0
- package/README.md +3 -14
- package/composer.json +1 -1
- package/dist/frontend-assets.d.ts +2 -0
- package/dist/frontend-assets.d.ts.map +1 -0
- package/dist/frontend-assets.js +44 -0
- package/dist/frontend-assets.js.map +1 -0
- package/dist/frontend-links.d.ts +14 -0
- package/dist/frontend-links.d.ts.map +1 -0
- package/dist/frontend-links.js +82 -0
- package/dist/frontend-links.js.map +1 -0
- package/dist/worker-assets.d.ts +5 -0
- package/dist/worker-assets.d.ts.map +1 -0
- package/dist/worker-assets.js +35 -0
- package/dist/worker-assets.js.map +1 -0
- package/docs/README.md +16 -0
- package/docs/activities/2026-07-30-2339-initial-pack.md +9 -0
- package/docs/activities/2026-08-24-1745-wordpress-core-v1.md +40 -0
- package/docs/activities/2026-08-26-0926-revision-safe-queue.md +41 -0
- package/docs/activities/README.md +5 -0
- package/docs/architecture.md +19 -0
- package/docs/configuration.md +19 -0
- package/docs/frontend-workflow.md +23 -0
- package/docs/operations.md +15 -0
- package/docs/recovery.md +16 -0
- package/docs/troubleshooting.md +22 -0
- package/docs/wordpress-queue.md +22 -0
- package/flowpress-deploy.php +33 -4
- package/includes/admin.php +110 -0
- package/includes/automatic-build.php +81 -0
- package/includes/manual-queue.php +78 -0
- package/includes/queue-provider.php +621 -0
- package/package.json +26 -4
|
@@ -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.
|
|
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": [
|
|
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": {
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.0.0"
|
|
57
|
+
},
|
|
36
58
|
"packageManager": "npm@10.9.3"
|
|
37
59
|
}
|