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

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-beta.1
4
+
5
+ - Added the automatically selected `flowpress-deploy` Core queue provider.
6
+ - Added revision-safe queue, deployed state and batch snapshot tables.
7
+ - Added fingerprint deduplication, WPML post grouping, URI history and safe
8
+ success/failure/stale-batch handling.
9
+ - Added non-destructive migration from the Core database queue.
10
+ - Added end-of-request enqueue, manual editor actions and the expanded
11
+ **Strumenti → FlowPress Deploy** operations screen.
12
+ - Added complete internal documentation and PHP queue contract tests.
13
+
14
+ ## 1.0.0-beta.0
15
+
16
+ - Adopted the FlowPress WordPress Core v1 Composer contract.
17
+ - Preserved full/partial orchestration, path safety, baseline checks and
18
+ Cloudflare route validation.
19
+ - Removed the v0.x Core dependency from the v1 release line.
20
+
3
21
  ## 0.1.0-beta.0
4
22
 
5
23
  - Initial full/partial deployment contract, path safety and Cloudflare route
package/README.md CHANGED
@@ -1,11 +1,34 @@
1
1
  # FlowPress Deploy
2
2
 
3
- Installable npm/Composer feature pack for safe Cloudflare Pages and Workers
4
- build orchestration. CMS partial builds require a successful compatible
5
- baseline; code and manual triggers remain full builds.
3
+ Installable npm/Composer feature pack for revision-safe editorial queues and
4
+ safe full/partial build orchestration. WordPress owns a durable queue per
5
+ frontend target; frontend workflows keep the existing `batchId`, `target`,
6
+ `mode`, `paths` and `deletedPaths` wire contract.
6
7
 
7
8
  The public API validates paths and `_routes.json`, merges partial artifacts
8
9
  without traversal or case collisions, regenerates sitemaps, selects Pages for
9
10
  static targets and Workers for dev, requires `ASTRO_KEY` for protected batches,
10
11
  and acknowledges the CMS queue only after deploy and smoke test. Failed
11
12
  batches are released for retry.
13
+
14
+ ## WordPress queue
15
+
16
+ When the Composer package is installed, Deploy automatically becomes the
17
+ FlowPress Core queue provider. It fingerprints content at the end of the
18
+ request, groups WPML translations, keeps current and previous URIs and stores
19
+ a revision snapshot for every dispatched batch. A callback acknowledges only
20
+ the dispatched revision; concurrent editorial changes remain pending.
21
+
22
+ Editors can use **Aggiungi alla build queue** from edit screens and list rows.
23
+ Administrators operate the queue in **Strumenti → FlowPress Deploy**.
24
+
25
+ ## Documentation
26
+
27
+ Start with [docs/README.md](docs/README.md). It links configuration, queue
28
+ semantics, frontend integration, operations, recovery and troubleshooting.
29
+
30
+ ## WordPress Core v1
31
+
32
+ The `1.0.0-beta.x` line requires FlowPress WordPress Core v1. It does not
33
+ provide a v0.x compatibility constraint or adapter; canary applications pin
34
+ the selected Core prerelease exactly.
package/composer.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "flowpress/deploy",
3
- "description": "WordPress capability registration for FlowPress deploy.",
3
+ "description": "Revision-safe editorial build queue and deploy capability for FlowPress.",
4
4
  "type": "wordpress-muplugin",
5
5
  "license": "proprietary",
6
6
  "require": {
7
7
  "php": ">=8.1",
8
- "flowpress/wordpress-core": "^0.2.10"
8
+ "flowpress/wordpress-core": "^1.0.0@beta"
9
9
  },
10
10
  "extra": {"installer-name": "flowpress-deploy"}
