@starci/eslint-canon-be 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cdc.mjs CHANGED
@@ -1,150 +1,150 @@
1
- const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
2
- String(filename || "").replace(/\\/g, "/"),
3
- ) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
4
-
5
- const isProjectionService = (filename) => /-projection\.service\.ts$/.test(
6
- String(filename || "").replace(/\\/g, "/"),
7
- )
8
-
9
- const memberName = (member) => member.key && (member.key.name || member.key.value)
10
-
11
- /** A groupId literal is safe: a plain string, or a template with no `${}` substitution. */
12
- const isStaticStringValue = (node) => {
13
- if (!node) return false
14
- if (node.type === "Literal" && typeof node.value === "string") return true
15
- if (node.type === "TemplateLiteral" && node.expressions.length === 0) return true
16
- return false
17
- }
18
-
19
- /** Flatten a function's parameter list to the names it binds, through destructuring/defaults/rest. */
20
- const collectParamNames = (params, names = []) => {
21
- for (const param of params || []) {
22
- if (!param) continue
23
- if (param.type === "Identifier") {
24
- names.push(param.name)
25
- } else if (param.type === "AssignmentPattern") {
26
- collectParamNames([param.left], names)
27
- } else if (param.type === "RestElement") {
28
- collectParamNames([param.argument], names)
29
- } else if (param.type === "ObjectPattern") {
30
- for (const prop of param.properties || []) {
31
- if (prop.type === "Property" && prop.key) {
32
- names.push(prop.key.name || prop.key.value)
33
- } else if (prop.type === "RestElement") {
34
- collectParamNames([prop.argument], names)
35
- }
36
- }
37
- }
38
- }
39
- return names
40
- }
41
-
42
- const DELTA_LIKE_PARAMS = new Set(["amount", "delta", "points", "increment"])
43
-
44
- /** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
45
- export const projectionListenerContract = {
46
- meta: {
47
- type: "problem",
48
- docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
49
- schema: [],
50
- messages: {
51
- base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
52
- member: "A projection CDC listener must declare `{{name}}`.",
53
- lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
54
- },
55
- },
56
- create(context) {
57
- if (!isProjectionListener(context.filename || context.getFilename())) return {}
58
- return {
59
- ClassDeclaration(node) {
60
- if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
61
- context.report({ node: node.id || node, messageId: "base" })
62
- }
63
- const members = node.body.body || []
64
- const names = new Set(members.map(memberName))
65
- for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
66
- if (!names.has(name)) {
67
- context.report({ node: node.id || node, messageId: "member", data: { name } })
68
- }
69
- }
70
- const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
71
- if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
72
- },
73
- }
74
- },
75
- }
76
-
77
- /** CDC Rule 3 (CDC-2): a projection's consumer group id is a constant, not computed at boot. */
78
- export const noDynamicProjectionGroupId = {
79
- meta: {
80
- type: "problem",
81
- docs: {
82
- description: "CDC Rule 3 (CDC-2): `groupId` is a stable string literal, not a value computed at boot.",
83
- },
84
- schema: [],
85
- messages: {
86
- dynamic: "CDC Rule 3 (CDC-2): `groupId` must be a constant string literal. A template substitution, a call (`Date.now()`, `randomUUID()`, `hostname()`), or an env/instance read makes the consumer group unstable across restarts, so each restart replays the whole history in a fresh group instead of resuming from the committed offset.",
87
- },
88
- },
89
- create(context) {
90
- if (!isProjectionListener(context.filename || context.getFilename())) return {}
91
- return {
92
- PropertyDefinition(node) {
93
- if (memberName(node) !== "groupId") return
94
- if (node.value && !isStaticStringValue(node.value)) {
95
- context.report({ node: node.value, messageId: "dynamic" })
96
- }
97
- },
98
- }
99
- },
100
- }
101
-
102
- /** CDC Rules 1 and 6 (CDC-4): recompute rebuilds from source by UPSERT, never by applying a delta. */
103
- export const projectionRecomputeMustUpsert = {
104
- meta: {
105
- type: "problem",
106
- docs: {
107
- description: "CDC Rules 1 and 6 (CDC-4): a projection recomputes from source rows by UPSERT; it is never updated by applying an event's delta.",
108
- },
109
- schema: [],
110
- messages: {
111
- delta: "CDC Rules 1 and 6 (CDC-4): `recompute` must not take a `{{name}}` parameter. Recompute takes only the target's identity and rebuilds the value from authoritative source rows -- a delta/amount/points parameter means a duplicate delivery doubles the projection and a lost delivery never heals.",
112
- upsert: "CDC Rules 1 and 6 (CDC-4): a projection service with a `recompute` method must write through an UPSERT (`ON CONFLICT`). A value assembled without one is not safe to replay, and replay is the one guarantee a broker actually offers.",
113
- },
114
- },
115
- create(context) {
116
- if (!isProjectionService(context.filename || context.getFilename())) return {}
117
- let hasRecompute = false
118
- return {
119
- MethodDefinition(node) {
120
- if (memberName(node) !== "recompute") return
121
- hasRecompute = true
122
- const params = collectParamNames(node.value.params)
123
- for (const name of params) {
124
- if (DELTA_LIKE_PARAMS.has(String(name).toLowerCase())) {
125
- context.report({ node, messageId: "delta", data: { name } })
126
- }
127
- }
128
- },
129
- "Program:exit"(node) {
130
- if (!hasRecompute) return
131
- const source = context.sourceCode || context.getSourceCode()
132
- if (!/on\s*conflict/i.test(source.getText())) {
133
- context.report({ node, messageId: "upsert" })
134
- }
135
- },
136
- }
137
- },
138
- }
139
-
140
- export const rules = {
141
- "projection-listener-contract": projectionListenerContract,
142
- "no-dynamic-projection-group-id": noDynamicProjectionGroupId,
143
- "projection-recompute-must-upsert": projectionRecomputeMustUpsert,
144
- }
145
-
146
- export const recommended = {
147
- "starci-be/projection-listener-contract": "error",
148
- "starci-be/no-dynamic-projection-group-id": "error",
149
- "starci-be/projection-recompute-must-upsert": "error",
150
- }
1
+ const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
2
+ String(filename || "").replace(/\\/g, "/"),
3
+ ) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
4
+
5
+ const isProjectionService = (filename) => /-projection\.service\.ts$/.test(
6
+ String(filename || "").replace(/\\/g, "/"),
7
+ )
8
+
9
+ const memberName = (member) => member.key && (member.key.name || member.key.value)
10
+
11
+ /** A groupId literal is safe: a plain string, or a template with no `${}` substitution. */
12
+ const isStaticStringValue = (node) => {
13
+ if (!node) return false
14
+ if (node.type === "Literal" && typeof node.value === "string") return true
15
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return true
16
+ return false
17
+ }
18
+
19
+ /** Flatten a function's parameter list to the names it binds, through destructuring/defaults/rest. */
20
+ const collectParamNames = (params, names = []) => {
21
+ for (const param of params || []) {
22
+ if (!param) continue
23
+ if (param.type === "Identifier") {
24
+ names.push(param.name)
25
+ } else if (param.type === "AssignmentPattern") {
26
+ collectParamNames([param.left], names)
27
+ } else if (param.type === "RestElement") {
28
+ collectParamNames([param.argument], names)
29
+ } else if (param.type === "ObjectPattern") {
30
+ for (const prop of param.properties || []) {
31
+ if (prop.type === "Property" && prop.key) {
32
+ names.push(prop.key.name || prop.key.value)
33
+ } else if (prop.type === "RestElement") {
34
+ collectParamNames([prop.argument], names)
35
+ }
36
+ }
37
+ }
38
+ }
39
+ return names
40
+ }
41
+
42
+ const DELTA_LIKE_PARAMS = new Set(["amount", "delta", "points", "increment"])
43
+
44
+ /** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
45
+ export const projectionListenerContract = {
46
+ meta: {
47
+ type: "problem",
48
+ docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
49
+ schema: [],
50
+ messages: {
51
+ base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
52
+ member: "A projection CDC listener must declare `{{name}}`.",
53
+ lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
54
+ },
55
+ },
56
+ create(context) {
57
+ if (!isProjectionListener(context.filename || context.getFilename())) return {}
58
+ return {
59
+ ClassDeclaration(node) {
60
+ if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
61
+ context.report({ node: node.id || node, messageId: "base" })
62
+ }
63
+ const members = node.body.body || []
64
+ const names = new Set(members.map(memberName))
65
+ for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
66
+ if (!names.has(name)) {
67
+ context.report({ node: node.id || node, messageId: "member", data: { name } })
68
+ }
69
+ }
70
+ const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
71
+ if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
72
+ },
73
+ }
74
+ },
75
+ }
76
+
77
+ /** CDC Rule 3 (CDC-2): a projection's consumer group id is a constant, not computed at boot. */
78
+ export const noDynamicProjectionGroupId = {
79
+ meta: {
80
+ type: "problem",
81
+ docs: {
82
+ description: "CDC Rule 3 (CDC-2): `groupId` is a stable string literal, not a value computed at boot.",
83
+ },
84
+ schema: [],
85
+ messages: {
86
+ dynamic: "CDC Rule 3 (CDC-2): `groupId` must be a constant string literal. A template substitution, a call (`Date.now()`, `randomUUID()`, `hostname()`), or an env/instance read makes the consumer group unstable across restarts, so each restart replays the whole history in a fresh group instead of resuming from the committed offset.",
87
+ },
88
+ },
89
+ create(context) {
90
+ if (!isProjectionListener(context.filename || context.getFilename())) return {}
91
+ return {
92
+ PropertyDefinition(node) {
93
+ if (memberName(node) !== "groupId") return
94
+ if (node.value && !isStaticStringValue(node.value)) {
95
+ context.report({ node: node.value, messageId: "dynamic" })
96
+ }
97
+ },
98
+ }
99
+ },
100
+ }
101
+
102
+ /** CDC Rules 1 and 6 (CDC-4): recompute rebuilds from source by UPSERT, never by applying a delta. */
103
+ export const projectionRecomputeMustUpsert = {
104
+ meta: {
105
+ type: "problem",
106
+ docs: {
107
+ description: "CDC Rules 1 and 6 (CDC-4): a projection recomputes from source rows by UPSERT; it is never updated by applying an event's delta.",
108
+ },
109
+ schema: [],
110
+ messages: {
111
+ delta: "CDC Rules 1 and 6 (CDC-4): `recompute` must not take a `{{name}}` parameter. Recompute takes only the target's identity and rebuilds the value from authoritative source rows -- a delta/amount/points parameter means a duplicate delivery doubles the projection and a lost delivery never heals.",
112
+ upsert: "CDC Rules 1 and 6 (CDC-4): a projection service with a `recompute` method must write through an UPSERT (`ON CONFLICT`). A value assembled without one is not safe to replay, and replay is the one guarantee a broker actually offers.",
113
+ },
114
+ },
115
+ create(context) {
116
+ if (!isProjectionService(context.filename || context.getFilename())) return {}
117
+ let hasRecompute = false
118
+ return {
119
+ MethodDefinition(node) {
120
+ if (memberName(node) !== "recompute") return
121
+ hasRecompute = true
122
+ const params = collectParamNames(node.value.params)
123
+ for (const name of params) {
124
+ if (DELTA_LIKE_PARAMS.has(String(name).toLowerCase())) {
125
+ context.report({ node, messageId: "delta", data: { name } })
126
+ }
127
+ }
128
+ },
129
+ "Program:exit"(node) {
130
+ if (!hasRecompute) return
131
+ const source = context.sourceCode || context.getSourceCode()
132
+ if (!/on\s*conflict/i.test(source.getText())) {
133
+ context.report({ node, messageId: "upsert" })
134
+ }
135
+ },
136
+ }
137
+ },
138
+ }
139
+
140
+ export const rules = {
141
+ "projection-listener-contract": projectionListenerContract,
142
+ "no-dynamic-projection-group-id": noDynamicProjectionGroupId,
143
+ "projection-recompute-must-upsert": projectionRecomputeMustUpsert,
144
+ }
145
+
146
+ export const recommended = {
147
+ "starci-be/projection-listener-contract": "error",
148
+ "starci-be/no-dynamic-projection-group-id": "error",
149
+ "starci-be/projection-recompute-must-upsert": "error",
150
+ }