@ironflow/browser 0.31.0 → 0.33.0

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/dist/client.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Singleton client for browser-based real-time interactions with Ironflow.
5
5
  */
6
- import { NotConfiguredError, IronflowError, RunFailedError, RunCancelledError, UnauthenticatedError, EnterpriseRequiredError, UnauthorizedError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, TriggerSyncResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, peelProjectionEnvelope, webhookVerifyConfigToWire, webhookGraceToWire, webhookSourceFromWire, webhookDeliveryFromWire, } from "@ironflow/core";
7
- import { mergeConfig } from "./config.js";
6
+ import { NotConfiguredError, IronflowError, RunFailedError, RunCancelledError, UnauthenticatedError, EnterpriseRequiredError, UnauthorizedError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, TriggerSyncResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, peelProjectionEnvelope, webhookVerifyConfigToWire, webhookGraceToWire, webhookSourceFromWire, webhookDeliveryFromWire, registeredFunctionFromWire, functionHistoryEntryFromWire, storedEventFromWire, runStepFromWire, consumerGroupFromWire, } from "@ironflow/core";
7
+ import { mergeConfig, warnAboutLongLivedBrowserKey } from "./config.js";
8
8
  import { SubscriptionManager, } from "./subscription.js";
9
9
  import { createWebSocketTransport } from "./transport/websocket.js";
10
10
  import { createConnectRPCTransport } from "./transport/connectrpc.js";
@@ -12,6 +12,18 @@ import { filterWaitStreamFrames } from "./projection-stream.js";
12
12
  import { BrowserKVClient } from "./kv.js";
13
13
  import { BrowserConfigClient } from "./config-client.js";
14
14
  import { createAgentsNamespace } from "./agents/index.js";
15
+ function auditEventFromWire(raw) {
16
+ return {
17
+ id: String(raw.id ?? ""),
18
+ runId: String(raw.run_id ?? raw.runId ?? ""),
19
+ functionId: String(raw.function_id ?? raw.functionId ?? ""),
20
+ stepId: (raw.step_id ?? raw.stepId),
21
+ eventType: String(raw.event_type ?? raw.eventType ?? ""),
22
+ payload: raw.payload ?? {},
23
+ metadata: raw.metadata,
24
+ createdAt: String(raw.created_at ?? raw.createdAt ?? ""),
25
+ };
26
+ }
15
27
  /**
16
28
  * Key for the offline queue's replay entry point.
17
29
  *
@@ -71,6 +83,7 @@ class IronflowClient {
71
83
  */
