From 49c0db82f3ffa83a9bc9db0ee447e3c849c5e4cf Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:30:17 -0500 Subject: [PATCH] feat(purge): "!field" entries in onlyWhenFieldsChange ignore a field onlyWhenFieldsChange was only an allow-list, so a rule could not say "purge on any change except this derived counter" without listing every rendered field. An entry starting with "!" now names an ignored field: with only ignored entries, any other changed field still purges; with a mixed list the allow-list applies and ignored fields never count. Inserts and deletes are unaffected. No schema change. For kof22: the cms_blog_post rule gets "!viewCount" so the nightly view count refresh purges nothing (Website-Backend#206). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 16 ++++- .../kingsrook/qbits/cdn/model/CdnTables.java | 4 +- .../qbits/cdn/purge/TagRuleEngine.java | 26 +++++++- .../qbits/cdn/purge/PurgePipelineTest.java | 29 +++++++++ .../qbits/cdn/purge/TagRuleEngineTest.java | 61 +++++++++++++++++++ 6 files changed, 132 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 723dc59..a3daa8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project are documented here. The format follows Keep ### Fixed +- Tag rules accept ignored fields in `onlyWhenFieldsChange`, such as `!viewCount`, so counter-only updates do not purge rendered pages. Plain allow-lists still work, and ignored fields take precedence in mixed lists. No schema change is needed. - Purge actions warn when providers are NONE or hostnames are OFF, and report requests as recorded but not sent. The home explains recorded-only purges and that this version does not run origin header checks. - Tag rule `{prefix.field}` placeholders now resolve the joined table from the `Id` field's possible-value source, so prefixed tables (`cms_blog`) work; a missing joined table is logged instead of silently skipped. diff --git a/README.md b/README.md index 0754c09..f267167 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,21 @@ Design: QRun-IO/qqq `docs/specs/2026-10-12-qbit-cdn-design.md`. NONE. See `docs/cloudfront-expected-config.md`. - **Permissions:** `cdn.view`, `cdn.purge`, `cdn.purgeAll`, `cdn.admin`. -## Purge-path placeholders +## Tag rules + +`cdn_table_tag_rule.onlyWhenFieldsChange` is a comma-separated list of QQQ field +names. Plain entries (for example `title,slug`) purge only when at least one +listed field changes. Prefix a field with `!` to ignore it: `!viewCount` prevents +a view-count-only update from purging, while a title change still purges. +With `!viewCount, !lastIndexedAt`, any change outside those fields counts. +Mixed lists keep the allow-list and exclude ignored fields, even if also listed +plainly: `title,viewCount,!viewCount` counts only title changes. + +Names are trimmed and case-sensitive. Partial updates retain omitted old values; +only unequal old and new values count. This filter does not restrict inserts or +deletes. The separate `publishedField` filter still applies to all changes. + +### Purge-path placeholders `cdn_table_tag_rule` templates (`tagTemplates`, `invalidationPaths`) can use `{field}` for the changed record's own fields and `{prefix.field}` for a joined record, e.g. `/blog/{blog.slug}/{slug}*`. diff --git a/src/main/java/com/kingsrook/qbits/cdn/model/CdnTables.java b/src/main/java/com/kingsrook/qbits/cdn/model/CdnTables.java index 44a005f..20aa283 100644 --- a/src/main/java/com/kingsrook/qbits/cdn/model/CdnTables.java +++ b/src/main/java/com/kingsrook/qbits/cdn/model/CdnTables.java @@ -24,6 +24,7 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; @@ -273,7 +274,8 @@ public final class CdnTables .withField(new QFieldMetaData("tableName", QFieldType.STRING).withIsRequired(true).withMaxLength(100)) .withField(new QFieldMetaData("tagTemplates", QFieldType.TEXT)) .withField(new QFieldMetaData("invalidationPaths", QFieldType.TEXT)) - .withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000)) + .withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000) + .withHelpContent(new QHelpContent("Comma-separated, case-sensitive field names. Plain entries allow only those changes; !viewCount ignores viewCount changes. With only ignored entries, any other changed field counts. In mixed lists, ignored fields never count. Inserts and deletes are unaffected by this filter."))) .withField(new QFieldMetaData("publishedField", QFieldType.STRING).withMaxLength(100)) .withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true))); } diff --git a/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java b/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java index 7d68acd..6b204fe 100644 --- a/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java +++ b/src/main/java/com/kingsrook/qbits/cdn/purge/TagRuleEngine.java @@ -55,8 +55,8 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** expires by TTL instead of being invalidated; ** - publishedField: when set, only records that are (or were) published ** count, so draft edits purge nothing; - ** - onlyWhenFieldsChange: comma list; an update that changes none of them - ** purges nothing. + ** - onlyWhenFieldsChange: comma list of watched fields; !field ignores a + ** field. With only ignored fields, any other changed field counts. ** ** A dotted placeholder "{blog.slug}" reads field "slug" of the record that ** field "blogId" points to (the xId convention). The joined table is the one @@ -159,7 +159,27 @@ public class TagRuleEngine String watched = rule.getValueString("onlyWhenFieldsChange"); if(current != null && old != null && StringUtils.hasContent(watched)) { - return (Arrays.stream(watched.split(",")).map(String::strip).filter(StringUtils::hasContent) + Set included = new LinkedHashSet<>(); + Set ignored = new LinkedHashSet<>(); + for(String entry : watched.split(",")) + { + String field = entry.strip(); + if(field.startsWith("!")) + { + ignored.add(field.substring(1).strip()); + } + else if(StringUtils.hasContent(field)) + { + included.add(field); + } + } + if(included.isEmpty() && !ignored.isEmpty()) + { + included.addAll(current.getValues().keySet()); + included.addAll(old.getValues().keySet()); + } + included.removeAll(ignored); + return (included.stream() .anyMatch(field -> !Objects.equals(current.getValue(field), old.getValue(field)))); } return (true); diff --git a/src/test/java/com/kingsrook/qbits/cdn/purge/PurgePipelineTest.java b/src/test/java/com/kingsrook/qbits/cdn/purge/PurgePipelineTest.java index c7027f6..e7e3c9c 100644 --- a/src/test/java/com/kingsrook/qbits/cdn/purge/PurgePipelineTest.java +++ b/src/test/java/com/kingsrook/qbits/cdn/purge/PurgePipelineTest.java @@ -28,11 +28,14 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType; import com.kingsrook.qbits.cdn.spi.CdnPurgeKind; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -138,6 +141,32 @@ class PurgePipelineTest + /******************************************************************************* + ** The record-change listener skips counter updates but records title changes. + *******************************************************************************/ + @Test + void ignoredCounterChangeRecordsNoPurge() throws Exception + { + QContext.getQInstance().getTable("post").withField(new QFieldMetaData("viewCount", QFieldType.INTEGER)); + CdnTestSupport.liveProvider(CdnProviderType.CLOUDFRONT, null); + QRecord rule = new QueryAction().execute(new QueryInput(CdnTables.TABLE_TAG_RULE)).getRecords().get(0); + new UpdateAction().execute(new UpdateInput(CdnTables.TABLE_TAG_RULE).withRecord(new QRecord().withValue("id", rule.getValue("id")) + .withValue("onlyWhenFieldsChange", "!viewCount"))); + QRecord post = CdnTestSupport.insert("post", new QRecord().withValue("slug", "counter-post").withValue("title", "Old") + .withValue("blogId", 1).withValue("isPublished", true).withValue("viewCount", 10)).get(0); + assertThat(purges()).hasSize(1); + + new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("viewCount", 11))); + assertThat(purges()).as("counter-only update adds no purge request").hasSize(1); + + new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("title", "New"))); + assertThat(purges()).hasSize(2); + assertThat(purges().get(1).getValueString("paths")).contains("/blog/quick-bits/counter-post*"); + assertThat(purges().get(1).getValueString("tags")).contains("post:" + post.getValue("id")); + } + + + /******************************************************************************* ** Retryable failures back off and end DEAD; permanent ones go DEAD at once. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java b/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java index 2e686a7..2a6d452 100644 --- a/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java +++ b/src/test/java/com/kingsrook/qbits/cdn/purge/TagRuleEngineTest.java @@ -135,6 +135,67 @@ class TagRuleEngineTest + /******************************************************************************* + ** Ignore-only rules compare actual values after merging partial updates. + *******************************************************************************/ + @Test + void ignoresCounterOnlyUpdates() throws Exception + { + QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " !viewCount, !lastIndexedAt ").withValue("tagTemplates", "post:{id}"); + QRecord old = new QRecord().withValue("id", 1).withValue("title", "Old").withValue("viewCount", 10).withValue("lastIndexedAt", "yesterday"); + TagRuleEngine engine = new TagRuleEngine(); + assertTrue(engine.evaluate(rule, List.of(new QRecord(old)), List.of(old)).isEmpty()); + assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("viewCount", 11).withValue("lastIndexedAt", "today")), List.of(old)).isEmpty()); + assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "Old")), List.of(old)).isEmpty()); + assertThat(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "New").withValue("viewCount", 11)), List.of(old)).tags()).containsExactly("post:1"); + assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", null)), List.of(old)).isEmpty()); + assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("newField", "value")), List.of(old)).isEmpty()); + assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("ViewCount", 11)), List.of(old)).isEmpty()); + } + + + + /******************************************************************************* + ** Plain lists retain allow-list behavior; ignored entries always win. + *******************************************************************************/ + @Test + void appliesAllowListAndMixedRules() + { + QRecord old = new QRecord().withValue("title", "Old").withValue("viewCount", 10); + QRecord counterChange = new QRecord(old).withValue("viewCount", 11); + QRecord titleChange = new QRecord(old).withValue("title", "New"); + QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " title, ,slug "); + assertFalse(TagRuleEngine.counts(rule, counterChange, old)); + assertTrue(TagRuleEngine.counts(rule, titleChange, old)); + assertFalse(TagRuleEngine.counts(rule, new QRecord(old), old)); + rule.setValue("onlyWhenFieldsChange", "title, viewCount, !viewCount"); + assertFalse(TagRuleEngine.counts(rule, counterChange, old)); + assertTrue(TagRuleEngine.counts(rule, titleChange, old)); + assertFalse(TagRuleEngine.counts(rule, new QRecord(old).withValue("slug", "new"), old)); + rule.setValue("onlyWhenFieldsChange", "viewCount,!viewCount"); + assertFalse(TagRuleEngine.counts(rule, titleChange, old)); + assertFalse(TagRuleEngine.counts(rule, counterChange, old)); + } + + + + /******************************************************************************* + ** Changed-field filters never suppress inserts or deletes. + *******************************************************************************/ + @Test + void insertsAndDeletesIgnoreFieldFilters() throws Exception + { + QRecord post = new QRecord().withValue("id", 1).withValue("viewCount", 10); + for(String fields : List.of("!viewCount", "title", "title,!viewCount")) + { + QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", fields).withValue("tagTemplates", "post:{id}"); + assertThat(new TagRuleEngine().evaluate(rule, List.of(post), List.of()).tags()).containsExactly("post:1"); + assertThat(new TagRuleEngine().evaluate(rule, List.of(), List.of(post)).tags()).containsExactly("post:1"); + } + } + + + /******************************************************************************* ** Stored text round-trips; invalid lines are dropped. *******************************************************************************/ -- 2.54.0