11
11
  }
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # FlowPress Deploy: documentazione
2
+
3
+ FlowPress Deploy collega le modifiche editoriali di WordPress ai workflow di
4
+ build senza salvare token o contenuti nella propria coda.
5
+
6
+ ## Pagine
7
+
8
+ | Argomento | Documento |
9
+ | --- | --- |
10
+ | Modello e responsabilità | [Architettura](architecture.md) |
11
+ | Variabili richieste | [Configurazione](configuration.md) |
12
+ | Deduplica, URI e revisioni | [Queue WordPress](wordpress-queue.md) |
13
+ | Payload e callback | [Frontend e workflow](frontend-workflow.md) |
14
+ | Uso quotidiano | [Operazioni](operations.md) |
15
+ | Callback mancanti e retry | [Recovery](recovery.md) |
16
+ | Diagnostica | [Troubleshooting](troubleshooting.md) |
@@ -0,0 +1,9 @@
1
+ # Release iniziale deploy pack
2
+
3
+ - Obiettivo: estrarre build full/partial, baseline e guardie Cloudflare.
4
+ - Modifiche: package npm, MU-plugin Composer, test e workflow OIDC.
5
+ - Verifiche: typecheck, 7 test, npm pack, Composer validate e PHP lint positivi.
6
+ - Hardening pre-release: merge sicuro full/partial, sitemap, target
7
+ Pages/Workers, guardia `ASTRO_KEY` e lifecycle ack/release della queue.
8
+ - Workflow tag idempotente per il bootstrap della prima pubblicazione npm.
9
+ - Release prevista: `0.1.0-beta.0`; resta da configurare il Trusted Publisher npm.
@@ -0,0 +1,40 @@
1
+ # Deploy su WordPress Core v1
2
+
3
+ ## Esito
4
+
5
+ Preparata la major prerelease `1.0.0-beta.0` npm/Composer con dipendenza
6
+ limitata a `flowpress/wordpress-core ^1.0.0@beta` e senza adapter v0.x. Il
7
+ consumer canary mantiene il pin Core esatto.
8
+
9
+ ## Scope
10
+
11
+ La logica Deploy non cambia. Versione package, Composer contract, lockfile,
12
+ changelog e documentazione vengono pubblicati dallo stesso tag.
13
+
14
+ ## Verifiche
15
+
16
+ | Gate | Esito |
17
+ |---|---|
18
+ | `npm ci && npm run release:check` | OK; 7/7 test e pack 9 file |
19
+ | `npm audit` | OK; 0 vulnerabilita' dopo aggiornamento lockfile `nanoid` |
20
+ | `composer validate --strict --no-check-publish` | OK |
21
+ | PHP lint | OK |
22
+ | `git diff --check` | OK |
23
+
24
+ ## Pubblicazione verificata
25
+
26
+ - PR v1 unita su `main`; merge e `gitHead` npm:
27
+ `24e0033d78566890263249e56da3c15449e80897`.
28
+ - Workflow Publish `32747502576`: verde con trusted publishing e provenance.
29
+ - npm: `@styleflow.app/flowpress-deploy@1.0.0-beta.0`, dist-tag `next`;
30
+ integrity, shasum, tarball e firma presenti. `latest` resta sulla linea
31
+ precedente.
32
+ - Repman: `flowpress/deploy@v1.0.0-beta.0`, sincronizzato dallo stesso tag e
33
+ con requisito `flowpress/wordpress-core ^1.0.0@beta`.
34
+ - Canary DMO: installazione npm/Composer da registry, manifest coerente e gate
35
+ completo verde.
36
+
37
+ ## Rollback
38
+
39
+ La linea `0.1.0-beta.0` resta immutabile. I consumer v1 usano pin esatti e non
40
+ spostano `latest`.
@@ -0,0 +1,41 @@
1
+ # FlowPress Deploy: revision-safe editorial queue
2
+
3
+ - Data: 2026-08-26 09:26 Europe/Rome
4
+ - Repository: `StyleFlow-App/flowpress-deploy`
5
+ - Versione prevista: `1.0.0-beta.1`
6
+
7
+ ## Obiettivo
8
+
9
+ Rendere Deploy proprietario della queue editoriale tramite il provider Core,
10
+ senza cambiare Core e senza assumere configurazioni Bagful/Cloudflare.
11
+
12
+ ## Modifiche
13
+
14
+ - Provider automatico, schema queue/state/batch e migrazione Core non
15
+ distruttiva.
16
+ - Fingerprint, URI history, WPML grouping, snapshot e ack revision-safe.
17
+ - Enqueue a fine richiesta, azioni manuali, UI operativa e GitHub test.
18
+ - Documentazione completa in `docs/` e inclusione nel pacchetto npm.
19
+
20
+ ## Verifiche
21
+
22
+ - `php tests/php/queue-provider-test.php`: superato.
23
+ - `find . -name '*.php' ... php -l`: superato.
24
+ - `git diff --check`: superato.
25
+ - `composer validate --strict`: superato.
26
+ - `npm run release:check`: superato (typecheck, 7 test Vitest, test PHP,
27
+ pack dry-run con `docs/` e `includes/`).
28
+
29
+ ## Verifica manuale
30
+
31
+ La verifica integrata in WordPress DMO sarà registrata nel report DMO; nessun
32
+ dispatch o deploy remoto è autorizzato in questo task.
33
+
34
+ ## Commit suggerito
35
+
36
+ `feat: add revision-safe editorial build queue`
37
+
38
+ ## Rischi e follow-up
39
+
40
+ Il rollout richiede configurazione remota dei token e un test editoriale
41
+ controllato prima di abilitare la pubblicazione production.
@@ -0,0 +1,5 @@
1
+ # Indice attività
2
+
3
+ - [2026-08-26-0926-revision-safe-queue.md](2026-08-26-0926-revision-safe-queue.md)
4
+ - [2026-08-24-1745-wordpress-core-v1.md](2026-08-24-1745-wordpress-core-v1.md)
5
+ - [2026-07-30-2339-initial-pack.md](2026-07-30-2339-initial-pack.md)
@@ -0,0 +1,19 @@
1
+ # Architettura
2
+
3
+ ## Perché
4
+
5
+ Core espone il contratto di dispatch e callback; Deploy possiede lo stato
6
+ editoriale necessario a non perdere aggiornamenti concorrenti.
7
+
8
+ ## Come funziona
9
+
10
+ 1. Il plugin registra il provider `flowpress-deploy` e lo seleziona.
11
+ 2. Gli hook Core inoltrano post, termini e modifiche globali al provider.
12
+ 3. Gli update vengono materializzati a `shutdown`, dopo meta e tassonomie.
13
+ 4. Le tabelle `flowpress_deploy_queue`, `flowpress_deploy_state` e
14
+ `flowpress_deploy_batches` conservano rispettivamente lavoro, ultimo stato
15
+ distribuito e snapshot immutabile del batch.
16
+ 5. Core effettua repository dispatch e callback usando il wire contract
17
+ pubblico. Deploy applica l’ack alla sola revisione fotografata.
18
+
19
+ La coda conserva hash SHA-256 e URI, non il contenuto completo o credenziali.
@@ -0,0 +1,19 @@
1
+ # Configurazione
2
+
3
+ ## Perché
4
+
5
+ Target e credenziali sono configurazione di ambiente e non devono finire nel
6
+ database WordPress.
7
+
8
+ ## Come fare
9
+
10
+ 1. Definire `SF_STATIC_TARGETS_JSON`, per esempio `['production']` in JSON
11
+ valido.
12
+ 2. Definire `GITHUB_REPOSITORY_SLUG=organizzazione/repository`.
13
+ 3. Definire `CMS_GITHUB_DISPATCH_TOKEN` con il minimo accesso necessario al
14
+ repository.
15
+ 4. Definire `CMS_BUILD_CALLBACK_TOKEN` sia nel backend sia nel workflow.
16
+ 5. Aprire **Strumenti → FlowPress Deploy**: lo stato indica soltanto presenza
17
+ o assenza dei token, mai il loro valore.
18
+
19
+ ⚠️ Un target deve corrispondere esattamente a quello gestito dal workflow.
@@ -0,0 +1,23 @@
1
+ # Frontend e workflow
2
+
3
+ ## Wire contract
4
+
5
+ Il repository dispatch riceve:
6
+
7
+ ```json
8
+ {
9
+ "batchId": "uuid",
10
+ "target": "production",
11
+ "mode": "partial",
12
+ "paths": ["/pagina/"],
13
+ "deletedPaths": ["/vecchio-slug/"],
14
+ "queueIds": [12]
15
+ }
16
+ ```
17
+
18
+ Il workflow deve chiamare `POST /flowpress/v1/build-complete` con bearer token
19
+ e body `{"batchId":"uuid","status":"success"}` soltanto dopo build, deploy
20
+ e smoke test. In ogni altro caso invia `failure`.
21
+
22
+ ⚠️ L’assenza del callback non equivale a successo: il batch resta in-flight e
23
+ sarà rilasciato dal recovery.
@@ -0,0 +1,15 @@
1
+ # Operazioni
2
+
3
+ ## Come fare
4
+
5
+ 1. Aprire **Strumenti → FlowPress Deploy**.
6
+ 2. Verificare repository, token, righe pending, URI e batch in-flight.
7
+ 3. Espandere **Payload preview** prima dell’invio.
8
+ 4. Usare **Pubblica queue** per la modalità prevista.
9
+ 5. Usare **Force full** soltanto quando baseline o codice richiedono una build
10
+ completa; il browser chiede conferma.
11
+ 6. Per forzare un singolo contenuto usare **Aggiungi alla build queue** nella
12
+ schermata o nella lista di post/termini.
13
+
14
+ Il pulsante **Test GitHub** verifica autenticazione e accesso al repository,
15
+ senza inviare un deployment.
@@ -0,0 +1,16 @@
1
+ # Recovery
2
+
3
+ ## Callback failure
4
+
5
+ 1. Il callback `failure` libera il batch.
6
+ 2. Le righe tornano pending mantenendo l’ultima revisione editoriale.
7
+ 3. Correggere la causa nel workflow e ripubblicare la queue.
8
+
9
+ ## Callback mancante
10
+
11
+ 1. Il cron orario cerca batch in-flight più vecchi di sei ore.
12
+ 2. Il batch viene rilasciato come failure.
13
+ 3. Il limite è modificabile con `flowpress_deploy_stale_batch_hours`.
14
+
15
+ ⚠️ Non cancellare manualmente righe delle tre tabelle: si perderebbe la
16
+ corrispondenza fra stato distribuito e snapshot. Usare callback/retry.
@@ -0,0 +1,22 @@
1
+ # Troubleshooting
2
+
3
+ ## Queue vuota
4
+
5
+ Controllare `SF_STATIC_TARGETS_JSON`, tipo di contenuto instradabile e che il
6
+ salvataggio non sia autosave/revisione. Un salvataggio identico è correttamente
7
+ deduplicato.
8
+
9
+ ## URI vecchio non eliminato
10
+
11
+ Controllare `previous_uris` nella pagina Deploy e che il workflow consumi
12
+ `deletedPaths`.
13
+
14
+ ## Batch sempre in-flight
15
+
16
+ Verificare token callback e log del workflow. Attendere il recovery oppure
17
+ inviare esplicitamente `failure` per il `batchId` corretto.
18
+
19
+ ## GitHub rifiuta il test
20
+
21
+ Verificare slug repository, scadenza e permessi del token. La pagina non mostra
22
+ né registra il segreto.
@@ -0,0 +1,22 @@
1
+ # Queue WordPress
2
+
3
+ ## Perché
4
+
5
+ Salvataggi ripetuti, traduzioni e cambi slug non devono produrre build inutili
6
+ o lasciare file vecchi online.
7
+
8
+ ## Come funziona
9
+
10
+ 1. Autosave e revisioni sono esclusi da Core.
11
+ 2. Deploy raggruppa per post/term ID; con WPML usa il translation group.
12
+ 3. Il fingerprint include contenuto, meta non volatili e tassonomie. Il filtro
13
+ `flowpress_deploy_volatile_meta_keys` aggiunge esclusioni.
14
+ 4. `flowpress_deploy_fingerprint_fields`, `flowpress_deploy_group_key` e
15
+ `flowpress_deploy_canonical_uris` permettono estensioni senza patch.
16
+ 5. `previous_uris` resta ancorato allo stato distribuito: uno slug change
17
+ produce il nuovo path e inserisce quello vecchio in `deletedPaths`.
18
+ 6. Una modifica durante un batch incrementa la revisione. Il callback completa
19
+ lo snapshot, ma rimette la revisione nuova in pending.
20
+
21
+ La prima installazione importa le righe Core senza cancellarle; ogni vecchio
22
+ batch in-flight viene importato come pending per un retry sicuro.
@@ -1,21 +1,49 @@
1
1
  <?php