72
84
  configure(options = {}) {
73
85
  this.config = mergeConfig(options);
86
+ warnAboutLongLivedBrowserKey(this.config.auth);
74
87
  // Set up logger
75
88
  if (this.config.logger === false) {
76
89
  this.logger = createNoopLogger();
@@ -326,6 +339,21 @@ class IronflowClient {
326
339
  const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/GetRun", { id: runId });
327
340
  return this.mapRunResponse(response);
328
341
  }
342
+ /** Get the durable steps recorded for a run. */
343
+ async getRunSteps(runId) {
344
+ this.ensureConfigured();
345
+ const response = await this.restRequest("GET", `/api/v1/runs/${encodeURIComponent(runId)}/steps`);
346
+ return {
347
+ steps: (response.steps ?? []).map(runStepFromWire),
348
+ count: response.count ?? 0,
349
+ };
350
+ }
351
+ /** Get the entity stream IDs touched by a run. */
352
+ async getRunStreams(runId) {
353
+ this.ensureConfigured();
354
+ const response = await this.restRequest("GET", `/api/v1/runs/${encodeURIComponent(runId)}/streams`);
355
+ return { entityIds: response.entity_ids ?? [] };
356
+ }
329
357
  /**
330
358
  * List runs with filtering
331
359
  */
@@ -671,9 +699,61 @@ class IronflowClient {
671
699
  clearTimeout(timeoutId);
672
700
  }
673
701
  }
674
- /**
675
- * List registered functions
676
- */
702
+ /** Get a registered function by ID. */
703
+ async getFunction(functionId) {
704
+ this.ensureConfigured();
705
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/GetFunction", { id: functionId });
706
+ return registeredFunctionFromWire(response);
707
+ }
708
+ /** Change a function's lifecycle status. */
709
+ async updateFunctionStatus(functionId, status) {
710
+ this.ensureConfigured();
711
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/UpdateFunctionStatus", {
712
+ id: functionId,
713
+ status: `FUNCTION_STATUS_${status.toUpperCase()}`,
714
+ });
715
+ return registeredFunctionFromWire(response);
716
+ }
717
+ /** Permanently delete a registered function. */
718
+ async deleteFunction(functionId) {
719
+ this.ensureConfigured();
720
+ await this.streamRequest("/ironflow.v1.IronflowService/DeleteFunction", { id: functionId });
721
+ }
722
+ /** List immutable configuration snapshots, newest first. */
723
+ async listFunctionHistory(functionId, options = {}) {
724
+ this.ensureConfigured();
725
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/ListFunctionHistory", {
726
+ functionId,
727
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
728
+ ...(options.fromVersion !== undefined
729
+ ? { fromVersion: String(options.fromVersion) }
730
+ : {}),
731
+ });
732
+ return {
733
+ entries: (response.entries ?? []).map(functionHistoryEntryFromWire),
734
+ hasMore: response.hasMore ?? false,
735
+ };
736
+ }
737
+ /** Get one historical configuration snapshot by entity version. */
738
+ async getFunctionAtVersion(functionId, version) {
739
+ this.ensureConfigured();
740
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/GetFunctionAtVersion", {
741
+ functionId,
742
+ version: String(version),
743
+ });
744
+ return functionHistoryEntryFromWire(response.entry);
745
+ }
746
+ /** Restore a function's configuration from a historical version. */
747
+ async rollbackFunction(functionId, version, changeReason) {
748
+ this.ensureConfigured();
749
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/RollbackFunction", {
750
+ functionId,
751
+ version: String(version),
752
+ ...(changeReason ? { changeReason } : {}),
753
+ });
754
+ return registeredFunctionFromWire(response.function);
755
+ }
756
+ /** List registered functions. */
677
757
  async listFunctions() {
678
758
  this.ensureConfigured();
679
759
  const url = `${this.config.serverUrl}/api/v1/functions`;
@@ -804,6 +884,104 @@ class IronflowClient {
804
884
  eventId: response.eventId,
805
885
  };
806
886
  }
