From 7bb12ab86a1dfd6f8e7da8e0fc2a18e1651722cb Mon Sep 17 00:00:00 2001 From: Simon Woolf Date: Thu, 24 Sep 2026 18:53:10 +0100 Subject: [PATCH] feat(rules)!: create channel rules as match expressions Channel rules now come in two modes. A `matcher` rule's id is a match expression, with the same syntax as a capability resource ("chat:*", "*:presence", "foo:*:baz"); a `namespace` rule's id is a single segment that matches that channel plus everything beneath it. New rules should be matchers - the dashboard only creates matchers - so `rules create` now always does. `rules update` sends no mode, so a rule keeps the one it was created with. `create`, `update` and `list` show each rule's mode, since the same-looking id means different things in each. BREAKING CHANGE: `ably apps rules create "chat"` used to create a rule applying to `chat` and every channel beneath it (`chat:room1`, ...). It now applies to the channel `chat` alone. To select the channels beneath it, use `ably apps rules create "chat:*"`. Existing rules are unaffected and keep matching as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/apps/rules/create.ts | 15 +++++++----- src/commands/apps/rules/list.ts | 7 +++++- src/commands/apps/rules/update.ts | 1 + src/services/control-api.ts | 7 ++++++ src/utils/channel-rule-display.ts | 10 ++++++-- test/fixtures/control-api.ts | 1 + test/unit/commands/apps/rules/create.test.ts | 17 +++++++++++++ test/unit/commands/apps/rules/list.test.ts | 25 ++++++++++++++++++++ test/unit/commands/apps/rules/update.test.ts | 23 ++++++++++++++++++ 9 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/commands/apps/rules/create.ts b/src/commands/apps/rules/create.ts index d8e9bc8d0..8f83dd19b 100644 --- a/src/commands/apps/rules/create.ts +++ b/src/commands/apps/rules/create.ts @@ -9,17 +9,18 @@ export default class RulesCreateCommand extends ControlBaseCommand { static args = { ruleName: Args.string({ - description: "Name of the rule", + description: + 'Match expression selecting the channels the rule applies to, e.g. "chat:*" or "*:presence"', required: true, }), }; static examples = [ - '$ ably apps rules create "chat" --persisted', - '$ ably apps rules create "chat" --mutable-messages', - '$ ably apps rules create "events" --push-enabled', - '$ ably apps rules create "notifications" --persisted --push-enabled --app "My App"', - '$ ably apps rules create "chat" --persisted --json', + '$ ably apps rules create "chat:*" --persisted', + '$ ably apps rules create "chat:*" --mutable-messages', + '$ ably apps rules create "*:presence" --push-enabled', + '$ ably apps rules create "notifications:*" --persisted --push-enabled --app "My App"', + '$ ably apps rules create "chat:*" --persisted --json', ]; static flags = { @@ -119,6 +120,7 @@ export default class RulesCreateCommand extends ControlBaseCommand { conflationEnabled: flags["conflation-enabled"], conflationInterval: flags["conflation-interval"], conflationKey: flags["conflation-key"], + mode: "matcher" as const, mutableMessages, persistLast: flags["persist-last"], persisted, @@ -145,6 +147,7 @@ export default class RulesCreateCommand extends ControlBaseCommand { conflationKey: createdNamespace.conflationKey ?? null, created: new Date(createdNamespace.created).toISOString(), id: createdNamespace.id, + mode: createdNamespace.mode ?? null, modified: new Date(createdNamespace.modified).toISOString(), mutableMessages: createdNamespace.mutableMessages || false, persistLast: createdNamespace.persistLast || false, diff --git a/src/commands/apps/rules/list.ts b/src/commands/apps/rules/list.ts index cd3e3b1fd..fe41764d3 100644 --- a/src/commands/apps/rules/list.ts +++ b/src/commands/apps/rules/list.ts @@ -1,5 +1,8 @@ import { Flags } from "@oclif/core"; -import type { Namespace } from "../../../services/control-api.js"; +import type { + ChannelRuleMode, + Namespace, +} from "../../../services/control-api.js"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; @@ -18,6 +21,7 @@ interface ChannelRuleOutput { conflationKey: null | string; created: string; id: string; + mode: ChannelRuleMode | null; modified: string; mutableMessages: boolean; persistLast: boolean; @@ -76,6 +80,7 @@ export default class RulesListCommand extends ControlBaseCommand { conflationKey: rule.conflationKey ?? null, created: new Date(rule.created).toISOString(), id: rule.id, + mode: rule.mode ?? null, modified: new Date(rule.modified).toISOString(), mutableMessages: rule.mutableMessages || false, persistLast: rule.persistLast || false, diff --git a/src/commands/apps/rules/update.ts b/src/commands/apps/rules/update.ts index e1e0484d1..7a4cfb2bc 100644 --- a/src/commands/apps/rules/update.ts +++ b/src/commands/apps/rules/update.ts @@ -238,6 +238,7 @@ export default class RulesUpdateCommand extends ControlBaseCommand { conflationKey: updatedNamespace.conflationKey ?? null, created: new Date(updatedNamespace.created).toISOString(), id: updatedNamespace.id, + mode: updatedNamespace.mode ?? null, modified: new Date(updatedNamespace.modified).toISOString(), mutableMessages: updatedNamespace.mutableMessages || false, persistLast: updatedNamespace.persistLast || false, diff --git a/src/services/control-api.ts b/src/services/control-api.ts index 4a3690508..c29724d53 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -65,6 +65,11 @@ export interface Key { status: string; } +// `matcher` rules select channels by match expression (e.g. "chat:*"); +// `namespace` rules predate them and match a first segment plus everything +// beneath it. A rule's mode is fixed at creation. +export type ChannelRuleMode = "matcher" | "namespace"; + export interface Namespace { appId: string; authenticated?: boolean; @@ -76,6 +81,7 @@ export interface Namespace { created: number; exposeTimeSerial?: boolean; id: string; + mode?: ChannelRuleMode; modified: number; mutableMessages?: boolean; persistLast?: boolean; @@ -338,6 +344,7 @@ export class ControlApi { conflationInterval?: number; conflationKey?: string; exposeTimeSerial?: boolean; + mode?: ChannelRuleMode; mutableMessages?: boolean; persistLast?: boolean; persisted?: boolean; diff --git a/src/utils/channel-rule-display.ts b/src/utils/channel-rule-display.ts index edaf50f22..e14ca8d2d 100644 --- a/src/utils/channel-rule-display.ts +++ b/src/utils/channel-rule-display.ts @@ -37,10 +37,16 @@ export function formatChannelRuleDetails( showTimestamps = true, } = options; const bool = bold ? boolFieldBold : boolField; - const lines: string[] = [ + const lines: string[] = []; + + if (rule.mode) { + lines.push(`${indent}${formatLabel("Mode")} ${rule.mode}`); + } + + lines.push( `${indent}${formatLabel("Persisted")} ${bool(rule.persisted)}`, `${indent}${formatLabel("Push Enabled")} ${bool(rule.pushEnabled)}`, - ]; + ); if (rule.mutableMessages !== undefined) { lines.push( diff --git a/test/fixtures/control-api.ts b/test/fixtures/control-api.ts index ea64ca834..b21db1a2f 100644 --- a/test/fixtures/control-api.ts +++ b/test/fixtures/control-api.ts @@ -134,6 +134,7 @@ export interface MockNamespace { id: string; persisted: boolean; pushEnabled: boolean; + mode?: "matcher" | "namespace"; mutableMessages?: boolean; created: number; modified: number; diff --git a/test/unit/commands/apps/rules/create.test.ts b/test/unit/commands/apps/rules/create.test.ts index 5fa78033b..678ca8a5e 100644 --- a/test/unit/commands/apps/rules/create.test.ts +++ b/test/unit/commands/apps/rules/create.test.ts @@ -157,6 +157,23 @@ describe("apps:rules:create command", () => { expect(result!.rule).toHaveProperty("persisted", true); expect(result!.rule).toHaveProperty("mutableMessages", true); }); + + it("should always create a matcher-mode rule", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.id === "chat:*" && body.mode === "matcher"; + }) + .reply(201, mockNamespace({ id: "chat:*", mode: "matcher" })); + + const { stdout } = await runCommand( + ["apps:rules:create", "chat:*", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result.rule).toHaveProperty("mode", "matcher"); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/apps/rules/list.test.ts b/test/unit/commands/apps/rules/list.test.ts index b9866cbf9..66f07168a 100644 --- a/test/unit/commands/apps/rules/list.test.ts +++ b/test/unit/commands/apps/rules/list.test.ts @@ -122,6 +122,31 @@ describe("apps:rules:list command", () => { expect(result).toHaveProperty("success", true); expect(result.rules).toHaveLength(2); }); + + it("should show each rule's mode", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .times(2) + .reply(200, [ + mockNamespace({ id: "chat:*", mode: "matcher" }), + mockNamespace({ id: "legacy", mode: "namespace" }), + ]); + + const { stdout } = await runCommand(["apps:rules:list"], import.meta.url); + expect(stdout).toContain("Mode: matcher"); + expect(stdout).toContain("Mode: namespace"); + + const { stdout: jsonStdout } = await runCommand( + ["apps:rules:list", "--json"], + import.meta.url, + ); + const result = parseJsonOutput(jsonStdout); + expect(result.rules.map((r: { mode: string }) => r.mode)).toEqual([ + "matcher", + "namespace", + ]); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/apps/rules/update.test.ts b/test/unit/commands/apps/rules/update.test.ts index 4b571d20f..f494f0430 100644 --- a/test/unit/commands/apps/rules/update.test.ts +++ b/test/unit/commands/apps/rules/update.test.ts @@ -209,6 +209,29 @@ describe("apps:rules:update command", () => { expect(result).toHaveProperty("success", true); expect(result!.rule).toHaveProperty("persisted", true); }); + + it("should not send a mode, so an older namespace-mode rule stays one", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId, mode: "namespace" })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { + return body.persisted === true && !("mode" in body); + }) + .reply( + 200, + mockNamespace({ id: mockRuleId, mode: "namespace", persisted: true }), + ); + + const { stdout } = await runCommand( + ["apps:rules:update", mockRuleId, "--persisted"], + import.meta.url, + ); + + expect(stdout).toContain("Mode: namespace"); + }); }); describe("error handling", () => {