2
2
  /**
3
3
  * Plugin Name: FlowPress Deploy
4
- * Description: Installable deployment capability for FlowPress.
5
- * Version: 0.1.0-beta.0
4
+ * Description: Revision-safe editorial build queue and deployment capability for FlowPress.
5
+ * Version: 1.0.0-beta.1
6
6
  */
7
7
 
8
8
  if (! defined('ABSPATH')) {
9
9
  exit;
10
10
  }
11
11
 
12
+ define('FLOWPRESS_DEPLOY_VERSION', '1.0.0-beta.1');
13
+ define('FLOWPRESS_DEPLOY_FILE', __FILE__);
14
+
15
+ require_once __DIR__ . '/includes/queue-provider.php';
16
+ require_once __DIR__ . '/includes/manual-queue.php';
17
+ require_once __DIR__ . '/includes/admin.php';
18
+
19
+ add_action('muplugins_loaded', static function (): void {
20
+ if (! function_exists('flowpress_register_build_queue_provider')) {
21
+ return;
22
+ }
23
+ flowpress_register_build_queue_provider('flowpress-deploy', [
24
+ 'enqueue' => 'flowpress_deploy_collect_change',
25
+ 'payload' => 'flowpress_deploy_build_payload',
26
+ 'markDispatched' => 'flowpress_deploy_mark_dispatched',
27
+ 'complete' => 'flowpress_deploy_complete_batch',
28
+ 'pendingCount' => 'flowpress_deploy_pending_count',
29
+ ]);
30
+ }, 110);
31
+
32
+ add_filter('flowpress_build_queue_provider', static fn (): string => 'flowpress-deploy', 100);
33
+
12
34
  add_action('muplugins_loaded', static function (): void {
13
35
  if (! function_exists('flowpress_register_feature')) {
14
36
  return;
15
37
  }
16
38
  flowpress_register_feature('deploy', [
17
- 'version' => '0.1.0-beta.0',
18
- 'capabilities' => ['full-build', 'partial-build', 'build-callback'],
39
+ 'version' => FLOWPRESS_DEPLOY_VERSION,
40
+ 'capabilities' => [
41
+ 'full-build',
42
+ 'partial-build',
43
+ 'build-callback',
44
+ 'revision-safe-queue',
45
+ 'manual-enqueue',
46
+ ],
19
47
  'config' => [
20
48
  'targets' => function_exists('flowpress_static_targets')
21
49
  ? flowpress_static_targets()
@@ -0,0 +1,110 @@
1
+ <?php
2
+
3
+ if (! defined('ABSPATH')) {
4
+ exit;
5
+ }
6
+
7
+ add_action('admin_menu', static function (): void {
8
+ remove_submenu_page('tools.php', 'flowpress-builds');
9
+ add_management_page(
10
+ 'FlowPress Deploy',
11
+ 'FlowPress Deploy',
12
+ 'manage_options',
13
+ 'flowpress-builds',
14
+ 'flowpress_deploy_render_admin_page'
15
+ );
16
+ }, 100);
17
+
18
+ add_action('admin_post_flowpress_deploy_test_github', static function (): void {
19
+ if (! current_user_can('manage_options')) {
20
+ wp_die('Forbidden', 403);
21
+ }
22
+ check_admin_referer('flowpress_deploy_test_github');
23
+ $repository = flowpress_env('GITHUB_REPOSITORY_SLUG');
24
+ $token = flowpress_env('CMS_GITHUB_DISPATCH_TOKEN');
25
+ $response = ($repository !== '' && $token !== '')
26
+ ? wp_remote_get('https://api.github.com/repos/' . $repository, [
27
+ 'timeout' => 15,
28
+ 'headers' => [
29
+ 'Accept' => 'application/vnd.github+json',
30
+ 'Authorization' => 'Bearer ' . $token,
31
+ 'User-Agent' => 'FlowPress-Deploy/' . FLOWPRESS_DEPLOY_VERSION,
32
+ ],
33
+ ])
34
+ : new WP_Error('missing_config', 'Configurazione GitHub incompleta.');
35
+ $ok = ! is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200;
36
+ set_transient('flowpress_build_notice_' . get_current_user_id(), [
37
+ 'type' => $ok ? 'success' : 'error',
38
+ 'message' => $ok ? 'Connessione GitHub verificata.' : (is_wp_error($response) ? $response->get_error_message() : 'GitHub ha rifiutato la richiesta.'),
39
+ ], 60);
40
+ wp_safe_redirect(admin_url('tools.php?page=flowpress-builds'));
41
+ exit;
42
+ });
43
+
44
+ function flowpress_deploy_render_admin_page(): void
45
+ {
46
+ if (! current_user_can('manage_options')) {
47
+ return;
48
+ }
49
+ global $wpdb;
50
+ $notice = get_transient('flowpress_build_notice_' . get_current_user_id());
51
+ delete_transient('flowpress_build_notice_' . get_current_user_id());
52
+ $repository = flowpress_env('GITHUB_REPOSITORY_SLUG');
53
+ $has_dispatch_token = flowpress_env('CMS_GITHUB_DISPATCH_TOKEN') !== '';
54
+ $has_callback_token = flowpress_env('CMS_BUILD_CALLBACK_TOKEN') !== '';
55
+ echo '<div class="wrap"><h1>FlowPress Deploy</h1>';
56
+ if (is_array($notice)) {
57
+ printf('<div class="notice notice-%s"><p>%s</p></div>', esc_attr((string) $notice['type']), esc_html((string) $notice['message']));
58
+ }
59
+ echo '<p>La coda è deduplicata per contenuto logico e target. I token non vengono salvati né mostrati.</p>';
60
+ echo '<table class="widefat striped" style="max-width:900px"><tbody>';
61
+ printf('<tr><th>Repository GitHub</th><td>%s</td></tr>', esc_html($repository !== '' ? $repository : 'non configurato'));
62
+ printf('<tr><th>Token dispatch</th><td>%s</td></tr>', $has_dispatch_token ? 'configurato' : 'mancante');
63
+ printf('<tr><th>Token callback</th><td>%s</td></tr>', $has_callback_token ? 'configurato' : 'mancante');
64
+ echo '</tbody></table>';
65
+ echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:16px 0">';
66
+ wp_nonce_field('flowpress_deploy_test_github');
67
+ echo '<input type="hidden" name="action" value="flowpress_deploy_test_github">';
68
+ submit_button('Test GitHub', 'secondary', 'submit', false);
69
+ echo '</form>';
70
+ foreach (flowpress_static_targets() as $target) {
71
+ $payload = flowpress_deploy_build_payload($target, false);
72
+ $rows = (array) $wpdb->get_results($wpdb->prepare(
73
+ 'SELECT * FROM ' . flowpress_deploy_queue_table() . ' WHERE target = %s ORDER BY updated_at DESC LIMIT 100',
74
+ $target
75
+ ), ARRAY_A);
76
+ $in_flight = (int) $wpdb->get_var($wpdb->prepare(
77
+ "SELECT COUNT(*) FROM " . flowpress_deploy_batch_table() . " WHERE target = %s AND status = 'in_flight'",
78
+ $target
79
+ ));
80
+ echo '<section style="margin:20px 0;padding:16px;background:#fff;border:1px solid #ccd0d4">';
81
+ printf('<h2>%s <small>(%d pending, %d in-flight)</small></h2>', esc_html($target), flowpress_deploy_pending_count($target), $in_flight);
82
+ echo '<p>Modalità prevista: <strong>' . esc_html($payload['mode']) . '</strong></p>';
83
+ echo '<details><summary>Payload preview</summary><pre style="white-space:pre-wrap">' . esc_html((string) wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '</pre></details>';
84
+ echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin:12px 8px 12px 0">';
85
+ wp_nonce_field('flowpress_publish_build');
86
+ echo '<input type="hidden" name="action" value="flowpress_publish_build"><input type="hidden" name="target" value="' . esc_attr($target) . '">';
87
+ submit_button('Pubblica queue', 'primary', 'submit', false);
88
+ echo '</form>';
89
+ echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block" onsubmit="return confirm(\'Confermi la build completa?\')">';
90
+ wp_nonce_field('flowpress_publish_build');
91
+ echo '<input type="hidden" name="action" value="flowpress_publish_build"><input type="hidden" name="target" value="' . esc_attr($target) . '"><input type="hidden" name="force_full" value="1">';
92
+ submit_button('Force full', 'secondary', 'submit', false);
93
+ echo '</form>';
94
+ if ($rows !== []) {
95
+ echo '<table class="widefat striped"><thead><tr><th>Gruppo</th><th>URI correnti</th><th>URI precedenti</th><th>Rev.</th><th>Stato</th></tr></thead><tbody>';
96
+ foreach ($rows as $row) {
97
+ printf('<tr><td>%s</td><td>%s</td><td>%s</td><td>%d</td><td>%s</td></tr>',
98
+ esc_html((string) $row['group_key']),
99
+ esc_html(implode(', ', flowpress_deploy_decode_uris($row['current_uris'])) ?: '—'),
100
+ esc_html(implode(', ', flowpress_deploy_decode_uris($row['previous_uris'])) ?: '—'),
101
+ (int) $row['revision'],
102
+ $row['batch_id'] ? 'in-flight' : 'pending'
103
+ );
104
+ }
105
+ echo '</tbody></table>';
106
+ }
107
+ echo '</section>';
108
+ }
109
+ echo '</div>';
110
+ }
@@ -0,0 +1,78 @@
1
+ <?php
2
+
3
+ if (! defined('ABSPATH')) {
4
+ exit;
5
+ }
6
+
7
+ function flowpress_deploy_manual_url(string $type, int $id): string
8
+ {
9
+ return wp_nonce_url(
10
+ admin_url('admin-post.php?action=flowpress_deploy_enqueue&type=' . rawurlencode($type) . '&id=' . $id),
11
+ 'flowpress_deploy_enqueue_' . $type . '_' . $id
12
+ );
13
+ }
14
+
15
+ function flowpress_deploy_post_action(array $actions, WP_Post $post): array
16
+ {
17
+ if (current_user_can('edit_post', $post->ID)) {
18
+ $actions['flowpress_deploy_enqueue'] = '<a href="' . esc_url(flowpress_deploy_manual_url('post', $post->ID)) . '">Aggiungi alla build queue</a>';
19
+ }
20
+ return $actions;
21
+ }
22
+ add_filter('post_row_actions', 'flowpress_deploy_post_action', 20, 2);
23
+ add_filter('page_row_actions', 'flowpress_deploy_post_action', 20, 2);
24
+
25
+ add_action('post_submitbox_misc_actions', static function (WP_Post $post): void {
26
+ if (! current_user_can('edit_post', $post->ID)) {
27
+ return;
28
+ }
29
+ echo '<div class="misc-pub-section"><a class="button" href="'
30
+ . esc_url(flowpress_deploy_manual_url('post', $post->ID))
31
+ . '">Aggiungi alla build queue</a></div>';
32
+ });
33
+
34
+ add_filter('term_row_actions', static function (array $actions, WP_Term $term): array {
35
+ $taxonomy = get_taxonomy($term->taxonomy);
36
+ if ($taxonomy && current_user_can($taxonomy->cap->edit_terms)) {
37
+ $actions['flowpress_deploy_enqueue'] = '<a href="'
38
+ . esc_url(flowpress_deploy_manual_url('term-' . $term->taxonomy, $term->term_id))
39
+ . '">Aggiungi alla build queue</a>';
40
+ }
41
+ return $actions;
42
+ }, 20, 2);
43
+
44
+ add_action('admin_post_flowpress_deploy_enqueue', static function (): void {
45
+ $type = sanitize_key((string) ($_GET['type'] ?? ''));
46
+ $id = max(0, (int) ($_GET['id'] ?? 0));
47
+ check_admin_referer('flowpress_deploy_enqueue_' . $type . '_' . $id);
48
+ $uri = '';
49
+ $resource_type = '';
50
+ if ($type === 'post' && current_user_can('edit_post', $id)) {
51
+ $post = get_post($id);
52
+ if ($post instanceof WP_Post) {
53
+ $resource_type = $post->post_type;
54
+ $uri = (string) get_permalink($post);
55
+ }
56
+ } elseif (str_starts_with($type, 'term-')) {
57
+ $taxonomy = substr($type, 5);
58
+ $object = get_taxonomy($taxonomy);
59
+ if ($object && current_user_can($object->cap->edit_terms)) {
60
+ $term = get_term($id, $taxonomy);
61
+ $link = $term instanceof WP_Term ? get_term_link($term) : '';
62
+ if (! is_wp_error($link)) {
63
+ $resource_type = $taxonomy;
64
+ $uri = (string) $link;
65
+ }
66
+ }
67
+ }
68
+ if ($resource_type === '' || $uri === '') {
69
+ wp_die('Forbidden', 403);
70
+ }
71
+ flowpress_deploy_enqueue_change($resource_type, $id, $uri, 'update', false);
72
+ set_transient('flowpress_build_notice_' . get_current_user_id(), [
73
+ 'type' => 'success',
74
+ 'message' => 'Contenuto aggiunto alla build queue.',
75
+ ], 60);
76
+ wp_safe_redirect(wp_get_referer() ?: admin_url('tools.php?page=flowpress-builds'));
77
+ exit;
78
+ });
@@ -0,0 +1,619 @@
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 ($operation === 'delete') {
322
+ flowpress_deploy_enqueue_change($resource_type, $resource_id, $uri, 'delete', $force_full);
323
+ return;
324
+ }
325
+ $changes = &flowpress_deploy_collected_changes();
326
+ $changes[$resource_type . ':' . $resource_id] = [
327
+ 'resourceType' => $resource_type,
328
+ 'resourceId' => $resource_id,
329
+ 'uri' => $uri,
330
+ 'operation' => $operation,
331
+ 'forceFull' => $force_full,
332
+ ];
333
+ }
334
+
335
+ function flowpress_deploy_flush_changes(): void
336
+ {
337
+ $changes = &flowpress_deploy_collected_changes();
338
+ foreach ($changes as $change) {
339
+ flowpress_deploy_enqueue_change(
340
+ $change['resourceType'],
341
+ $change['resourceId'],
342
+ $change['uri'],
343
+ $change['operation'],
344
+ $change['forceFull']
345
+ );
346
+ }
347
+ $changes = [];
348
+ }
349
+ add_action('shutdown', 'flowpress_deploy_flush_changes', 0);
350
+
351
+ function flowpress_deploy_enqueue_change(
352
+ string $resource_type,
353
+ int $resource_id,
354
+ string $uri,
355
+ string $operation = 'update',
356
+ bool $force_full = false,
357
+ ?string $only_target = null
358
+ ): void {
359
+ global $wpdb;
360
+ $descriptor = flowpress_deploy_describe_resource($resource_type, $resource_id, $uri);
361
+ if ($operation === 'delete') {
362
+ $descriptor['uris'] = [];
363
+ $descriptor['fingerprint'] = hash('sha256', 'deleted:' . $descriptor['groupKey']);
364
+ }
365
+ $targets = $only_target !== null ? [$only_target] : flowpress_static_targets();
366
+ foreach ($targets as $target) {
367
+ $target = sanitize_key($target);
368
+ if ($target === '') {
369
+ continue;
370
+ }
371
+ $existing = $wpdb->get_row($wpdb->prepare(
372
+ 'SELECT * FROM ' . flowpress_deploy_queue_table() . ' WHERE target = %s AND group_key = %s',
373
+ $target,
374
+ $descriptor['groupKey']
375
+ ), ARRAY_A);
376
+ $state = $wpdb->get_row($wpdb->prepare(
377
+ 'SELECT * FROM ' . flowpress_deploy_state_table() . ' WHERE target = %s AND group_key = %s',
378
+ $target,
379
+ $descriptor['groupKey']
380
+ ), ARRAY_A);
381
+ if (
382
+ ! $force_full
383
+ && $operation !== 'delete'
384
+ && ! is_array($existing)
385
+ && is_array($state)
386
+ && hash_equals((string) $state['fingerprint'], $descriptor['fingerprint'])
387
+ && flowpress_deploy_decode_uris($state['current_uris']) === $descriptor['uris']
388
+ ) {
389
+ continue;
390
+ }
391
+ $previous = is_array($existing)
392
+ ? flowpress_deploy_decode_uris($existing['previous_uris'])
393
+ : (is_array($state) ? flowpress_deploy_decode_uris($state['current_uris']) : []);
394
+ if ($operation === 'delete' && $previous === []) {
395
+ $previous = flowpress_deploy_decode_uris([$uri]);
396
+ }
397
+ $now = current_time('mysql', true);
398
+ if (is_array($existing)) {
399
+ $wpdb->update(flowpress_deploy_queue_table(), [
400
+ 'resource_type' => $descriptor['resourceType'],
401
+ 'resource_id' => $descriptor['resourceId'],
402
+ 'current_uris' => flowpress_deploy_json($descriptor['uris']),
403
+ 'fingerprint' => $descriptor['fingerprint'],
404
+ 'revision' => (int) $existing['revision'] + 1,
405
+ 'force_full' => ($force_full || ! empty($existing['force_full'])) ? 1 : 0,
406
+ 'updated_at' => $now,
407
+ ], ['id' => (int) $existing['id']]);
408
+ } else {
409
+ $wpdb->insert(flowpress_deploy_queue_table(), [
410
+ 'target' => $target,
411
+ 'group_key' => $descriptor['groupKey'],
412
+ 'resource_type' => $descriptor['resourceType'],
413
+ 'resource_id' => $descriptor['resourceId'],
414
+ 'current_uris' => flowpress_deploy_json($descriptor['uris']),
415
+ 'previous_uris' => flowpress_deploy_json($previous),
416
+ 'fingerprint' => $descriptor['fingerprint'],
417
+ 'revision' => 1,
418
+ 'force_full' => $force_full ? 1 : 0,
419
+ 'created_at' => $now,
420
+ 'updated_at' => $now,
421
+ ]);
422
+ }
423
+ }
424
+ }
425
+
426
+ /** @return array<string,list<array<string,mixed>>> */
427
+ function &flowpress_deploy_payload_snapshots(): array
428
+ {
429
+ static $snapshots = [];
430
+ return $snapshots;
431
+ }
432
+
433
+ function flowpress_deploy_build_payload(string $target, bool $force_full = false): array
434
+ {
435
+ flowpress_deploy_flush_changes();
436
+ flowpress_deploy_recover_stale_batches();
437
+ global $wpdb;
438
+ $rows = (array) $wpdb->get_results($wpdb->prepare(
439
+ 'SELECT * FROM ' . flowpress_deploy_queue_table()
440
+ . ' WHERE target = %s AND batch_id IS NULL ORDER BY id ASC',
441
+ $target
442
+ ), ARRAY_A);
443
+ $paths = [];
444
+ $deleted = [];
445
+ $full = $force_full;
446
+ foreach ($rows as $row) {
447
+ $current = flowpress_deploy_decode_uris($row['current_uris']);
448
+ $previous = flowpress_deploy_decode_uris($row['previous_uris']);
449
+ $delta = flowpress_deploy_route_delta($previous, $current);
450
+ foreach ($delta['paths'] as $path) {
451
+ $paths[$path] = true;
452
+ unset($deleted[$path]);
453
+ }
454
+ foreach ($delta['deletedPaths'] as $path) {
455
+ if (! isset($paths[$path])) {
456
+ $deleted[$path] = true;
457
+ }
458
+ }
459
+ $full = $full || ! empty($row['force_full']);
460
+ }
461
+ $batch_id = wp_generate_uuid4();
462
+ $snapshots = &flowpress_deploy_payload_snapshots();
463
+ $snapshots[$batch_id] = $rows;
464
+ return [
465
+ 'batchId' => $batch_id,
466
+ 'target' => sanitize_key($target),
467
+ 'mode' => $full ? 'full' : 'partial',
468
+ 'paths' => array_keys($paths),
469
+ 'deletedPaths' => array_keys($deleted),
470
+ 'queueIds' => array_map('intval', array_column($rows, 'id')),
471
+ ];
472
+ }
473
+
474
+ function flowpress_deploy_mark_dispatched(array $payload): void
475
+ {
476
+ global $wpdb;
477
+ $batch_id = sanitize_text_field((string) ($payload['batchId'] ?? ''));
478
+ $snapshots = &flowpress_deploy_payload_snapshots();
479
+ $rows = $snapshots[$batch_id] ?? [];
480
+ unset($snapshots[$batch_id]);
481
+ $now = current_time('mysql', true);
482
+ $accepted = [];
483
+ foreach ($rows as $row) {
484
+ $updated = $wpdb->query($wpdb->prepare(
485
+ 'UPDATE ' . flowpress_deploy_queue_table()
486
+ . ' SET batch_id = %s, dispatched_revision = %d, dispatched_at = %s'
487
+ . ' WHERE id = %d AND revision = %d AND batch_id IS NULL',
488
+ $batch_id,
489
+ (int) $row['revision'],
490
+ $now,
491
+ (int) $row['id'],
492
+ (int) $row['revision']
493
+ ));
494
+ if ($updated) {
495
+ $accepted[] = $row;
496
+ }
497
+ }
498
+ $wpdb->replace(flowpress_deploy_batch_table(), [
499
+ 'batch_id' => $batch_id,
500
+ 'target' => sanitize_key((string) ($payload['target'] ?? '')),
501
+ 'mode' => ($payload['mode'] ?? '') === 'full' ? 'full' : 'partial',
502
+ 'snapshot' => (string) wp_json_encode($accepted, JSON_UNESCAPED_SLASHES),
503
+ 'status' => 'in_flight',
504
+ 'created_at' => $now,
505
+ ]);
506
+ }
507
+
508
+ function flowpress_deploy_complete_batch(string $batch_id, string $status): int
509
+ {
510
+ global $wpdb;
511
+ $batch = $wpdb->get_row($wpdb->prepare(
512
+ 'SELECT * FROM ' . flowpress_deploy_batch_table() . ' WHERE batch_id = %s',
513
+ $batch_id
514
+ ), ARRAY_A);
515
+ if (! is_array($batch)) {
516
+ return 0;
517
+ }
518
+ $rows = json_decode((string) $batch['snapshot'], true);
519
+ $rows = is_array($rows) ? $rows : [];
520
+ $affected = 0;
521
+ foreach ($rows as $snapshot) {
522
+ $current = $wpdb->get_row($wpdb->prepare(
523
+ 'SELECT * FROM ' . flowpress_deploy_queue_table() . ' WHERE id = %d',
524
+ (int) ($snapshot['id'] ?? 0)
525
+ ), ARRAY_A);
526
+ if (! is_array($current) || (string) $current['batch_id'] !== $batch_id) {
527
+ continue;
528
+ }
529
+ if ($status === 'success') {
530
+ $wpdb->replace(flowpress_deploy_state_table(), [
531
+ 'target' => (string) $snapshot['target'],
532
+ 'group_key' => (string) $snapshot['group_key'],
533
+ 'current_uris' => (string) $snapshot['current_uris'],
534
+ 'fingerprint' => (string) $snapshot['fingerprint'],
535
+ 'revision' => (int) $snapshot['revision'],
536
+ 'updated_at' => current_time('mysql', true),
537
+ ]);
538
+ }
539
+ $ack_action = flowpress_deploy_ack_action(
540
+ $status,
541
+ (int) $current['revision'],
542
+ (int) $snapshot['revision']
543
+ );
544
+ if ($ack_action === 'delete') {
545
+ $affected += (int) $wpdb->delete(flowpress_deploy_queue_table(), ['id' => (int) $current['id']]);
546
+ } else {
547
+ $affected += (int) $wpdb->update(flowpress_deploy_queue_table(), [
548
+ 'batch_id' => null,
549
+ 'dispatched_revision' => null,
550
+ 'dispatched_at' => null,
551
+ ], ['id' => (int) $current['id']]);
552
+ }
553
+ }
554
+ $wpdb->update(flowpress_deploy_batch_table(), [
555
+ 'status' => $status,
556
+ 'completed_at' => current_time('mysql', true),
557
+ ], ['batch_id' => $batch_id]);
558
+ return $affected;
559
+ }
560
+
561
+ function flowpress_deploy_pending_count(string $target): int
562
+ {
563
+ global $wpdb;
564
+ return (int) $wpdb->get_var($wpdb->prepare(
565
+ 'SELECT COUNT(*) FROM ' . flowpress_deploy_queue_table()
566
+ . ' WHERE target = %s AND batch_id IS NULL',
567
+ sanitize_key($target)
568
+ ));
569
+ }
570
+
571
+ function flowpress_deploy_recover_stale_batches(): int
572
+ {
573
+ global $wpdb;
574
+ $hours = max(1, (int) apply_filters('flowpress_deploy_stale_batch_hours', 6));
575
+ $cutoff = gmdate('Y-m-d H:i:s', time() - ($hours * HOUR_IN_SECONDS));
576
+ $batch_ids = (array) $wpdb->get_col($wpdb->prepare(
577
+ 'SELECT batch_id FROM ' . flowpress_deploy_batch_table()
578
+ . " WHERE status = 'in_flight' AND created_at < %s",
579
+ $cutoff
580
+ ));
581
+ $affected = 0;
582
+ foreach ($batch_ids as $batch_id) {
583
+ $affected += flowpress_deploy_complete_batch((string) $batch_id, 'failure');
584
+ }
585
+ return $affected;
586
+ }
587
+ add_action('flowpress_deploy_recover_stale_batches', 'flowpress_deploy_recover_stale_batches');
588
+ add_action('init', static function (): void {
589
+ if (! wp_next_scheduled('flowpress_deploy_recover_stale_batches')) {
590
+ wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'flowpress_deploy_recover_stale_batches');
591
+ }
592
+ }, 20);
593
+
594
+ function flowpress_deploy_migrate_core_queue(): void
595
+ {
596
+ if (get_option('flowpress_deploy_core_queue_migrated') === '1') {
597
+ return;
598
+ }
599
+ global $wpdb;
600
+ $core = $wpdb->prefix . 'flowpress_build_queue';
601
+ $like = method_exists($wpdb, 'esc_like') ? $wpdb->esc_like($core) : $core;
602
+ if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like)) !== $core) {
603
+ update_option('flowpress_deploy_core_queue_migrated', '1', false);
604
+ return;
605
+ }
606
+ $rows = (array) $wpdb->get_results("SELECT * FROM {$core} ORDER BY id ASC", ARRAY_A);
607
+ foreach ($rows as $row) {
608
+ $legacy = flowpress_deploy_normalize_legacy_row($row);
609
+ flowpress_deploy_enqueue_change(
610
+ $legacy['resourceType'],
611
+ $legacy['resourceId'],
612
+ $legacy['uri'],
613
+ $legacy['operation'],
614
+ $legacy['forceFull'],
615
+ $legacy['target']
616
+ );
617
+ }
618
+ update_option('flowpress_deploy_core_queue_migrated', '1', false);
619
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styleflow.app/flowpress-deploy",
3
- "version": "0.1.0-beta.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "Safe full and partial deployment primitives for FlowPress.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,11 +18,11 @@
18
18
  "import": "./dist/index.js"
19
19
  }
20
20
  },
21
- "files": ["dist", "flowpress-deploy.php", "composer.json", "README.md", "CHANGELOG.md"],
21
+ "files": ["dist", "includes", "docs", "flowpress-deploy.php", "composer.json", "README.md", "CHANGELOG.md"],
22
22
  "scripts": {
23
23
  "build": "tsc -b",
24
24
  "typecheck": "tsc -b --pretty false",
25
- "test": "vitest run",
25
+ "test": "vitest run && php tests/php/queue-provider-test.php",
26
26
  "release:check": "npm run typecheck && npm test && npm pack --dry-run",
27
27
  "release:publish:next": "npm publish --access public --tag next",
28
28
  "prepublishOnly": "npm run typecheck && npm test"