887
+ /**
888
+ * Publish a message to a developer pub/sub topic.
889
+ * Unlike emit(), this does not trigger workflow functions.
890
+ */
891
+ async publish(topic, data, options) {
892
+ this.ensureConfigured();
893
+ const response = await this.streamRequest("/ironflow.v1.PubSubService/Publish", {
894
+ topic,
895
+ data: data ?? {},
896
+ ...(options?.idempotencyKey
897
+ ? { idempotencyKey: options.idempotencyKey }
898
+ : {}),
899
+ });
900
+ return {
901
+ eventId: response.eventId,
902
+ sequence: Number(response.sequence) || 0,
903
+ };
904
+ }
905
+ /** Trigger multiple events in one request. */
906
+ async triggerBatch(events) {
907
+ this.ensureConfigured();
908
+ const response = await this.streamRequest("/ironflow.v1.IronflowService/TriggerBatch", {
909
+ events: events.map((event) => ({
910
+ event: event.event,
911
+ data: event.data,
912
+ ...(event.version !== undefined ? { version: event.version } : {}),
913
+ ...(event.idempotencyKey
914
+ ? { idempotencyKey: event.idempotencyKey }
915
+ : {}),
916
+ ...(event.metadata ? { metadata: event.metadata } : {}),
917
+ })),
918
+ });
919
+ return (response.results ?? []).map((result) => ({
920
+ runIds: result.runIds ?? [],
921
+ eventId: result.eventId,
922
+ }));
923
+ }
924
+ /** Read a keyset-paginated page from the event log. */
925
+ async listEvents(options = {}) {
926
+ this.ensureConfigured();
927
+ const query = new URLSearchParams();
928
+ if (options.name)
929
+ query.set("name", options.name);
930
+ if (options.names?.length)
931
+ query.set("names", options.names.join(","));
932
+ if (options.sources?.length)
933
+ query.set("source", options.sources.join(","));
934
+ if (options.search)
935
+ query.set("search", options.search);
936
+ if (options.since)
937
+ query.set("since", options.since);
938
+ if (options.until)
939
+ query.set("until", options.until);
940
+ if (options.limit !== undefined)
941
+ query.set("limit", String(options.limit));
942
+ if (options.cursor)
943
+ query.set("cursor", options.cursor);
944
+ if (options.before)
945
+ query.set("before", options.before);
946
+ const suffix = query.size ? `?${query}` : "";
947
+ const response = await this.restRequest("GET", `/api/v1/events${suffix}`);
948
+ return {
949
+ events: (response.events ?? []).map(storedEventFromWire),
950
+ count: response.count ?? 0,
951
+ limit: response.limit ?? options.limit ?? 20,
952
+ nextCursor: response.next_cursor,
953
+ prevCursor: response.prev_cursor,
954
+ hasNext: response.has_next ?? false,
955
+ hasPrev: response.has_prev ?? false,
956
+ approxTotal: response.approx_total,
957
+ approxTotalCapped: response.approx_total_capped ?? false,
958
+ };
959
+ }
960
+ /** Get one persisted event by ID. */
961
+ async getEvent(eventId) {
962
+ this.ensureConfigured();
963
+ const response = await this.restRequest("GET", `/api/v1/events/${encodeURIComponent(eventId)}`);
964
+ return storedEventFromWire(response);
965
+ }
966
+ /** List event names and counts for filter UIs. */
967
+ async listEventNames(options = {}) {
968
+ this.ensureConfigured();
969
+ const query = new URLSearchParams();
970
+ if (options.sources?.length)
971
+ query.set("source", options.sources.join(","));
972
+ if (options.since)
973
+ query.set("since", options.since);
974
+ if (options.until)
975
+ query.set("until", options.until);
976
+ const suffix = query.size ? `?${query}` : "";
977
+ const response = await this.restRequest("GET", `/api/v1/events/names${suffix}`);
978
+ return {
979
+ names: response.names ?? [],
980
+ scanned: response.scanned ?? 0,
981
+ truncated: response.truncated ?? false,
982
+ scanCap: response.scan_cap ?? 0,
983
+ };
984
+ }
807
985
  /**
808
986
  * Emit an event synchronously — waits for the triggered run to complete and returns the result.
809
987
  *
@@ -857,6 +1035,84 @@ class IronflowClient {
857
1035
  });
858
1036
  return sub;
859
1037
  }
1038
+ /** Consumer-group lifecycle management. */
1039
+ consumerGroups = {
1040
+ create: async (config) => {
1041
+ this.ensureConfigured();
1042
+ const response = await this.streamRequest("/ironflow.v1.PubSubService/CreateConsumerGroup", {
1043
+ name: config.name,
1044
+ pattern: config.pattern,
1045
+ namespace: config.namespace ?? "default",
1046
+ ...(config.filterExpr !== undefined ? { filter_expr: config.filterExpr } : {}),
1047
+ ...(config.ackMode ? { ack_mode: `ACK_MODE_${config.ackMode.toUpperCase()}` } : {}),
1048
+ ...(config.backpressure
1049
+ ? { backpressure: `BACKPRESSURE_MODE_${config.backpressure.toUpperCase()}` }
1050
+ : {}),
1051
+ ...(config.maxInflight !== undefined ? { max_inflight: config.maxInflight } : {}),
1052
+ ...(config.maxRedeliveries !== undefined
1053
+ ? { max_redeliveries: config.maxRedeliveries }
1054
+ : {}),
1055
+ ...(config.redeliverDelayMs !== undefined
1056
+ ? { redeliver_delay_ms: config.redeliverDelayMs }
1057
+ : {}),
1058
+ ...(config.metadata !== undefined ? { metadata: config.metadata } : {}),
1059
+ });
1060
+ return consumerGroupFromWire(response);
1061
+ },
1062
+ get: async (name, namespace = "default") => {
1063
+ this.ensureConfigured();
1064
+ const response = await this.streamRequest("/ironflow.v1.PubSubService/GetConsumerGroup", { name, namespace });
1065
+ return consumerGroupFromWire(response);
1066
+ },
1067
+ list: async (namespace) => {
1068
+ this.ensureConfigured();
1069
+ const groups = [];
1070
+ let cursor;
1071
+ do {
1072
+ const response = await this.streamRequest("/ironflow.v1.PubSubService/ListConsumerGroups", {
1073
+ ...(namespace ? { namespace } : {}),
1074
+ limit: 100,
1075
+ ...(cursor ? { cursor } : {}),
1076
+ });
1077
+ groups.push(...(response.groups ?? []).map(consumerGroupFromWire));
1078
+ cursor = response.nextCursor || response.next_cursor || undefined;
1079
+ } while (cursor);
1080
+ return groups;
1081
+ },
1082
+ update: async (name, input, namespace = "default") => {
1083
+ this.ensureConfigured();
1084
+ const group = { name, namespace };
1085
+ const paths = [];
1086
+ const set = (path, value) => {
1087
+ if (value !== undefined) {
1088
+ group[path] = value;
1089
+ paths.push(path);
1090
+ }
1091
+ };
1092
+ set("pattern", input.pattern);
1093
+ set("filter_expr", input.filterExpr);
1094
+ set("ack_mode", input.ackMode ? `ACK_MODE_${input.ackMode.toUpperCase()}` : undefined);
1095
+ set("backpressure", input.backpressure
1096
+ ? `BACKPRESSURE_MODE_${input.backpressure.toUpperCase()}`
1097
+ : undefined);
1098
+ set("max_inflight", input.maxInflight);
1099
+ set("max_redeliveries", input.maxRedeliveries);
1100
+ set("redeliver_delay_ms", input.redeliverDelayMs);
1101
+ set("metadata", input.metadata);
1102
+ set("status", input.status
1103
+ ? `CONSUMER_GROUP_STATUS_${input.status.toUpperCase()}`
1104
+ : undefined);
1105
+ if (paths.length === 0) {
1106
+ throw new ValidationError("Consumer group update requires at least one field");
1107
+ }
1108
+ const response = await this.streamRequest("/ironflow.v1.PubSubService/UpdateConsumerGroup", { group, update_mask: { paths } });
1109
+ return consumerGroupFromWire(response);
1110
+ },
1111
+ delete: async (name, namespace = "default") => {
1112
+ this.ensureConfigured();
1113
+ await this.streamRequest("/ironflow.v1.PubSubService/DeleteConsumerGroup", { name, namespace });
1114
+ },
1115
+ };
860
1116
  // ============================================================================
