@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/CHANGELOG.md +7 -0
- package/README.md +70 -69
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +388 -389
- package/cqrs.mjs +302 -303
- package/data-access.mjs +306 -307
- package/e2e-flow.mjs +222 -222
- package/event-delivery.mjs +93 -93
- package/exception-identity.mjs +200 -200
- package/exceptions.mjs +262 -265
- package/index.mjs +107 -107
- package/lib/path.mjs +2 -0
- package/module-layering.mjs +426 -432
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/testing.mjs +500 -454
- package/transport.mjs +168 -168
- package/type-safety.mjs +510 -380
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
|
+
}
|