861
1117
  // Entity Streams
862
1118
  // ============================================================================
@@ -995,6 +1251,29 @@ class IronflowClient {
995
1251
  createdAt: response.createdAt ?? "",
996
1252
  };
997
1253
  },
1254
+ /** List entity streams visible in the current environment. */
1255
+ listStreams: async () => {
1256
+ this.ensureConfigured();
1257
+ const response = await this.restRequest("GET", "/api/v1/streams");
1258
+ return (response.streams ?? []).map((stream) => ({
1259
+ entityId: String(stream.entity_id ?? stream.entityId ?? ""),
1260
+ entityType: String(stream.entity_type ?? stream.entityType ?? ""),
1261
+ version: Number(stream.version ?? 0),
1262
+ eventCount: Number(stream.event_count ?? stream.eventCount ?? 0),
1263
+ lastEventAt: String(stream.last_event_at ?? stream.lastEventAt ?? stream.updated_at ?? ""),
1264
+ }));
1265
+ },
1266
+ /** Get the unified event history for an entity. */
1267
+ getEntityHistory: async (entityId) => {
1268
+ this.ensureConfigured();
1269
+ const response = await this.restRequest("GET", `/api/v1/streams/${encodeURIComponent(entityId)}/history`);
1270
+ return (response.entries ?? []).map((entry) => ({
1271
+ eventName: String(entry.event_name ?? entry.eventName ?? ""),
1272
+ data: entry.event_data ?? entry.data,
1273
+ version: Number(entry.entity_version ?? entry.version ?? 0),
1274
+ timestamp: String(entry.timestamp ?? ""),
1275
+ }));
1276
+ },
998
1277
  /**
999
1278
  * Subscribe to real-time events for an entity stream
1000
1279
  *
@@ -1156,46 +1435,6 @@ class IronflowClient {
1156
1435
  clearTimeout(timeoutId);
1157
1436
  }
1158
1437
  }
1159
- /**
1160
- * Trigger a rebuild of a projection
1161
- *
1162
- * @example
1163
- * ```typescript
1164
- * const result = await ironflow.rebuildProjection('order-stats');
1165
- * ```
1166
- */
1167
- async rebuildProjection(name, options) {
1168
- this.ensureConfigured();
1169
- const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/rebuild`;
1170
- const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1171
- const controller = new AbortController();
1172
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1173
- try {
1174
- const headers = {
1175
- "Content-Type": "application/json",
1176
- [HEADERS.ENVIRONMENT]: this.config.environment,
1177
- };
1178
- this.applyAuthHeader(headers);
1179
- const response = await fetch(url, {
1180
- method: "POST",
1181
- headers,
1182
- body: JSON.stringify({
1183
- partition: options?.partition,
1184
- from_event_id: options?.fromEventId,
1185
- dry_run: options?.dryRun,
1186
- }),
1187
- signal: controller.signal,
1188
- });
1189
- if (!response.ok) {
1190
- const error = safeJsonParse(await response.text());
1191
- throw new IronflowError(error?.message || `Rebuild projection failed: ${response.status}`, { code: error?.code || "REBUILD_PROJECTION_FAILED" });
1192
- }
1193
- return response.json();
1194
- }
1195
- finally {
1196
- clearTimeout(timeoutId);
1197
- }
1198
- }
1199
1438
  /**
1200
1439
  * Wait until the named projection has processed events up to `minSeq`,
1201
1440
  * or the timeout elapses. Read-your-writes primitive for CQRS UIs:
@@ -1461,11 +1700,8 @@ class IronflowClient {
1461
1700
  * @example
1462
1701
  * ```typescript
1463
1702
  * const cfg = ironflow.configManager();
1464
- * await cfg.set("app", { featureX: true, maxRetries: 3 });
1465
1703
  * const { data, revision } = await cfg.get("app");
1466
- * await cfg.patch("app", { maxRetries: 5 });
1467
1704
  * const configs = await cfg.list();
1468
- * await cfg.delete("app");
1469
1705
  *
1470
1706
  * // Watch for changes
1471
1707
  * const sub = await cfg.watch("app", {
@@ -1652,6 +1888,12 @@ class IronflowClient {
1652
1888
  this.ensureConfigured();
1653
1889
  await this.restRequest("DELETE", `/api/v1/roles/${encodeURIComponent(roleId)}/policies/${encodeURIComponent(policyId)}`);
1654
1890
  },
1891
+ /** List policies assigned to a role. */
1892
+ listPolicies: async (roleId) => {
1893
+ this.ensureConfigured();
1894
+ const response = await this.restRequest("GET", `/api/v1/roles/${encodeURIComponent(roleId)}/policies`);
1895
+ return response.policies ?? [];
1896
+ },
1655
1897
  };
1656
1898
  /**
1657
1899
  * Policy management operations (enterprise-only)
@@ -1816,6 +2058,32 @@ class IronflowClient {
1816
2058
  nextCursor: response.next_cursor,
1817
2059
  };
1818
2060
  }
2061
+ /** Query the environment-wide audit stream. */
2062
+ async listAuditEvents(options = {}) {
2063
+ this.ensureConfigured();
2064
+ const query = new URLSearchParams();
2065
+ if (options.runId)
2066
+ query.set("run_id", options.runId);
2067
+ if (options.functionId)
2068
+ query.set("function_id", options.functionId);
2069
+ if (options.eventType)
2070
+ query.set("event_type", options.eventType);
2071
+ if (options.fromTimestamp)
2072
+ query.set("from", options.fromTimestamp);
2073
+ if (options.toTimestamp)
2074
+ query.set("to", options.toTimestamp);
2075
+ if (options.limit !== undefined)
2076
+ query.set("limit", String(options.limit));
2077
+ if (options.cursor)
2078
+ query.set("cursor", options.cursor);
2079
+ const suffix = query.size ? `?${query}` : "";
2080
+ const response = await this.restRequest("GET", `/api/v1/audit${suffix}`);
2081
+ return {
2082
+ events: (response.events ?? []).map(auditEventFromWire),
2083
+ totalCount: response.total_count ?? 0,
2084
+ nextCursor: response.next_cursor || undefined,
2085
+ };
2086
+ }
1819
2087
  // ============================================================================
1820
2088
  // Webhook Management
1821
2089
  // ============================================================================
@@ -1963,55 +2231,6 @@ class IronflowClient {
1963
2231
  };
1964
2232
  },
1965
2233
  };
1966
- // ============================================================================
1967
- // User Management
1968
- // ============================================================================
1969
- /**
1970
- * User management operations
1971
- *
1972
- * @example
1973
- * ```typescript
1974
- * // List all users
1975
- * const users = await ironflow.users.list();
1976
- *
1977
- * // Create a user
1978
- * const user = await ironflow.users.create({
1979
- * email: "alice@example.com",
1980
- * password: "secret",
1981
- * roles: ["admin"],
1982
- * });
1983
- *
1984
- * // Update a user
1985
- * await ironflow.users.update(user.id, { name: "Alice" });
1986
- * ```
1987
- */
1988
- users = {
1989
- /** Create a new user (admin only) */
1990
- create: async (input) => {
1991
- this.ensureConfigured();
1992
- return this.restRequest("POST", "/api/v1/users", input);
1993
- },
1994
- /** List all users in the current organization (admin only) */
1995
- list: async () => {
1996
- this.ensureConfigured();
1997
- return this.restRequest("GET", "/api/v1/users");
1998
- },
1999
- /** Get a user by ID */
2000
- get: async (id) => {
2001
- this.ensureConfigured();
2002
- return this.restRequest("GET", `/api/v1/users/${encodeURIComponent(id)}`);
2003
- },
2004
- /** Update a user's profile (admin only) */
2005
- update: async (id, input) => {
2006
- this.ensureConfigured();
2007
- return this.restRequest("PATCH", `/api/v1/users/${encodeURIComponent(id)}`, input);
2008
- },
2009
- /** Delete a user (admin only) */
2010
- delete: async (id) => {
2011
- this.ensureConfigured();
2012
- await this.restRequest("DELETE", `/api/v1/users/${encodeURIComponent(id)}`);
2013
- },
2014
- };
2015
2234
  /**
2016
2235
  * Tenant management operations (enterprise-only)
2017
2236
  *
@@ -2027,6 +2246,19 @@ class IronflowClient {
2027
2246
  this.ensureConfigured();
2028
2247
  return this.restRequest("GET", "/api/v1/tenants");
2029
2248
  },
2249
+ /** Provision an organization, environment, and initial administrator key. */
2250
+ provision: async (input) => {
2251
+ this.ensureConfigured();
2252
+ const response = await this.restRequest("POST", "/api/v1/tenants/provision", {
2253
+ org_name: input.orgName,
2254
+ env_name: input.envName ?? "production",
2255
+ });
2256
+ return {
2257
+ org: response.org,
2258
+ environment: response.environment,
2259
+ apiKey: { key: response.api_key.key, roles: response.api_key.roles ?? [] },
2260
+ };
2261
+ },
2030
2262
  };
2031
2263
  // ============================================================================
2032
2264
  // Pattern Helpers (static)