Compare commits

..

21 Commits

Author SHA1 Message Date
ryan c3606bc6f6 fix(cloudflare): deduplicate batch member ids and guard move dialog dismiss during pending 2026-09-26 15:39:10 +08:00
ryan 2b3be6f3a3 docs(changelog): document cloudflare member move and ip group search 2026-09-26 15:32:08 +08:00
ryan 11c8e5c7f3 feat(cloudflare): add domain move and batch operations in group detail page 2026-09-26 15:26:37 +08:00
ryan 4d52a5b097 feat(waf): add search and filter in ip group view dialog 2026-09-26 15:15:26 +08:00
ryan b1626d068f feat(cloudflare): register move and batch member API routes 2026-09-26 15:08:32 +08:00
ryan 079fa7ee53 feat(cloudflare): add move and batch operation logics with tests 2026-09-26 14:57:16 +08:00
ryan 3867841a2f docs: add implementation plan for cloudflare member move and ip group search 2026-09-24 11:45:51 +08:00
ryan 805eea5bd6 docs: add design spec for cloudflare member move and ip-group search 2026-09-24 11:44:43 +08:00
ryan aad059ab6e fix(objectstore): decouple WebDAV targetPath from logical storage key and fix basePath duplication
- WebDAV Put now returns PutResult with driver-agnostic logical relative key (relKey), keeping database records decoupled from mount basePath.
- targetPath mounts logical keys to the remote WebDAV server path, and transparently handles legacy database records containing basePath or duplicate basePath prefixes.
- Make localBackend path resolution resilient to keys with leading slashes or legacy absolute paths outside local root by safely mounting them as relative paths.
- Add comprehensive unit tests for WebDAV targetPath, relKey, end-to-end roundtrip with in-memory WebDAV server, and local storage leading slash handling.
2026-09-22 14:16:21 +08:00
Ryan 96abbf180d Merge pull request #32 from Rain-kl/dependabot/npm_and_yarn/frontend/sharp-0.35.4
chore(deps): bump sharp from 0.35.3 to 0.35.4 in /frontend
2026-09-19 13:58:26 +08:00
Ryan fff4f425ae Merge pull request #31 from Rain-kl/dependabot/npm_and_yarn/frontend/js-yaml-4.3.2
chore(deps): bump js-yaml from 4.3.1 to 4.3.2 in /frontend
2026-09-19 13:58:15 +08:00
Ryan e1753f868e Merge pull request #30 from Rain-kl/dependabot/go_modules/google.golang.org/grpc-1.83.2
chore(deps): bump google.golang.org/grpc from 1.83.0 to 1.83.2
2026-09-19 13:58:04 +08:00
ryan a49d07e2c2 chore(release): v3.5.5
### 🛠 修复
- 修复 Cloudflare 指向分组引用的节点已被删除时,分组列表/详情接口整体返回「Cloudflare 资源不存在」的问题;现会跳过缺失节点并继续返回其余分组。
- 修复静态导出部署下访问 Cloudflare 指向分组详情(`/cloudflare/groups/{id}`,id 不为 1)会跳回首页并触发 React hydration 报错的问题。

### ⚡️ 优化与改进
- 优化 openflare-agent Docker 镜像体积:精简运行时依赖并消除离线 IP 库冗余层,镜像总体积从 500MB+ 缩减至约 150MB。
2026-09-19 12:42:47 +08:00
ryan 1fe2763034 perf(docker): switch agent openresty base image to alpine-slim 2026-09-19 12:34:51 +08:00
ryan bf1e77a865 fix(docker): optimize Dockerfile by reducing layers and improving permissions 2026-09-19 12:27:50 +08:00
ryan 65bc7d9b81 chore(release): v3.5.5
### 🛠 修复

- 修复 Cloudflare 指向分组引用的节点已被删除时,分组列表/详情接口整体返回「Cloudflare 资源不存在」的问题;现会跳过缺失节点并继续返回其余分组。
- 修复静态导出部署下访问 Cloudflare 指向分组详情(`/cloudflare/groups/{id}`,id 不为 1)会跳回首页并触发 React hydration 报错的问题。
2026-09-19 11:13:42 +08:00
ryan 1d4533f183 fix(cloudflare): handle missing nodes in group detail responses 2026-09-18 14:04:44 +08:00
ryan a2404a50ef fix(cloudflare): restore group detail pages for ids other than 1
Static export only generates /cloudflare/groups/1.html. Unknown ids fell
through to the dashboard index, bouncing the browser home and triggering
React hydration error #418. Serve the generated group shell and resolve
the real id from the pathname, matching the websites detail fallback.
2026-09-17 22:00:12 +08:00
dependabot[bot] 71a715a64b chore(deps): bump sharp from 0.35.3 to 0.35.4 in /frontend
Bumps [sharp](https://github.com/lovell/sharp) from 0.35.3 to 0.35.4.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 13:47:50 +00:00
dependabot[bot] 501c754352 chore(deps): bump js-yaml from 4.3.1 to 4.3.2 in /frontend
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 13:47:40 +00:00
dependabot[bot] a0324dc65c chore(deps): bump google.golang.org/grpc from 1.83.0 to 1.83.2
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.0 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.0...v1.83.2)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 13:47:06 +00:00
1468 changed files with 48358 additions and 112589 deletions
-300
View File
@@ -1,300 +0,0 @@
---
name: autoresearch
description: >
Autonomous goal-directed iteration loop, inspired by Karpathy's autoresearch.
Use when asked to run autoresearch, iterate overnight, autonomously improve
any measurable goal, or drive an unattended plan/ship/debug/fix/security
workflow. Loops forever: modify → verify → keep/revert → log → repeat.
Never stops until the user interrupts.
---
# Autoresearch
> Ported from `supratikpm/gemini-autoresearch` (Gemini CLI). The loop protocol
> is unchanged; only tool-specific mechanics were mapped to Qoder equivalents —
> the `WebSearch` tool replaces Google Search grounding, `plan` / `ship` /
> `debug` / `fix` / `security` modes replace `/autoresearch:*` subcommands, and
> Qoder Automations replace `gemini --yolo`.
You are an autonomous improvement agent. You iterate forever until interrupted.
You do not ask "should I continue?" You do not pause for confirmation. You run
the loop.
## Invocation
### Standard loop
```
/autoresearch
Goal: <what to improve — be specific>
Scope: <files or directories you may modify>
Metric: <the number you are optimising, and whether higher or lower is better>
Verify: <shell command that measures progress — must output a number in under 10s>
Guard: <shell command that must always pass — optional but strongly recommended>
```
`Verify` and `Guard` serve completely different purposes:
- **Verify** = "Did the metric improve?" — measures progress toward the goal
- **Guard** = "Did anything else break?" — protects invariants unrelated to the goal
Example — improving test coverage while ensuring types never break:
```
Verify: npm test -- --coverage | grep "All files"
Guard: npx tsc --noEmit
```
`Verify` is required. `Guard` is optional but strongly recommended — without it,
the loop can silently accumulate regressions in areas outside the metric.
Guard files are **never modified** by the loop. They are read-only constraints.
Goal, Scope, Metric, and Verify are required. Guard is optional.
If any required fields are missing, ask for them once, then start.
### Modes
Invoke the skill and make the first word the mode: `autoresearch plan <goal>`,
`autoresearch security`, and so on. Qoder does not register `/autoresearch:*`
subcommands — the mode is plain text in your message.
| Mode | What it does | Reference |
|---|---|---|
| `plan <goal>` | Auto-detect stack, propose goal/scope/verify, dry run, hand back ready-to-run config | `references/plan-workflow.md` |
| `ship` | Pre-flight checklist — tests, types, lint, bundle, secrets, deps. Autoresearch loop on anything that fails | `references/ship-workflow.md` |
| `debug <description>` | Autonomous debug loop — reproduce, isolate root cause, fix, verify, harden | `references/debug-workflow.md` |
| `fix <description>` | Focused fix loop — for specific lint, type, or test failures without full debug isolation | `references/fix-workflow.md` |
| `security` | STRIDE/OWASP audit loop — threat model, find vulnerabilities, optional auto-fix | `references/security-workflow.md` |
No mode means the standard loop above.
**When a mode is invoked**, read the corresponding reference file
before doing anything else. The reference file contains the full protocol
for that workflow.
---
## Setup phase (run once before the loop)
1. Read every file in Scope to build full context. Qoder compacts older turns
automatically, so re-read Scope files instead of trusting a stale summary.
2. Read `autoresearch-lessons.md` if it exists. This is accumulated knowledge
from prior runs. Read it carefully before forming any hypothesis.
3. Run the Verify command. Record the output as the baseline (iteration #0).
4. If Guard is provided: run it once. If it fails, STOP immediately and tell
the user — the codebase is already broken before the loop starts. Fix the
Guard failure manually before proceeding. Guard must be green at baseline.
5. Initialise `autoresearch-results.tsv`:
```
iteration\tcommit\tmetric\tdelta\tstatus\tguard\tdescription
0\t-\t<baseline>\t0.0\tbaseline\tpass\tinitial measurement
```
6. Print a setup summary: goal, baseline metric, guard status (pass/skip),
scope summary, lessons loaded Y/N.
7. Start the loop immediately. Do not wait for confirmation.
---
## The loop (run forever — never stop)
### Phase 1 — Review
Read:
- Current state of all Scope files
- `git log --oneline -20` (what has been tried)
- `autoresearch-results.tsv` (what worked, what failed, patterns)
- `autoresearch-lessons.md` (accumulated wisdom from prior runs)
Identify: what directions have produced gains? what has consistently failed?
what has not been tried yet?
### Phase 2 — Ideate
Pick ONE hypothesis. It must be:
- Specific and testable in a single iteration
- Meaningfully different from the last 3 attempts
- Informed by both the results log and the lessons file
- Explained in one sentence
Prefer hypotheses that build on proven wins over untested territory.
Prefer simplicity — a small clean change beats a large complex one.
### Phase 3 — Modify
Make exactly ONE atomic change in Scope. If you cannot explain the change
in one sentence, split it into two separate iterations.
Do not touch files outside Scope. Do not refactor unrelated code. One thing.
### Phase 4 — Commit
```bash
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
```
**Commit BEFORE verifying.** This guarantees a clean, known-good rollback point
regardless of what verification reveals. Never skip this step.
### Phase 5 — Verify + Guard
**Step A — Run Verify.** Extract the numeric metric value.
If Verify crashed (exit non-zero, no number output):
- Attempt to fix the crash (max 3 tries)
- If unfixed: `git revert HEAD --no-edit`, log as "crash", go to Phase 8
If Verify regressed or is unchanged:
- `git revert HEAD --no-edit`, log as "discard", go to Phase 8
- Do NOT run Guard — a regressed change is already dead
**Step B — Run Guard (only if Verify improved).** Exit code 0 = pass.
**Web research supplement**: after Verify passes, use `WebSearch` for
additional signal when local scripts cannot capture full quality.
See `references/web-research-patterns.md`. Research is a supplement only.
### Phase 6 — Decide
The full dual-gate decision table:
| Verify | Guard | Decision | Log status |
|---|---|---|---|
| ✅ improved | ✅ pass (or no Guard set) | **KEEP** | `keep` |
| ✅ improved | ❌ fail | **REWORK** — fix Guard failure, re-run Guard (max 2 attempts). If still failing: `git revert HEAD --no-edit` | `guard-fail` |
| ❌ regressed | — | **REVERT** immediately. Do not run Guard. | `discard` |
| ❌ unchanged | — | **REVERT**. Treat unchanged as a regression. | `discard` |
| 💥 crashed | — | **FIX** (max 3 attempts), then revert if unfixed. | `crash` |
**Rework protocol** (when Verify passes but Guard fails):
1. Read the Guard failure output carefully
2. Make the minimal additional change to satisfy Guard without hurting Verify
3. Amend the commit: `git add -A && git commit --amend --no-edit`
4. Re-run both Verify AND Guard
5. If both pass → KEEP. If Guard still fails after 2 rework attempts → REVERT.
### Phase 7 — Log
Append one row to `autoresearch-results.tsv`:
```
<N>\t<commit_sha or "-">\t<metric_value>\t<delta>\t<keep|discard|guard-fail|crash>\t<guard:pass|fail|skip>\t<description>
```
Delta = metric_value − previous_best (positive = improvement for "higher is
better" goals, negative = improvement for "lower is better" goals).
### Phase 8 — Repeat
Go to Phase 1. Immediately. NEVER STOP.
---
## Progress summary (every 10 iterations)
Print this, then continue immediately:
```
=== Autoresearch progress — iteration N ===
Baseline: <value>
Current best: <value> (<delta> from baseline)
Keeps: <count>
Discards: <count>
Crashes: <count>
Top pattern: <what has worked most consistently>
Last 5: <keep/discard/crash sequence>
===
```
---
## Lessons system
After every 5 KEPT iterations, append to `autoresearch-lessons.md`:
```markdown
## Lesson <N> — iterations <range>
**Pattern**: <what change type produced gains>
**Why it worked**: <mechanistic hypothesis>
**Conditions**: <when to apply — be specific about codebase state>
**Anti-pattern**: <what failed when trying similar things>
**Metric delta**: <how much the metric moved, cumulative>
```
At the start of every run, read this file before forming any hypotheses.
Weight recent lessons more heavily. Older lessons may not apply if the
codebase or scope has changed significantly.
This is the compounding mechanism. Each overnight run starts smarter than
the last.
---
## Stuck recovery
After 5 consecutive discards or crashes:
1. Re-read all Scope files from scratch. Full context, not memory.
2. Search the lessons log for near-misses — what came closest to working?
3. Try combining two near-miss approaches into one hypothesis.
4. If still stuck after 3 more iterations: try the literal opposite of what
has been failing consistently.
5. If still stuck after 3 more: use `WebSearch` to research the
problem space. Search for `[domain] [metric] improvement techniques [year]`.
Extract 3 concrete techniques. Use each as the next 3 hypotheses.
6. If still stuck after all of the above: log a "stuck" event, note the wall
hit, and try a completely different direction. Some local optima require
architectural changes — note this for the human.
---
## Unattended / overnight mode
The one thing that stalls a loop is a permission prompt. Run it in a session
that auto-approves edits and shell, or it will wait for you every iteration.
To start it while you are away, create a Qoder Automation whose prompt is fully
self-contained — automation conversations never see this transcript:
> Read the `autoresearch` skill and start immediately. Goal: `<goal>`.
> Scope: `<scope>`. Metric: `<metric — higher/lower is better>`.
> Verify: `<command>`. Guard: `<command>`. Do not pause, do not ask questions,
> iterate until stopped.
You will wake up to `autoresearch-results.tsv` and `autoresearch-lessons.md`.
Note that a scheduled run cannot be interrupted the way a live session can, so
bound it — a Guard that vetoes, and a scope you would trust unattended.
---
## Non-negotiable rules
1. **NEVER STOP** until the user manually interrupts the run.
2. **ONE change per iteration** — atomic, explainable in one sentence.
3. **Mechanical verification only** — no "looks better", no "seems cleaner".
If you cannot measure it, you cannot use it as a signal.
4. **Commit BEFORE verifying** — always. No exceptions.
5. **Auto-revert on regression** — no debate, no "let me try one more thing".
6. **Guard is a hard veto** — Verify passing does not mean KEEP. Guard must also pass.
7. **Never modify Guard files** — they are read-only invariants, not scope.
8. **Read git history before every hypothesis** — it is your short-term memory.
9. **Read lessons before every run** — it is your long-term memory.
10. **Simplicity wins ties** — equal metric + less code = KEEP.
11. **Never touch files outside Scope** — discipline is what makes the loop safe.
12. **When in doubt, make the smaller change** — scope creep kills iterations.
---
## Reference files
**Core loop**
- `references/loop-protocol.md` — detailed phase-by-phase protocol
- `references/results-logging.md` — TSV format, summary templates, examples
- `references/lessons-system.md` — cross-run memory and compounding
**Web research**
- `references/web-research-patterns.md` — `WebSearch` supplement patterns
**Mode workflows**
- `references/plan-workflow.md` — `plan` mode — auto-detect and configure
- `references/ship-workflow.md` — `ship` mode — pre-flight checklist
- `references/debug-workflow.md` — `debug` mode — root cause and fix
- `references/fix-workflow.md` — `fix` mode — focused type/lint fix
- `references/security-workflow.md` — `security` mode — STRIDE/OWASP audit
@@ -1,25 +0,0 @@
# `autoresearch debug` mode — Autonomous Debug Loop
This workflow is triggered by the `debug` mode. It is designed to reproduce, isolate, and fix specific bugs autonomously.
## Context
Use this when something is clearly broken (e.g., a failing test, a crash, or a UI bug).
## Phase 1: Reproduction
1. Create a minimal reproduction script (e.g., `debug/repro.js` or a new test case).
2. Run the repro script and verify it fails as expected.
3. This repro command becomes your `Verify` command for the loop.
## Phase 2: Isolation
1. Use `Grep` and `Read` to find the code responsible for the failure.
2. Form a hypothesis about the root cause.
## Phase 3: Fix Loop
1. Start a standard autoresearch loop with:
- **Goal**: Fix the bug identified in the repro script.
- **Verify**: The repro command (must exit 0 on success).
- **Guard**: Existing test suite and linting.
## Phase 4: Hardening
1. After the fix is verified, add a permanent regression test to the codebase.
2. Verify that the fix holds across the entire project.
@@ -1,31 +0,0 @@
# Fix Workflow (`autoresearch fix` mode)
The `fix` workflow is a lightweight version of the `debug` loop. It is designed for situations where you have a specific, known failure (e.g., a TypeScript error or a lint violation) and you want to fix it without the overhead of full reproduction and isolation.
## Protocol
### 1. Context Loading
* Read the error message or description provided in the command.
* Identify the affected file(s).
* Read the current state of those files.
### 2. Hypothesis
* Form a direct hypothesis on how to fix the specific error.
* The fix must be minimal and targeted.
### 3. Execution
* Apply the fix.
* Commit the change.
### 4. Verification
* Run the command that triggered the original failure (e.g., `npx tsc` or `npm run lint`).
* If a `Guard` is set in the main autoresearch config, run that as well.
### 5. Decision
* If the error is gone and Guard passes: **KEEP**.
* If the error persists: **RETRY** (max 3 times) with a different approach.
* If it still fails after 3 tries: **REVERT** and report to the user.
## When to use `fix` vs `debug`
* Use **`fix`** for mechanical errors: "Fix the lint error on line 42", "Fix the missing import in `utils.ts`".
* Use **`debug`** for logical errors: "The login flow fails for users with specialized characters", "Database connection timeouts under high load".
@@ -1,117 +0,0 @@
# Lessons system
The lessons system is what separates autoresearch from a dumb
mutation loop. It is the mechanism by which each overnight run starts
smarter than the last.
---
## The compounding model
```
Night 1: 100 experiments → lessons-v1 written
Night 2: reads lessons-v1 → avoids 20 known failures → 80 net-new experiments
Night 3: reads lessons-v2 → avoids 35 known failures → faster convergence
...
```
Without the lessons system, every run starts from scratch. With it, runs
compound — each failure is learned once and never repeated.
---
## File location and format
File: `autoresearch-lessons.md` in your project root.
Add to `.gitignore` — this is a working file for the agent, not source code.
```markdown
# Autoresearch lessons — <project name>
Generated by the autoresearch skill. Do not edit manually.
Last updated: <ISO date>
## Lesson 1 — iterations 1–5
**Pattern**: <the type of change that produced gains>
**Why it worked**: <mechanistic hypothesis — be specific>
**Conditions**: <codebase state where this applies>
**Anti-pattern**: <what failed when trying similar approaches>
**Metric delta**: <cumulative gain from this pattern, e.g. "+4.2%">
## Lesson 2 — iterations 6–10
...
```
---
## When to write lessons
Append a new lesson after every 5 KEPT iterations (not every 5 total
iterations). Lessons should only describe what worked.
Failed patterns are captured implicitly — if a pattern never generates a
kept iteration, it never generates a lesson, and the loop naturally
deprioritises it via Phase 2's "different from last 3 attempts" rule.
---
## What makes a good lesson
**Good** (specific, mechanistic, conditional):
```
**Pattern**: Defer non-critical third-party scripts using loading="lazy"
**Why it worked**: Removes scripts from the critical render path, reducing
Time to Interactive without affecting functionality
**Conditions**: Applies to analytics, chat widgets, social embeds — not
to scripts required for initial page render
**Anti-pattern**: Lazy-loading scripts that are called in the first 500ms
of page load caused layout shifts and broke interactions
**Metric delta**: +6.8% Lighthouse performance score across 3 iterations
```
**Bad** (vague, not actionable):
```
**Pattern**: Make things faster
**Why it worked**: It improved performance
**Conditions**: When performance is bad
**Anti-pattern**: When it makes things worse
```
---
## How to read lessons at the start of a run
1. Read the full file — do not skip old lessons even if they seem stale.
2. For each lesson, assess: does this pattern still apply given the current
state of the codebase? If the code it describes has been significantly
refactored, downweight it.
3. Extract the top 2-3 highest-delta patterns. These are your first
hypotheses unless the results log shows they have already been exhausted.
4. Extract the anti-patterns. These are your first exclusions — do not
generate hypotheses that match these patterns.
---
## Cross-project lessons
For teams running autoresearch across multiple similar projects (e.g.
multiple Next.js apps), consider maintaining a shared lessons file at
`~/.autoresearch/global-lessons.md`.
At the start of a run, read both the project-level and global lessons.
Project-level lessons take precedence when they conflict with global ones.
This is optional but significantly accelerates convergence on new projects
that share a tech stack with already-researched ones.
---
## Lessons file maintenance
- Do not manually edit the lessons file during a run — the agent reads it
at the start of each run and its contents influence hypothesis generation.
- After a long run (100+ iterations), review the file and remove lessons
that are no longer applicable (e.g. they describe code that no longer
exists). Add a comment explaining why the lesson was removed.
- The lessons file is cumulative — never delete lessons, only annotate them
as superseded if a newer lesson contradicts them.
@@ -1,193 +0,0 @@
# Autonomous loop protocol
Detailed specification for each of the 8 phases. The SKILL.md contains the
summary version. Read this reference when you need precise guidance on edge
cases in any phase.
---
## Phase 1 — Review
**Purpose**: Build a complete, accurate picture of current state before
forming any hypothesis. Hypotheses formed without full context waste iterations.
**What to read**:
- Every file in Scope (not just the ones you last touched)
- `git log --oneline -20` — what has been attempted, in order
- `autoresearch-results.tsv` — the full record of what worked and failed
- `autoresearch-lessons.md` — accumulated patterns from prior runs
**What to extract**:
- Current metric trajectory (improving? plateauing? volatile?)
- Which change types produced the most gain per iteration
- Which change types consistently failed
- Which directions have not yet been explored
- Any patterns in crash causes
**Duration**: This phase should take as long as needed to form a genuinely
informed hypothesis. Rushing Phase 1 leads to repeated failures.
---
## Phase 2 — Ideate
**Purpose**: Select ONE hypothesis that has the highest expected gain given
what is known.
**Hypothesis selection criteria** (in order of priority):
1. Builds directly on a proven pattern from the lessons file
2. Explores a direction adjacent to a near-miss (something that almost worked)
3. Combines two near-miss approaches that individually failed
4. Tries the opposite of what consistently failed
5. Applies an externally validated technique (from `WebSearch` research)
6. Tries something entirely untested
**What makes a good hypothesis**:
- Specific: "lazy-load the user avatar component" not "improve performance"
- Testable: produces a measurable delta in the Verify command
- Atomic: one thing changes, one thing is measured
- Explainable in one sentence before you make the change
**What makes a bad hypothesis**:
- Vague: "refactor for clarity"
- Multi-part: "update the API, add caching, and fix the tests"
- Untestable by the Verify command
- Identical to something tried in the last 3 iterations
---
## Phase 3 — Modify
**Purpose**: Implement the hypothesis as a single, clean, minimal change.
**Rules**:
- Touch only files in Scope
- Make the smallest change that tests the hypothesis
- If the change is getting large, stop and split it — make the first half now,
the second half in the next iteration
- Do not fix unrelated things you notice while editing
- Do not reformat code that is not part of the hypothesis
- Leave comments only if they directly explain the change
**Signs you are over-scoping**:
- You have edited more than 3 files
- The diff is more than ~50 lines
- You are explaining the change with "and also"
When in doubt, make a smaller change. Smaller changes fail faster and teach more.
---
## Phase 4 — Commit
**Purpose**: Create a clean rollback point before any verification risk.
**Command**:
```bash
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
```
**Commit message format**:
- Always prefix with `autoresearch iter N:`
- One sentence, present tense, describes the change not the goal
- Good: `autoresearch iter 14: lazy-load user avatar to reduce initial bundle`
- Bad: `autoresearch iter 14: improve performance`
**Why commit before verifying**: if the Verify command crashes, hangs, or
corrupts state, you can always `git revert HEAD --no-edit` and return to
a known-good state. If you verify before committing, a crash during
verification leaves you with uncommitted changes and an unknown baseline.
**Never skip this step**, even if the change feels obviously correct.
---
## Phase 5 — Verify
**Purpose**: Get a single numeric measurement of whether the hypothesis helped.
**Execution**:
1. Run the Verify command exactly as specified by the user
2. Extract the numeric metric value
3. Optionally supplement with `WebSearch` research (see
`references/web-research-patterns.md`)
4. Record the raw output for the log
**Handling slow Verify commands**:
If the Verify command takes more than 30 seconds, note this. After the run,
recommend the user find a faster proxy metric — slower verification means
fewer experiments per hour, which compounds negatively over a full night.
**Handling non-deterministic Verify commands**:
If the metric varies significantly between runs on identical code (>5%
variance), note this in the log. Run the Verify command twice and average.
Log both values. Recommend the user address flakiness before the next
overnight run.
---
## Phase 6 — Decide
**Purpose**: Make a clear, mechanical keep/revert decision. No deliberation.
**Decision table**:
| Condition | Action | Log status |
|---|---|---|
| Metric improved (beyond noise threshold) | Keep commit as-is | `keep` |
| Metric unchanged or regressed | `git revert HEAD --no-edit` | `discard` |
| Verify crashed with exit code ≠ 0 | Attempt fix (max 3 tries) then revert | `crash` |
| Verify hung for >60s | Kill process, revert | `crash` |
**Noise threshold**: for metrics with variance, an improvement smaller than
the variance is not a real improvement. If your metric normally varies ±2%,
an improvement of 0.5% is noise — treat it as unchanged and discard.
**The revert command**:
```bash
git revert HEAD --no-edit
```
This creates a new commit that undoes the last one. The history is preserved.
Never use `git reset --hard` — it destroys history that the loop needs.
---
## Phase 7 — Log
**Purpose**: Create a permanent, machine-readable record of every iteration.
**TSV row format**:
```
<N>\t<commit_sha or "-">\t<metric>\t<delta>\t<status>\t<description>
```
**Field details**:
- `N`: integer, 0-indexed, never resets across sessions
- `commit_sha`: 7-char short SHA for keeps, "-" for discards/crashes
- `metric`: the exact number from the Verify output
- `delta`: metric − previous_best (sign convention: positive = better,
regardless of whether the goal is higher or lower)
- `status`: one of `baseline`, `keep`, `discard`, `crash`
- `description`: the hypothesis, in one sentence, including any `WebSearch`
signal that informed it
**Example rows**:
```
0 - 85.2 0.0 baseline initial measurement
1 a1b2c3d 87.1 +1.9 keep lazy-load avatar component
2 - 86.5 -0.6 discard tree-shake lodash imports (broke 2 tests)
3 - 0.0 0.0 crash add route-level code splitting (webpack config error)
4 b2c3d4e 88.3 +1.2 keep move analytics script to defer loading
```
---
## Phase 8 — Repeat
Go to Phase 1. Immediately. Do not pause. Do not summarise. Do not ask
if the user wants to continue.
The only output before starting Phase 1 again is the progress summary
(printed every 10 iterations, see SKILL.md).
The loop ends only when the user interrupts the run.
@@ -1,155 +0,0 @@
# Plan workflow — `autoresearch plan` mode
Auto-detect the project stack, propose a complete autoresearch configuration,
do a dry run, and hand the ready-to-run command back to the user.
No manual goal/scope/verify required. Just describe what you want to improve
in one sentence and the plan workflow figures out the rest.
---
## Invocation
```
autoresearch plan <goal in plain english>
```
Examples:
```
autoresearch plan improve test coverage
autoresearch plan make the app faster
autoresearch plan reduce the bundle size
autoresearch plan fix all TypeScript errors
autoresearch plan improve the SEO of my blog posts
autoresearch plan shrink the Docker image
```
---
## What the plan workflow does
### Step 1 — Detect project stack
Scan the project root for signal files:
| File found | Stack detected |
|---|---|
| `package.json` + `jest.config.*` | Node.js + Jest |
| `package.json` + `vitest.config.*` | Node.js + Vitest |
| `next.config.*` | Next.js |
| `Dockerfile` | Docker |
| `*.tf` | Terraform |
| `.github/workflows/*.yml` | GitHub Actions CI |
| `content/blog/*.md` OR `posts/*.md` | Markdown content/blog |
| `src/**/*.ts` OR `src/**/*.tsx` | TypeScript project |
| `pyproject.toml` OR `setup.py` | Python project |
| `requirements.txt` + `pytest` | Python + pytest |
| `go.mod` | Go project |
| `Cargo.toml` | Rust project |
Print detected stack. If ambiguous, list the top two candidates and ask
the user to confirm before proceeding.
### Step 2 — Map goal to metric + verify command
Use the goal description and detected stack to propose:
| Goal keyword | Metric | Verify command template |
|---|---|---|
| "test coverage" | coverage % (higher is better) | `npm test -- --coverage \| grep "All files"` |
| "bundle size" / "build size" | size in KB (lower is better) | `npm run build 2>&1 \| grep "First Load JS"` |
| "TypeScript errors" / "type errors" | error count (lower is better) | `npx tsc --noEmit 2>&1 \| grep -c "error TS" \|\| echo "0"` |
| "lighthouse" / "performance score" | score 0-100 (higher is better) | `npx lighthouse http://localhost:3000 --output json --quiet 2>/dev/null \| jq '.categories.performance.score * 100'` |
| "docker image" / "image size" | size in MB (lower is better) | `docker build -t bench . -q && docker images bench --format "{{.Size}}"` |
| "flaky tests" | failure count (lower is better) | `for i in {1..5}; do npm test 2>&1; done \| grep -c "FAIL" \|\| echo "0"` |
| "SEO" / "blog" / "content" | SEO score (higher is better) | `node scripts/seo-score.js <detected content path>` |
| "lines of code" / "complexity" | LOC count (lower is better) | `find src/ -name "*.ts" \| xargs wc -l \| tail -1 \| awk '{print $1}'` |
| "CI pipeline" / "pipeline speed" | seconds (lower is better) | `node scripts/estimate-ci-time.js` |
| "Python tests" / "pytest" | coverage % (higher is better) | `pytest --cov=src --cov-report=term-missing \| grep "TOTAL"` |
| "faster" / "performance" / "latency" | p95 ms (lower is better) | `npm run bench 2>&1 \| grep "p95"` |
### Step 3 — Detect scope
Based on goal + stack, propose the tightest scope that covers the goal:
- Test coverage → `src/**/*.ts, src/**/*.test.ts`
- Bundle size → `src/**/*.tsx, src/**/*.ts`
- Docker → `Dockerfile, .dockerignore`
- SEO → `content/blog/*.md` or detected content directory
- TypeScript errors → `src/**/*.ts`
- CI pipeline → `.github/workflows/*.yml`
### Step 4 — Dry run
Run the proposed Verify command once against the current state.
- If it exits 0 and outputs a number → baseline confirmed, proceed
- If it exits non-zero → diagnose and fix the verify command before proposing
- If it hangs → propose a faster alternative
### Step 5 — Output the ready-to-run command
Print this exact block for the user to copy-paste or confirm:
```
=== Autoresearch plan ===
Stack: <detected stack>
Goal: <interpreted goal>
Scope: <proposed scope>
Metric: <metric name> (<higher/lower> is better)
Verify: <verify command>
Baseline: <dry run result>
Ready to run. Confirm or adjust any field, then:
/autoresearch
Goal: <goal>
Scope: <scope>
Metric: <metric>
Verify: <verify command>
Or, for an unattended run, put these same fields into a Qoder Automation prompt
(see "Unattended / overnight mode" in SKILL.md).
===
```
If the user says "looks good" or "run it" — start the autoresearch loop
immediately without requiring them to retype the command.
---
## Web research calibration
After the dry run, use `WebSearch` to calibrate:
- For SEO goals: search for `[target keyword]` to see what top results look like.
Note any structural patterns (FAQ sections, word count, heading structure)
that the current content lacks. Add these as initial hypotheses.
- For performance goals: search for `[framework] performance benchmarks [year]`
to calibrate whether the baseline is already good or has significant headroom.
- For security goals: search for `[stack] common vulnerabilities [year]`
to seed the initial hypothesis pool with known attack vectors.
This research step happens during plan, not during the loop — so it adds
context once without slowing down iterations.
---
## Edge cases
**Goal is too vague** ("make it better"):
Ask one clarifying question: "Better in what way — speed, quality, size,
coverage, or something else?" Then proceed.
**Multiple valid verify commands exist**:
Propose the fastest one. Note the slower alternative in a comment.
**Verify command requires a running server**:
Note this in the plan output. Add a `# requires: local server on :3000`
comment. Suggest the user start it before running the loop.
**No matching stack detected**:
Ask the user to describe their stack in one sentence, then proceed with
a custom verify command.
@@ -1,105 +0,0 @@
# Results logging
Specification for `autoresearch-results.tsv` — the per-iteration record
of every experiment in a run.
---
## File format
Tab-separated values. Headers on row 1. One row per iteration.
```
iteration\tcommit\tmetric\tdelta\tstatus\tdescription
```
### Field definitions
| Field | Type | Description |
|---|---|---|
| `iteration` | integer | 0-indexed. Never resets — if you run multiple sessions, continue from the last number. |
| `commit` | string | 7-char git short SHA for kept commits. `-` for discards and crashes. |
| `metric` | float | Raw metric value from the Verify command. |
| `delta` | float | `metric − previous_best`. Sign convention: positive = improvement (regardless of higher/lower goal). |
| `status` | enum | One of: `baseline`, `keep`, `discard`, `crash` |
| `description` | string | The hypothesis, one sentence. Include the change type and the expected mechanism. |
---
## Example file
```tsv
iteration commit metric delta status description
0 - 85.2 0.0 baseline initial measurement — test coverage 85.2%
1 a1b2c3d 87.1 +1.9 keep add tests for auth middleware edge cases
2 - 86.5 -0.7 discard refactor test helpers (broke 2 existing tests)
3 - 0.0 0.0 crash add integration tests (postgres connection failed — fix in iter 4)
4 b2c3d4e 88.3 +1.2 keep add tests for error handling in API routes
5 - 88.1 -0.2 discard add tests for rate limiter (metric within variance, treated as regression)
6 c3d4e5f 89.0 +0.7 keep add boundary value tests for form validators
7 d4e5f6g 89.8 +0.8 keep add tests for session expiry edge cases
8 - 89.2 -0.6 discard mock external API calls (test isolation but metric regressed)
9 e5f6g7h 90.6 +0.8 keep add tests for concurrent request handling
10 f6g7h8i 91.1 +0.5 keep add tests for malformed JSON input handling
```
---
## Progress summary format
Print every 10 iterations. Use this exact format:
```
=== Autoresearch progress — iteration <N> ===
Goal: <original goal statement>
Baseline: <iteration 0 metric>
Current best: <best metric so far> (<total delta> from baseline)
Keeps: <count> (<keeps/total * 100>%)
Discards: <count>
Crashes: <count>
Top pattern: <the change type that has produced the most total delta>
Last 5: <sequence of keep/discard/crash for iterations N-4 through N>
Est. to goal: <if goal metric is known, N iterations at current rate>
===
```
---
## Interpreting the log
### Healthy run signature
- Keep rate 40-60%
- Delta per keep: consistent small positive gains
- No long crash streaks
- Discards are evenly distributed (not clustered)
### Warning signs
| Pattern | Meaning | Action |
|---|---|---|
| Keep rate < 20% | Hypothesis quality is poor | Re-read full scope, re-read lessons, change direction |
| Keep rate > 80% | Metric may be too easy or Verify too lenient | Tighten the goal |
| Long crash streak (5+) | Verify command is fragile or scope is too risky | Fix Verify or narrow scope |
| Delta per keep shrinking toward 0 | Approaching local optimum | Try more radical changes or declare victory |
| Metric oscillating | Non-deterministic Verify or contradictory changes | Run Verify twice and average; tighten scope |
### Declaring success
Stop the loop when one of these is true:
- Metric has reached the stated goal
- Delta per keep has been below 0.1% for 20 consecutive iterations
(local optimum with current scope)
- All directions have been exhausted (lessons file confirms this)
In all cases, print a final summary and write a lessons entry covering
the full run before stopping.
---
## File hygiene
- Add `autoresearch-results.tsv` to `.gitignore`. It is a working file.
- Do not edit it manually during a run.
- Between runs, you may archive it:
`mv autoresearch-results.tsv autoresearch-results-<date>.tsv`
and start fresh, but keep the lessons file — that is the persistent memory.
@@ -1,171 +0,0 @@
# Security workflow — `autoresearch security` mode
Autonomous security audit using STRIDE threat modelling and OWASP categories.
Finds vulnerabilities, classifies them by severity, and optionally fixes
confirmed critical and high findings via an autoresearch loop.
---
## Invocation
```
autoresearch security # full audit, report only
autoresearch security --fix # audit + auto-fix confirmed findings
autoresearch security --fail-on critical # end with a FAIL verdict if critical found
autoresearch security --scope src/api/ # audit a specific directory only
```
---
## Phase 1 — Asset discovery
Map the attack surface:
1. Identify all entry points: API routes, form handlers, file uploads,
auth flows, webhooks, admin panels
2. Identify all data stores: databases, caches, file system writes,
environment variables, secrets
3. Identify all trust boundaries: public vs authenticated, user vs admin,
internal vs external services
4. Map data flows: what user input reaches what data store via what path
Output: `security/audit-<timestamp>/attack-surface-map.md`
### Live threat intelligence
Use `WebSearch` to seed the audit with current threats:
```
WebSearch: [your stack] common vulnerabilities [current year]
WebSearch: [your main framework] CVE [current year]
WebSearch: OWASP top 10 [current year]
```
Add any newly discovered attack patterns to the audit queue.
This ensures the audit covers threats that postdate your static analysis tools.
---
## Phase 2 — STRIDE threat model
For each asset and trust boundary, model threats across all 6 STRIDE categories:
| Category | Question to ask |
|---|---|
| **S**poofing | Can an attacker impersonate a user, service, or system? |
| **T**ampering | Can input be modified to alter data or behaviour unexpectedly? |
| **R**epudiation | Can actions be performed without a traceable audit trail? |
| **I**nformation disclosure | Can sensitive data be accessed by unauthorised parties? |
| **D**enial of service | Can the service be made unavailable through normal inputs? |
| **E**levation of privilege | Can a lower-privilege user gain higher-privilege access? |
Output: `security/audit-<timestamp>/threat-model.md`
---
## Phase 3 — Autonomous audit loop
```
LOOP (through all attack vectors from threat model):
1. Select next untested attack vector
2. Deep-dive into the relevant code (read fully — do not skim)
3. Attempt to construct a concrete exploit scenario
4. Validate with code evidence (file:line + exact scenario)
5. Classify: severity + OWASP category + STRIDE tag
6. Log to security-audit-results.tsv
7. Print coverage summary every 5 iterations
8. Continue until all vectors tested
```
### Severity classification
| Severity | Definition |
|---|---|
| Critical | Exploitable without authentication, leads to full compromise or data breach |
| High | Exploitable with low-privilege access, significant impact |
| Medium | Requires specific conditions, moderate impact |
| Low | Minor information disclosure, no direct exploitation path |
| Info | Best practice violation, no immediate security impact |
### Evidence requirement
Every finding MUST have:
- File path and line number
- Exact vulnerable code snippet (copy from source, do not paraphrase)
- Concrete exploit scenario (how an attacker would trigger this)
- Proof of exploitability (not theoretical — show the actual path)
Findings without concrete evidence are logged as "unconfirmed" and flagged
for manual review, not included in the fix loop.
---
## Phase 4 — Report generation
Output folder: `security/audit-<timestamp>/`
```
security/audit-20260325-1430/
├── overview.md ← executive summary + finding counts by severity
├── threat-model.md ← STRIDE analysis per asset
├── attack-surface-map.md ← entry points, data flows, trust boundaries
├── findings.md ← all confirmed findings, sorted by severity
├── owasp-coverage.md ← coverage matrix — which OWASP categories checked
├── recommendations.md ← fix guidance for each confirmed finding
└── security-audit-results.tsv ← machine-readable log of all iterations
```
Print summary:
```
=== Security audit summary ===
Critical: <N>
High: <N>
Medium: <N>
Low: <N>
Info: <N>
Vectors tested: <N> / <total>
OWASP categories covered: <list>
Full report: security/audit-<timestamp>/overview.md
===
```
---
## Phase 5 — Auto-fix loop (with `--fix`)
Only runs when `--fix` flag is passed.
Only fixes **Confirmed Critical and High** findings.
Uses `recommendations.md` as the fix guide for each finding.
```
FOR EACH confirmed Critical/High finding:
1. Read the finding + recommendation
2. Make ONE targeted fix
3. git commit the fix
4. Re-run the specific exploit scenario to verify it no longer works
5. Run full test suite to confirm no regressions
6. If tests break → revert, try alternative fix
7. Maximum 3 attempts per finding, then skip and flag for manual review
8. Log fix outcome to fix-log.md
```
---
## Verdict mode (`--fail-on`)
```
autoresearch security --fail-on critical
```
The audit ends with an explicit verdict line in `overview.md`:
```
VERDICT: FAIL — 2 findings at or above `critical`
VERDICT: PASS — no findings at or above `critical`
```
A skill run has no process exit code, so do not wire this into a CI gate as if
it did — use a real scanner for blocking merges. What it *is* good for is an
unattended scheduled audit: a Qoder Automation running this mode reports the
verdict, and you act on it.
@@ -1,164 +0,0 @@
# Ship workflow — `autoresearch ship`
Run a pre-flight checklist before shipping — tests, types, lint, bundle size,
security basics, and a final autoresearch pass on anything that fails.
The ship workflow is not just a checklist. It runs an autoresearch loop on
each failing gate until it passes, then re-checks. You don't ship broken.
You ship when everything is green.
---
## Invocation
```
autoresearch ship
```
Optional flags:
```
autoresearch ship --fast # skip slow checks (lighthouse, e2e)
autoresearch ship --loop N # max N autoresearch iterations per gate (default: 20)
autoresearch ship --dry-run # report status without fixing anything
```
---
## The ship checklist
The workflow runs these gates in order. Each gate that fails triggers an
autoresearch sub-loop to fix it before moving to the next gate.
### Gate 1 — Tests pass
```bash
npm test # Node.js
pytest # Python
go test ./... # Go
cargo test # Rust
```
If tests fail → autoresearch loop on `src/**/*.ts` (or equivalent) with
metric: failing test count (lower is better), max 20 iterations.
### Gate 2 — No type errors
```bash
npx tsc --noEmit # TypeScript
mypy src/ # Python
```
If errors found → autoresearch loop on `src/**/*.ts` with
metric: error count (lower is better), max 20 iterations.
### Gate 3 — No lint errors
```bash
npx eslint src/ # JavaScript/TypeScript
ruff check src/ # Python
golangci-lint run # Go
```
If errors found → autoresearch loop with metric: lint error count (lower is better).
Auto-fixable errors are fixed first (`--fix` flag), then the loop handles the rest.
### Gate 4 — Bundle size (if applicable)
Only runs for frontend projects (detected: `next.config.*`, `vite.config.*`,
`webpack.config.*`).
```bash
npm run build 2>&1 | grep "First Load JS"
```
Threshold: warn if > 300KB, block if > 500KB (configurable via `.autoresearch.yml`).
If over threshold → autoresearch loop on `src/**/*.tsx, src/**/*.ts` with
metric: bundle size in KB (lower is better), max 20 iterations.
### Gate 5 — No hardcoded secrets
```bash
git diff HEAD~1 --diff-filter=A | grep -iE "(api_key|secret|password|token)\s*=\s*['\"][^'\"]{8,}"
```
If secrets found → do NOT autoresearch. Flag for human review. Block ship.
### Gate 6 — Dependency audit
```bash
npm audit --audit-level=high # Node.js
pip-audit # Python
```
If critical vulnerabilities found → autoresearch loop to update affected
dependencies, max 10 iterations.
---
## Ship report
After all gates pass, print:
```
=== Ship report ===
Tests: ✓ PASS (247 passing)
Types: ✓ PASS (0 errors)
Lint: ✓ PASS (0 errors)
Bundle: ✓ PASS (187KB)
Secrets: ✓ PASS (none detected)
Deps: ✓ PASS (0 high/critical)
Autoresearch loops run: <N>
Total improvements: <M> iterations kept
Ready to ship. Run: git push && <your deploy command>
===
```
If any gate is still failing after the max iterations:
```
=== Ship report ===
Tests: ✓ PASS
Types: ✗ FAIL (3 errors remaining after 20 iterations)
→ manual fix required: src/auth/session.ts:47
Ship BLOCKED. Fix the above before shipping.
===
```
---
## Web research post-check
After all gates pass, use `WebSearch` to check:
```
WebSearch: [your framework] [version] known issues [current year]
WebSearch: [your main dependencies] security advisory [current year]
```
If any critical advisories surface that the dependency audit missed,
flag them before shipping. This is a final sanity check that goes beyond
what local tools can detect.
---
## Configuration via `.autoresearch.yml`
Create this file in your project root to customise ship behaviour:
```yaml
ship:
bundle_warn_kb: 300
bundle_block_kb: 500
max_iterations_per_gate: 20
skip_gates:
- lighthouse # skip if no local server available
extra_gates:
- name: "E2E tests"
command: "npx playwright test"
metric: "failing tests (lower is better)"
max_iterations: 10
```
@@ -1,144 +0,0 @@
# Web research patterns
Qoder exposes a `WebSearch` tool (and `WebFetch` to read a promising result in
full). Use them as a verification supplement — not a replacement for the Verify
command, but an additional signal when local scripts alone cannot capture
quality.
---
## When to use WebSearch in the loop
| Goal type | Use WebSearch for | Example query |
|---|---|---|
| SEO content | Check competing pages, keyword signals | `[target keyword] filetype:md OR site:*.dev` |
| API correctness | Verify endpoint signatures, check for deprecations | `[library] [method] deprecated 2025 OR 2026` |
| Dependency versions | Confirm latest stable before updating | `[package name] latest stable version` |
| Best practices | Check if your approach matches current consensus | `[pattern] best practice [language] 2026` |
| Content accuracy | Ground-truth check generated facts | `[claim] site:official-source.com` |
| Bundle/perf baselines | Compare your score to current industry benchmarks | `[framework] bundle size benchmark 2026` |
---
## Pattern 1 — SEO content verification
Use when: optimising blog posts, landing pages, documentation for search.
After your local score script runs, supplement with:
```
WebSearch: [target keyword] to see what the top 3 results have in common.
Note: heading structure, content length, semantic coverage, internal links.
If top results consistently have trait X that your content lacks,
add "add trait X" as the next hypothesis.
```
This gives you signal that no local readability or keyword-density script can
provide — what the search engine is actually rewarding right now.
---
## Pattern 2 — API currency check
Use when: refactoring code that calls external libraries or APIs.
Before committing any API-surface change:
```
WebSearch: [library name] [method name] changelog 2026
WebSearch: [library name] [method name] deprecated
```
If search returns deprecation notices or breaking changes, note the current
replacement pattern and use that as the hypothesis instead.
This prevents iterating toward a working-but-deprecated solution that will
break on the next library update.
---
## Pattern 3 — Dependency version check
Use when: the Verify command suggests a dependency might be outdated, or when
optimising for security/bundle size.
```
WebSearch: [package name] npm latest 2026
WebSearch: [package name] security advisory
```
Cross-reference against what is in `package.json`, `go.mod`, `requirements.txt`
or equivalent. Use the delta as a hypothesis: "update [package] from X to Y,
check if metric improves."
---
## Pattern 4 — Best practice calibration
Use when: stuck after 5 consecutive discards and local ideas are exhausted.
```
WebSearch: [language/framework] [metric type] optimisation techniques 2026
WebSearch: how to improve [metric] in [stack]
```
Extract 3 concrete, actionable techniques from the top results — use `WebFetch`
on the most promising one if the snippet is too thin. Do not extract vague
advice. Add each as a separate iteration hypothesis. This restocks your
hypothesis pool with externally validated approaches.
---
## Pattern 5 — Benchmark calibration
Use when: you want to know if your current metric value is good relative to
the industry, not just relative to your own baseline.
```
WebSearch: [framework] [metric] benchmark 2026 average
```
If your metric is already at or above the industry median, note this and
shift the goal definition (e.g. from "reduce bundle size" to "reduce bundle
size while improving lighthouse score").
---
## Pattern 6 — Content accuracy check
Use when: the Verify command measures style/structure but not factual accuracy
(e.g. documentation, blog posts, runbooks).
```
WebSearch: [specific claim in content] site:[authoritative source]
```
If the authoritative source contradicts your content, flag this as a
required fix before the next iteration (accuracy issues override metric gains).
---
## Rules for using WebSearch
1. **Supplement, never replace.** The Verify command runs every iteration.
Web research adds signal; it does not replace the metric.
2. **Search at the right time.** Patterns 1-3 supplement Phase 5 (Verify).
Patterns 4-5 are for stuck recovery in Phase 1 (Review). Pattern 6
runs in Phase 6 (Decide) when a kept iteration touches factual claims.
3. **Extract actionable hypotheses.** Never let a search result produce a
vague conclusion ("content could be better"). Always turn the search
result into a specific next hypothesis ("add a FAQ section with 3
questions, which top-ranking competitors include").
4. **Log the research signal.** When a search result influences a hypothesis,
note it in the results log description:
`"added FAQ section (web research: top results for [kw] all include FAQ)"`
5. **Don't over-search.** Maximum one WebSearch call per iteration. If you are
searching every iteration, your Verify command is probably too weak —
strengthen the local script instead.
6. **Cite, don't guess.** `WebSearch` results come with source links; never
turn an unverified snippet into a change that the Guard cannot catch.
+2 -2
View File
@@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf"
import (
"bytes"
"OpenFlare/internal/apps/upload"
"OpenFlare/internal/model"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/model"
)
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
-167
View File
@@ -1,167 +0,0 @@
---
name: go-documentation
description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。
license: Apache-2.0
metadata:
sources: "Google 风格指南"
allowed-tools: Bash(bash:*)
---
# Go 文档
## 可用脚本
- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。
> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。
---
## 文档注释
> **规范**:所有顶层导出名称必须有文档注释。
### 基本规则
1. 以被描述对象的名称开头
2. 冠词("a"、"an"、"the")可以放在名称前面
3. 使用完整句子(首字母大写,带标点符号)
```go
// A Request represents a request to run a command.
type Request struct { ...
// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...
```
行为不明显的未导出类型/函数也应有文档注释。
> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。
---
## 注释语句
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语
---
## 注释行长度
> **建议**:目标约 80 列,但不设硬性限制。
根据标点符号换行。不要拆分长 URL。
---
## 结构体文档
使用段落注释对字段分组。标记可选字段及默认值:
```go
type Options struct {
// 通用设置:
Name string
Group *FooGroup
// 自定义设置:
LargeGroupThreshold int // 可选;默认值:10
}
```
---
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// Package math provides basic constants and mathematical functions.
package math
```
- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...`
- 对于较长的包注释,使用 `doc.go` 文件
> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## 文档编写要点
> **建议**:记录非显而易见的行为,显而易见的行为无需记录。
| 主题 | 何时记录... | 何时跳过... |
|------|------------|------------|
| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 |
| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 |
| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 |
| 清理 | 始终记录资源释放要求 | — |
| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — |
| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 |
关键原则:
- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明
- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明
- 始终记录清理要求(例如,`Call Stop to release resources`)
- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用
- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性
> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。
---
## 可运行示例
> **建议**:在测试文件(`*_test.go`)中提供可运行示例。
```go
func ExampleConfig_WriteTo() {
cfg := &Config{Name: "example"}
cfg.WriteTo(os.Stdout)
// Output:
// {"name": "example"}
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## Godoc 格式化
> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
---
## 快速参考
| 主题 | 关键规则 |
|------|---------|
| 文档注释 | 以名称开头,使用完整句子 |
| 行长度 | 约 80 字符,优先考虑可读性 |
| 包注释 | 每个包一个,放在 `package` 声明之前 |
| 参数 | 仅记录非显而易见的行为 |
| 上下文 | 记录与隐含行为不同的例外情况 |
| 并发 | 记录线程安全性不明确的情况 |
| 清理 | 始终记录资源释放要求 |
| 错误 | 记录哨兵值和类型(注意指针) |
| 示例 | 在测试文件中使用可运行示例 |
| 格式化 | 空行分隔段落,缩进表示代码 |
---
## 相关技能
- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md)
- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md)
- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md)
- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -1,61 +0,0 @@
// Package example demonstrates proper Go documentation conventions.
//
// This package shows how to write doc comments for packages, types,
// functions, methods, and constants following Google Go Style Guide
// conventions.
//
// # Getting Started
//
// Create a new Widget with [NewWidget]:
//
// w := example.NewWidget("name")
// defer w.Close()
package example
import "errors"
// ErrNotFound is returned when a requested item does not exist.
var ErrNotFound = errors.New("example: not found")
// MaxRetries is the default number of retry attempts.
const MaxRetries = 3
// Widget processes items with configurable options.
//
// A zero-value Widget is not valid; use [NewWidget] to create one.
// Widget is safe for concurrent use.
//
// # Cleanup
//
// Call [Widget.Close] when done to release resources.
type Widget struct {
name string
}
// NewWidget creates a Widget with the given name.
//
// Name must be non-empty; NewWidget panics otherwise.
func NewWidget(name string) *Widget {
if name == "" {
panic("example: name must be non-empty")
}
return &Widget{name: name}
}
// Process handles the given input and returns the result.
//
// Process returns [ErrNotFound] if the input references
// a missing item.
func (w *Widget) Process(input string) (string, error) {
return input, nil
}
// Close releases resources held by the Widget.
func (w *Widget) Close() error {
return nil
}
// Deprecated: Use [NewWidget] with functional options instead.
func NewWidgetLegacy(name string) *Widget {
return NewWidget(name)
}
@@ -1,239 +0,0 @@
# 文档约定参考
## 参数和配置
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
```go
// 不好:重复了显而易见的信息
// Sprintf formats according to a format specifier and returns the resulting string.
//
// format is the format, and data is the interpolation data.
func Sprintf(format string, data ...any) string
// 好:记录了非显而易见的行为
// Sprintf formats according to a format specifier and returns the resulting string.
//
// The provided data is used to interpolate the format string. If the data does
// not match the expected format verbs or the amount of data does not satisfy
// the format specification, the function will inline warnings about formatting
// errors into the output string.
func Sprintf(format string, data ...any) string
```
---
## 上下文
> **建议**:不要重复隐含的上下文行为;记录例外情况。
上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。
```go
// 不好:重复了隐含的行为
// Run executes the worker's run loop.
//
// The method will process work until the context is cancelled.
func (Worker) Run(ctx context.Context) error
// 好:只记录关键信息
// Run executes the worker's run loop.
func (Worker) Run(ctx context.Context) error
```
**当行为不同时记录:**
```go
// 好:非标准的取消行为
// Run executes the worker's run loop.
//
// If the context is cancelled, Run returns a nil error.
func (Worker) Run(ctx context.Context) error
// 好:特殊的上下文要求
// NewReceiver starts receiving messages sent to the specified queue.
// The context should not have a deadline.
func NewReceiver(ctx context.Context) *Receiver
```
---
## 并发
> **建议**:记录非显而易见的线程安全特性。
只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。
**何时记录:**
```go
// 不明确的操作(看似只读但内部有修改)
// Lookup returns the data associated with the key from the cache.
//
// This operation is not safe for concurrent use.
func (*Cache) Lookup(key string) (data []byte, ok bool)
// API 提供同步机制
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
// It is safe for simultaneous use by multiple goroutines.
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
// 接口有并发要求
// A Watcher reports the health of some entity (usually a backend service).
//
// Watcher methods are safe for simultaneous use by multiple goroutines.
type Watcher interface {
Watch(changed chan<- bool) (unwatch func())
Health() error
}
```
---
## 清理
> **建议**:始终记录显式清理要求。
```go
// 好:
// NewTicker returns a new Ticker containing a channel that will send the
// current time on the channel after each tick.
//
// Call Stop to release the Ticker's associated resources when done.
func NewTicker(d Duration) *Ticker
// 好:展示如何清理
// Get issues a GET to the specified URL.
//
// When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
//
// resp, err := http.Get("http://example.com/")
// if err != nil {
// // handle error
// }
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)
```
---
## 错误
> **建议**:记录重要的错误哨兵值和类型。
```go
// 好:记录哨兵值
// Read reads up to len(b) bytes from the File and stores them in b.
//
// At end of file, Read returns 0, io.EOF.
func (*File) Read(b []byte) (n int, err error)
// 好:记录错误类型(包含指针接收者)
// Chdir changes the current working directory to the named directory.
//
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error
```
注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。
对于包级别的错误约定,在包注释中记录。
---
## 命名返回参数
> **建议**:在类型本身不够清晰时用于文档说明。
```go
// 好:多个同类型参数
func (n *Node) Children() (left, right *Node, err error)
// 好:面向操作的名称阐明了用法
// The caller must arrange for the returned cancel function to be called.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())
// 不好:类型已经很清晰,命名没有增加信息
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)
// 好:类型已足够
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)
```
不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。
---
## 弃用通知
> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。
`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。
**标准格式:**
```
// Deprecated: Use NewThing instead.
```
Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。
**函数弃用:**
```go
// EstimateSize returns an approximate byte count.
//
// Deprecated: Use [Size] instead, which returns an exact count.
func EstimateSize(r io.Reader) (int64, error)
```
**类型弃用:**
```go
// LegacyClient talks to the v1 API.
//
// Deprecated: Use [Client] instead, which supports v2.
type LegacyClient struct{ /* ... */ }
```
**包弃用** — 在包文档注释中添加 `Deprecated:`:
```go
// Package old provides the original implementation.
//
// Deprecated: Use package example/new instead.
package old
```
始终建议具体的替代方案,让调用者知道迁移目标。
---
## 注释语句 — 详细说明
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语:
```go
// 好:
// A Server handles serving quotes from Shakespeare.
type Server struct {
// BaseDir points to the base directory for Shakespeare's works.
//
// Expected structure:
// {BaseDir}/manifest.json
// {BaseDir}/{name}/{name}-part{number}.txt
BaseDir string
WelcomeMessage string // 用户登录时显示
ProtocolVersion string // 与传入请求进行校验
PageLength int // 每页行数(可选;默认值:20)
}
```
@@ -1,107 +0,0 @@
# 包注释和示例参考
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// 好:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results across architectures.
package math
```
### Main 包
使用二进制名称(与 BUILD 文件匹配):
```go
// 好:
// The seed_generator command is a utility that generates a Finch seed file
// from a set of JSON study configs.
package main
```
有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...`
### doc.go
- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件
- 放在 import 之后的维护者注释不会出现在 Godoc 中
- 保持 doc.go 文件专注于面向用户的文档
```go
// Package complex provides advanced mathematical operations for
// complex number arithmetic, including polar form conversion,
// matrix operations, and numerical integration.
//
// Basic usage
//
// Create a complex number and perform operations:
//
// z := complex.New(3, 4)
// magnitude := z.Abs() // 5.0
// conjugate := z.Conj() // (3, -4)
//
// Matrix operations
//
// The package supports complex-valued matrices:
//
// m := complex.NewMatrix(2, 2)
// m.Set(0, 0, complex.New(1, 0))
// det := m.Det()
package complex
```
---
## 可运行示例
> **建议**:提供可运行示例来展示包的用法。
将示例放在测试文件(`*_test.go`)中:
```go
// 好:
func ExampleConfig_WriteTo() {
cfg := &Config{
Name: "example",
}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Exitf("Failed to write config: %s", err)
}
// Output:
// {
// "name": "example"
// }
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
### 命名约定
| 函数名称 | 文档对象 |
|----------|---------|
| `Example()` | 包级别示例 |
| `ExampleFoo()` | 函数 `Foo` |
| `ExampleBar_Baz()` | 方法 `Bar.Baz` |
| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 |
### 技巧
- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证
- 保持示例专注于展示一个概念
- 使用真实但精简的数据
- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁
- 同一符号的多个示例使用小写 `_suffix`:
```go
func ExampleNewClient_withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := NewClient(ctx)
// ...
}
```
@@ -1,85 +0,0 @@
# Godoc 格式化参考
## Godoc 格式化
> **建议**:使用 godoc 语法编写格式良好的文档。
**段落** - 用空行分隔:
```go
// 好:
// LoadConfig reads a configuration out of the named file.
//
// See some/shortlink for config file format details.
```
**逐字/代码块** - 额外缩进两个空格:
```go
// 好:
// Update runs the function in an atomic transaction.
//
// This is typically used with an anonymous TransactionFunc:
//
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
// //...
// }
```
**列表和表格** - 使用逐字格式:
```go
// 好:
// LoadConfig treats the following keys in special ways:
// "import" will make this configuration inherit from the named file.
// "env" if present will be populated with the system environment.
```
**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落:
```go
// 好:
// Using headings
//
// Headings come with autogenerated anchor tags for easy linking.
```
---
## 信号增强
> **建议**:添加注释以突出不寻常或容易被忽略的模式。
以下两种情况很难区分:
```go
if err := doSomething(); err != nil { // 常见
// ...
}
if err := doSomething(); err == nil { // 不寻常!
// ...
}
```
添加注释来增强信号:
```go
// 好:
if err := doSomething(); err == nil { // 如果没有错误
// ...
}
```
---
## 文档预览
> **建议**:在代码审查之前和期间预览文档。
```bash
go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite
```
这可以验证 godoc 格式化是否正确渲染。
@@ -1,298 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.0.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Check for missing doc comments on exported Go symbols
USAGE
bash $SCRIPT_NAME [options] [path]
DESCRIPTION
Scans Go source files for exported functions, types, methods, constants,
and variables that lack doc comments. Go convention requires all exported
symbols to have a doc comment starting with the symbol name.
Exits 0 if all exports are documented, 1 if undocumented exports found,
2 on error.
OPTIONS
-h, --help Show this help message
-v, --version Show version
--json Output results as JSON
--strict Also check unexported types/functions with 5+ lines
--limit N Show at most N results (default: all)
ARGUMENTS
path Directory or file to check (default: ./...)
EXAMPLES
bash $SCRIPT_NAME
bash $SCRIPT_NAME ./pkg/api
bash $SCRIPT_NAME --json .
bash $SCRIPT_NAME --strict ./internal/server
EOF
}
JSON_OUTPUT=false
STRICT=false
LIMIT=0
TARGET=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
--json) JSON_OUTPUT=true; shift ;;
--strict) STRICT=true; shift ;;
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGET="$1"; shift ;;
esac
done
TARGET="${TARGET:-./...}"
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
find_go_files() {
local t="$1"
if [[ -f "$t" ]]; then
echo "$t"
elif [[ -d "$t" ]]; then
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
local dir="${t%%/...}"
dir="${dir:-.}"
if [[ -d "$dir" ]]; then
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
echo "error: path not found: $t" >&2
exit 2
fi
fi
}
MISSING=()
add_missing() {
local file="$1" line="$2" kind="$3" name="$4"
MISSING+=("${file}:${line}|${kind}|${name}")
}
check_file() {
local file="$1"
local prev_line=""
local prev_prev_line=""
local line_num=0
local in_grouped_block=false
local grouped_kind=""
local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\('
local re_grouped_open='^(const|var|type)[[:space:]]*\($'
local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)'
local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)'
while IFS= read -r line; do
line_num=$((line_num + 1))
# Check exported function/method declarations
if [[ "$line" =~ ^func[[:space:]] ]]; then
local name=""
local kind=""
# Method: func (r *Type) Name(
if [[ "$line" =~ $re_method ]]; then
name="${BASH_REMATCH[1]}"
kind="method"
# Function: func Name(
elif [[ "$line" =~ $re_func ]]; then
name="${BASH_REMATCH[1]}"
kind="function"
fi
if [[ -n "$name" ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$kind" "$name"
fi
fi
# Strict mode: also check unexported functions
if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then
name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "function" "$name"
fi
fi
fi
# Check exported type declarations
if [[ "$line" =~ $re_exported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Strict mode: also check unexported type declarations
if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Check exported const (single-line, not in block)
if [[ "$line" =~ $re_exported_const ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "const" "$name"
fi
fi
# Check exported var (single-line, not blank identifier)
if [[ "$line" =~ $re_exported_var ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "var" "$name"
fi
fi
# Check package comment
if [[ "$line" =~ ^package[[:space:]]+ ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
local pkg_name
pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//')
add_missing "$file" "$line_num" "package" "$pkg_name"
fi
fi
# Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... )
if [[ "$line" =~ $re_grouped_open ]]; then
in_grouped_block=true
grouped_kind="${BASH_REMATCH[1]}"
fi
if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then
in_grouped_block=false
grouped_kind=""
fi
if $in_grouped_block && [[ -n "$grouped_kind" ]]; then
# Check for exported names inside grouped block
if [[ "$line" =~ $re_grouped_exported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
# Strict: also check unexported names in grouped blocks
if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
fi
prev_prev_line="$prev_line"
prev_line="$line"
done < "$file"
}
is_documented() {
local prev="$1"
local prev_prev="$2"
# Previous line is a comment (// or end of block comment */)
if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then
return 0
fi
# Previous line might be empty but line before is comment (allow one blank line)
if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then
return 0
fi
return 1
}
FILES=()
while IFS= read -r f; do
[[ -n "$f" ]] && FILES+=("$f")
done < <(find_go_files "$TARGET")
if [[ ${#FILES[@]} -eq 0 ]]; then
if $JSON_OUTPUT; then
echo '{"missing":[],"count":0,"status":"no_go_files"}'
else
echo "No Go files found in: $TARGET"
fi
exit 0
fi
for file in "${FILES[@]}"; do
check_file "$file"
done
# Truncation
TOTAL=${#MISSING[@]}
TRUNCATED=false
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
MISSING=("${MISSING[@]:0:$LIMIT}")
TRUNCATED=true
fi
if $JSON_OUTPUT; then
echo "{"
echo ' "missing": ['
first=true
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
IFS='|' read -r location kind name <<< "$entry"
file="${location%%:*}"
line="${location#*:}"
$first || echo ","
first=false
printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \
"$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")"
done
echo ""
echo " ],"
printf ' "total": %d,\n' "$TOTAL"
printf ' "truncated": %s\n' "$TRUNCATED"
echo "}"
else
if [[ $TOTAL -eq 0 ]]; then
echo "All exported symbols are documented."
exit 0
fi
echo "Undocumented exported symbols:"
echo ""
for entry in "${MISSING[@]}"; do
IFS='|' read -r location kind name <<< "$entry"
printf " %s [%s] %s\n" "$location" "$kind" "$name"
done
if $TRUNCATED; then
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
fi
echo ""
echo "Total: $TOTAL undocumented symbol(s)"
fi
if [[ $TOTAL -gt 0 ]]; then
exit 1
fi
exit 0
+93 -29
View File
@@ -13,38 +13,102 @@ description: "Wavelet 项目专用:当新增或修改自定义业务 API、新
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
### 插件目录标准结构 (`backend/openflare/plugins/<name>/` 或 `backend/plugins/domain/<name>/`)
1. **禁止修改框架级路由文件**:
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
- `internal/router/router.go`(核心入口委派)
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
- `internal/router/root/frontend.go`(前端静态服务)
- `internal/router/v1/v1.go`(V1 分发层协调器)
- `internal/router/v1/admin.go`(框架管理员端管理接口)
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
2. **仅允许在 `custom.go` 中注册业务接口**:
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
所有标准插件与下游定制插件,**统一以 `backend/downstream/plugins/custom_example` 为基准模板**,严格采用物理子包隔离的分层架构:
---
## 路由归属判定表 (Where should I register my new API?)
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
| :--- | :--- | :--- | :--- |
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
---
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
### 1. 根路径自定义包:`root/custom.go`
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
* **用法示例**:
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
```go
package root
import (
"github.com/Rain-kl/Wavelet/internal/apps/custom"
"github.com/gin-gonic/gin"
)
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
func RegisterCustomRootRoutes(r *gin.Engine) {
// 挂载到根路径下,如 GET /my-custom-webhook
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
}
```
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
### 2. V1 API 自定义包:`v1/custom.go`
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
* **用法示例**:
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
```go
package v1
import (
"github.com/Rain-kl/Wavelet/internal/apps/custom"
"github.com/gin-gonic/gin"
)
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
customRouter := apiV1Router.Group("/custom")
{
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
customRouter.POST("/action", custom.DoActionHandler)
}
}
```
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
---
## 建议创建/修改的文件结构 (Recommended Directory Structure)
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
```text
backend/openflare/plugins/<name>/ (或 backend/plugins/domain/<name>/)
├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册
│
├── consts/ # package consts:常量、配置键名与错误码定义
│ └── consts.go
│
├── controller/ # package controller:HTTP 控制器与路由声明 (参数绑定、会话获取、信封响应)
│ └── hello/ # 业务分组/实体子包
│ └── hello.go # 接口处理 Handler(直接以业务命名,禁止 controller_hello.go)
│
├── service/ # package service:业务逻辑层(用例编排、事务控制、事件发布)
│ └── order.go # 订单业务用例实现(纯 Go 逻辑,禁止依赖 *gin.Context)
│
├── dao/ # package dao:数据访问持久化层 DAL (GORM CRUD、SQL 转义防注入)
│ └── order.go # 订单数据访问实现(直接以业务命名,禁止 dao_order.go)
│
├── model/ # package model:纯数据实体与 DTO(无外部依赖)
│ ├── entity/ # 数据库映射实体 (TableName() 带插件专属前缀)
│ │ └── order.go
│ └── do/ # 请求 Request DTO 与响应 Response DTO、领域对象
│ └── order.go
│
└── migrations/ # 专属嵌入式 Goose SQL 双方言迁移脚本 (//go:embed)
├── postgres/ # PostgreSQL 迁移脚本
└── sqlite/ # SQLite 迁移脚本
internal/
├── router/
│ ├── root/
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
│ └── v1/
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
└── apps/
└── custom/
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
```
> ⚠️ **严禁**:严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`dao_*.go` 等前缀文件,子包内文件直接按业务实体命名。严格约束 `controller -> service -> dao -> model` 单向依赖。
---
@@ -63,7 +127,7 @@ backend/openflare/plugins/<name>/ (或 backend/plugins/domain/<name>/)
在 `internal/apps/custom/routers.go` 中编写 Handler:
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
- 负责提取 Session / 用户身份。
- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应:
- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/shared/response` 统一返回响应:
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
- 失败时返回:`response.Err(msg)`
- 编写规范的 Swagger 注释。
@@ -6,8 +6,8 @@ package references
import (
"net/http"
"OpenFlare/internal/service"
"OpenFlare/internal/util"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/util"
"github.com/gin-gonic/gin"
)
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"OpenFlare/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/logger"
"go.uber.org/zap"
)
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"OpenFlare/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/logger"
"go.uber.org/zap"
)
@@ -10,7 +10,7 @@
package upload
import (
"OpenFlare/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/infra/task"
)
// 异步任务类型标识。格式建议为 "{module}:{action}"。
@@ -83,7 +83,7 @@ package upload
import (
"context"
"OpenFlare/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/infra/task"
)
type CleanupUnusedUploadsHandler struct{}
@@ -114,7 +114,7 @@ import (
"fmt"
"strings"
"OpenFlare/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/infra/task"
)
type SendEmailPayload struct {
@@ -169,9 +169,9 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
package handlers
import (
"OpenFlare/internal/apps/upload"
"OpenFlare/internal/apps/user"
"OpenFlare/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/Rain-kl/Wavelet/internal/infra/task"
)
func Register() {
+7 -6
View File
@@ -14,9 +14,10 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
| **`backend/plugins/domain/msg_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
| :--- | :--- | :--- |
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
@@ -37,8 +38,8 @@ import (
"context"
"time"
"OpenFlare/internal/apps/admin/push"
"OpenFlare/internal/listener"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/listener"
)
var NewUserRegistered = push.EventMetadata{
@@ -83,7 +84,7 @@ func Register() {
在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件:
```go
import "OpenFlare/internal/listener"
import "github.com/Rain-kl/Wavelet/internal/listener"
func Register(c *gin.Context) {
// ... 注册成功逻辑 ...
-242
View File
@@ -1,242 +0,0 @@
# Autoresearch lessons — Wavelet / Cordis quality run
Accumulated wisdom across iterations. Read this before forming a hypothesis.
Weight recent lessons higher: the yardstick and codebase change under us.
## Lesson 1 — iterations 0-1
**Pattern**: The project's committed gate (`golangci-lint run` with `.golangci.yml`)
had already been driven to 0 issues by a previous run, so it could no longer
measure anything.
**Why it worked**: Measuring against a pinned snapshot + extra analyzers in
`.auto/lint.ref.yaml` (hash-locked by the Guard) restored headroom and made it
impossible to lower the number by editing the config.
**Conditions**: Any repo whose own lint gate is already green.
**Anti-pattern**: Optimising `tagliatelle` (325 findings) or `wrapcheck` (290).
Those are pure cosmetics — error-message wording and tag naming. A run that
chases them will look productive while shuffling strings.
**Metric delta**: baseline re-established at 102 instead of a dead 0.
## Lesson 2 — iterations 1-4
**Pattern**: Triage every analyzer finding for reality before "fixing" it.
**Why it worked**: Three buckets turned out to be false positives:
`forcetypeassert` in `core/events.go` is guarded by `returnsErr` (the handler's
declared last out really is `error`), and both `exhaustive` switches already
have `default:` arms — `exhaustive` only flags them because
`default-signifies-exhaustive` defaults to false.
**Conditions**: Always, but especially for linters whose defaults assume a
different project convention.
**Anti-pattern**: Adding `if !ok { ... }` branches or empty `case:` arms that
cannot execute. That raises the score and lowers the code.
**Metric delta**: 3 of 16 candidate linters dropped from the plan (0 gained,
real regressions avoided).
## Lesson 3 — iterations 1, 4
**Pattern**: Pair the metric drop with a mechanically provable defect: write the
regression test, commit, then revert *only* the source files and require the
test to fail (`.auto/prove_fix.sh`).
**Why it worked**: It caught a live bug that no counter measures — a
singleflight body capturing the first caller's request context, so one
disconnecting browser poisoned every concurrent request for that image.
Iteration 4 kept debt flat at 93 yet was the most valuable change so far.
**Conditions**: Every behavioural fix. A change that survives its own revert is
not a fix, it is a rename.
**Anti-pattern**: Calling something "hardening" without a test that fails
without it.
**Metric delta**: 0 for the proven bug (kept under the fix gate), 8 for the rest.
## Lesson 4 — iteration 5
**Pattern**: Strengthen the architecture gate; it is a generator of real,
previously invisible debt.
**Why it worked**: `check_cordis_architecture.sh` only grepped `go func(`, so
`go w.run()` — the shape used by four long-lived cleanup loops — passed
silently, each one able to take down the process on a panic. Widening the
pattern surfaced them immediately.
**Conditions**: Whenever a gate has been green for a long time. A green gate
proves the checks exist, not that they cover anything.
**Anti-pattern**: Weakening `.golangci.yml` (blocked outright by the Guard via
`check_gate_weaken.py` + a SHA lock on the yardstick).
**Metric delta**: 4 uncovered crash-on-panic sites hardened.
## Lesson 5 — iteration 3
**Pattern**: Deduplicate by extracting the shared *classification*, not the
shared *response*.
**Why it worked**: Two handlers mapped upload-lookup errors with copy-pasted
blocks that had quietly drifted (different fallback status, different synonym
constant for the same message). `filesrv.AbortUploadRecordError` handles the
200/400 branches, and each endpoint keeps its own fallback it can still
justify. Deleting the orphaned `ErrInvalidUploadID` constant was part of the
change, not extra cleanup.
**Conditions**: Duplicated error-mapping or validation blocks in sibling handlers.
**Anti-pattern**: Silently unifying HTTP status codes across endpoints to make a
helper fit — that is a behaviour change wearing a refactor's clothes.
**Metric delta**: -2.
## Lesson 6 — iterations 15-21
**Pattern**: Delegate a broad read-only audit for what mechanical gates cannot
see (N+1s, locks held over I/O, resource leaks, layering), then re-verify each
claim yourself before touching code.
**Why it worked**: The audit produced the run's best findings — the per-request
CORS database query, the orphan cron dispatching to a task nobody registered,
media temp dirs nothing ever removed. It also produced a wrong one: it asserted
telebot falls back to `http.DefaultClient` with no timeout, when telebot itself
constructs a client with a one minute deadline. Acting on that would have added
a tunable dressed up as a bug fix.
**Conditions**: Whenever the committed gates are green and the easy signal is
exhausted.
**Anti-pattern**: Trusting an audit summary's file:line as evidence. One
referenced file did not exist.
**Metric delta**: 0 for three landed fixes (all kept under the proven-fix gate),
but they were the run's highest-impact changes.
## Lesson 7 — iteration 16
**Pattern**: Prove query-reduction with a functional test double that counts
loader invocations, and assert the counter for both the batch and the looped
form in the same test.
**Why it worked**: Asserting "1 query" alone is vacuous — it also passes when
nothing ran. Asserting batch=1 and per-id=3 in one test makes the instrument
itself checked, so the claim cannot silently degrade.
**Conditions**: Any change whose whole value is doing less I/O.
**Anti-pattern**: Fixing an N+1 by reaching around the contract into another
plugin's repository. The layering was the reason the slow path existed; the
right move was to extend the contract with a batch method.
**Metric delta**: 0 (kept under the proven-fix gate).
## Lesson 8 — iterations 17-22
**Pattern**: Strengthen a gate only alongside the code that satisfies it, and
never rewrite history in a shared worktree.
**Why it worked**: Deleting 24 dead lint suppressions paid off exactly as the
self-correcting design predicted: two of them were load-bearing under the
project's own gate even though the analyzer called them unused, the Guard
vetoed, and their removal surfaced two verified `contextcheck` false positives
worth documenting instead of silently swallowing. Meanwhile a concurrent
session was committing plan documents in the same tree, so `git add -A` swept
one of its in-flight edits into my commit — unfixable by rebase without
destroying their work, so the repair was to stage explicit paths from then on.
**Conditions**: Always, in this repo. Assume another agent is editing `docs/`
and `backend/core` concurrently.
**Anti-pattern**: `git add -A` outside the first setup commit. Also: trusting
"unused directive" as "safe to delete" — check the strictest config, not just
the pinned yardstick.
**Metric delta**: -25 in one iteration.
## Lesson 9 — iterations 23-24
**Pattern**: Cross-check every service a plugin's `Apply` reads out of the
container against what that plugin's `Inject()` declares. `Inject()` is the only
thing `App.reconcileLocked` gates on, so anything consumed as a *value* at Apply
time but left undeclared is resolved from a container that may not hold it yet.
**Why it worked**: It found the run's worst defect, invisible to every
mechanical gate: `user` declared only `DBService` while capturing
`contracts.AuthService` to build its route guard, and `cmd/app.go` lists `user`
before `auth`. Because user's dep set is a strict subset of auth's and it sits
earlier in the slice, user *always* mounts first — deterministically, not a
race — so `loginMW` fell back to a `c.Next()` closure and
`/api/v1/user/{change-password,profile,access-tokens}` mounted unguarded. The
same lookups in `admin` read a package global that its own `OnDispose` nils, so
in-flight requests fail open during dispose.
**Conditions**: Any Cordis plugin whose Apply assigns a contract result to a
variable used later (middleware, handler closures). Services bound through
`core.When` late binding are exempt — that is the correct pattern for genuinely
late deps, so do not blanket-declare everything.
**Anti-pattern**: Assuming a checked `x, ok :=` assertion is safe. All three
plugins used the checked form and all three failed *open* — checked syntax,
unchecked semantics.
**Metric delta**: 0 across both iterations (kept under the proven-fix gate),
but this is the run's highest-severity finding. `RouterRegistry` records each
route's `Handlers`/`Middlewares`, which makes "is this route actually guarded?"
directly assertable from the route table — the cheapest available oracle for
security properties here.
## Lesson 10 — iteration 23 review
**Pattern**: When the remaining metric is dominated by a positional or
taste-based analyzer, say so and refuse to spend iterations on it.
**Why it worked**: `funcorder` was 21 of 54 findings (39%) — pure function
*ordering within a file*. Reordering private helpers to the bottom of a file
moves the number and changes nothing a reader or the machine cares about, which
is Lesson 1's "looks productive while shuffling strings" with a different label.
Skipping it kept the loop honest. Triage also cleared 12 of 13
`forcetypeassert` (guarded by construction) and 2 of 3 `unparam` (deliberate
constructor symmetry behind one factory switch).
**Conditions**: Whenever one linter dominates a shrinking total, break the count
down per linter *before* picking a hypothesis.
**Anti-pattern**: Treating a large single-linter share as an easy win. Real
headroom at this point is ~10 findings, so a plateau in `debt` no longer means a
stalled loop.
**Metric delta**: 0 spent, ~21 findings deliberately left in place.
## Lesson 11 — iterations 24-25
**Pattern**: Run the Guard after every single commit, and confirm which commit a
proof script is actually reverting against.
**Why it worked**: Four `staticcheck ST1023` findings from iteration 24 shipped
straight through `go build ./...` and a green 47-package `go test ./...` —
neither runs the project linter, so only `checks.sh` section 3 catches them.
Separately, `prove_fix.sh` reverts to `HEAD^`; appending the iteration-23 log
commit shifted `HEAD^` to the *fixed* state and reported "PROVE FAILED: tests
still pass without the fix" on a genuinely load-bearing fix. Re-checking against
the explicit pre-fix commit (`git checkout <sha> -- <files>`) showed the real
answer. A false negative here is worse than no proof: it reads like the fix was
cosmetic.
**Conditions**: Always. Also note zsh does not word-split unquoted variables, so
`git checkout $FILES` passes one bogus pathspec and silently reverts nothing —
the command still exits 0.
**Anti-pattern**: Batch-verifying at ship time. And any shell loop built on the
bash word-splitting habit in this environment.
**Metric delta**: -0, 1 wrong verdict corrected.
## Lesson 12 — iterations 27-32
**Pattern**: Two things produced every substantive win: (1) find a place where
correctness rests on a *prose comment* instead of an enforced constraint, and (2)
find immutable startup work being redone inside a request path.
**Why it worked**: Lint cannot see either class, so `debt` barely moved while real
defects did. The comment "field comes from call sites, never from user input" sat
on a function that interpolated its column argument straight into `WHERE` — the
tautology payload executed and returned a row with `err=<nil>`, a filter bypass,
not a hypothetical. The comment "contracts are pure abstractions" sat on a DTO
carrying `TableName()`, which is exactly the handle four plugins used to read
`w_users` instead of calling `UserService`. On the second pattern, three packages
each re-normalised and re-split static whitelist patterns per request: hoisting
that to registration cut 14 allocs/op to 1.
**Conditions**: Any exported function taking a string that reaches SQL, a path
matcher, or a shell. Any loop over configuration inside a request handler.
**Anti-pattern**: Believing `nolintlint`'s "unused directive" means "safe to
delete" — hit twice now, and the project gate vetoed it both times. Also believing
a doc comment's self-assessment: verify the claim or leave it alone.
**Metric delta**: 64 -> 63 across five keeps. Four of the five kept changes had
delta 0. Under a pure-debt loop this run would have looked stalled while fixing a
security bypass and a hot-path allocation bug.
## Standing notes
- **The golangci-lint cache is machine-wide** (`~/.cache/golangci-lint`), so a
sibling worktree analysing identical sources replays here carrying *that*
checkout's absolute paths — 12 of 63 findings pointed outside the repo, which
misattributes findings and can serve a stale Guard verdict. `measure.sh` and
`checks.sh` now key `GOLANGCI_LINT_CACHE` per checkout (iteration 31). It is
count-neutral (cold and warm both 63), but check path attribution before
trusting any finding's location.
- **Do not delegate a repo-wide audit to one subagent.** Both broad audits
(architecture, bugs/perf) hit the 150-turn cap after ~45M tokens combined and
returned nothing usable. Everything this run found came from targeted inline
greps followed by reading the specific function. If delegating, bound it to one
package cluster and a small finding budget.
- Run decisions for this run: real defects first with `debt` as a secondary gate,
commits directly on `main`, small file moves allowed but large package
restructuring goes to a written proposal first.
- Upstream moves fast in this repo: `origin/main` gained 11 commits mid-run
(Cordis config extension point — `ctx.Config().Bind`, `DeclareConfig()`,
`core.ConfigGatedPlugin`), which raised measured `debt` 54 -> 64 and
`nolint_dirs` 72 -> 73 on its own. Rebase early and re-run the Guard after;
a clean rebase does not mean a green one.
- `cmd.TestNewWaveletAppWithRedisEnabled` needs a live Redis on
`127.0.0.1:6379` and fails without one. Pre-existing on `origin/main`, so
`tests_passed` 46 vs 47 is environmental, not a regression. Confirm against a
scratch `git worktree` of `origin/main` before blaming a change for it.
- Repo facts: backend module rooted at `backend/`, gofumpt orders a single
import group as `Wavelet/...` before stdlib (uppercase sorts first); new Go
files need the Apache license header or `scripts/update_go_license.sh --check`
fails the Guard.
- Handler edits require `make swagger` (cheap: it regenerates identical docs
when only bodies change).
- Dead suppressions are tracked by the `nolint_dirs` counter; removing one that
is still needed re-raises the original finding, so the metric self-corrects.
24 were removed in iteration 22; 72 remain, each still doing work (73 after
the upstream rebase).
-12
View File
@@ -1,12 +0,0 @@
# Autoresearch baseline — captured at iteration #0 (2026-08-29).
# The Guard compares live values against these floors; the PRIMARY metric is debt.
BASE_DEBT=102
BASE_NOLINT=96
BASE_TESTS_PASSED=46
BASE_TEST_FUNCS=232
BASE_TEST_FILES=76
BASE_ARCH_VIOL=0
BASE_COVERAGE=34.09
# SHA-256 of the pinned yardstick config. Guard aborts if it changes.
REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened.
Compares the live gate against the immutable snapshot taken at run start.
Exits non-zero with a reason if any hardening rule is violated.
"""
import os
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml")
LIVE = os.path.join(ROOT, ".golangci.yml")
# threshold-like knobs: (path, direction) where direction "max" means the value
# is an upper bound (smaller == stricter), "min" means a lower bound.
STRICTNESS = [
(("linters", "settings", "dupl", "threshold"), "max"),
(("linters", "settings", "cyclop", "max-complexity"), "max"),
(("linters", "settings", "cyclop", "package-average"), "max"),
(("linters", "settings", "nestif", "min-complexity"), "min"),
(("linters", "settings", "funlen", "lines"), "max"),
(("linters", "settings", "funlen", "statements"), "max"),
(("linters", "settings", "gocyclo", "min-complexity"), "min"),
(("linters", "settings", "lll", "line-length"), "max"),
]
def load(path):
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def dig(doc, path):
node = doc
for key in path:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def enabled_linters(doc):
lint = doc.get("linters") or {}
if lint.get("enable-presets"):
return None # preset based; fall back to "any removal is suspicious"
return set(lint.get("enable") or [])
def main():
try:
base, live = load(BASELINE), load(LIVE)
except OSError as exc:
print(f"gate snapshot unreadable: {exc}")
return 1
except yaml.YAMLError as exc:
print(f".golangci.yml is not parseable: {exc}")
return 1
problems = []
base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {}
if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"):
problems.append("linters.default changed")
base_set, live_set = enabled_linters(base), enabled_linters(live)
if base_set is None or live_set is None:
if set((base.get("linters") or {}).get("enable-presets") or []) - set(
(live.get("linters") or {}).get("enable-presets") or []
):
problems.append("an enable-preset was removed")
elif dropped := base_set - live_set:
problems.append(f"linters disabled: {sorted(dropped)}")
for path, direction in STRICTNESS:
old, new = dig(base, path), dig(live, path)
if old is None or new is None:
continue
try:
old_f, new_f = float(old), float(new)
except (TypeError, ValueError):
continue
if direction == "max" and new_f > old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
if direction == "min" and new_f < old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or [])
live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or [])
if base_mnd - live_mnd:
problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}")
issues_live = live.get("issues") or {}
for key in ("exclude-rules", "exclude-patterns"):
if issues_live.get(key) and not (base.get("issues") or {}).get(key):
problems.append(f"issues.{key} added (suppresses reporting)")
for key in ("max-issues-per-linter", "max-same-issues"):
old = (base.get("issues") or {}).get(key)
new = issues_live.get(key)
if old == 0 and new != 0:
problems.append(f"issues.{key} no longer 0 — findings would be truncated")
# Exclusions expressed through the newer 'linters.exclusions' block.
if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}):
problems.append("linters.exclusions added")
if problems:
print("\n".join(f" - {p}" for p in problems))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-61
View File
@@ -1,61 +0,0 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# 基础检查
- govet
- staticcheck
- errcheck
- ineffassign
- unused
# 代码坏味道
- dupl # 重复代码
- mnd # 魔法数字
- goconst # 不必要的字符串常量
- cyclop # 包/函数复杂度
- nestif # if 嵌套太深
- maintidx # 维护性指数
- revive # 风格/命名/坏味道
- gocritic # 各类代码问题
- funlen # 函数过长
- gosec # 安全问题检查
- bodyclose # HTTP response body 没有正确关闭
- noctx # 没有传递 context.Context
- contextcheck # 其他检查
- sqlclosecheck # SQL rows 没有正确关闭
- unconvert # 不必要的类型转换
- nilerr # 函数返回 nil 错误
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实)
issues:
max-issues-per-linter: 0
max-same-issues: 0
-78
View File
@@ -1,78 +0,0 @@
version: "2"
# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run.
# Snapshot of the committed .golangci.yml plus the extra analyzers that report
# genuine defects (correctness / panics / dead code) rather than cosmetics.
# Keeping this separate from .golangci.yml means strengthening the project gate
# can never silently lower the measured debt.
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# --- from the committed project gate ---
- govet
- staticcheck
- errcheck
- ineffassign
- unused
- dupl
- mnd
- goconst
- cyclop
- nestif
- maintidx
- revive
- gocritic
- funlen
- gosec
- bodyclose
- noctx
- contextcheck
- sqlclosecheck
- unconvert
- nilerr
# --- extra real-risk analyzers (defects, not cosmetics) ---
- errorlint # err == / %v instead of errors.Is/As and %w
- forcetypeassert # unchecked type assertions can panic
- nilnil # (value, nil) breaks the nil-check contract
- predeclared # shadowing builtins
- unparam # dead params/results
- wastedassign # dead stores
- exhaustive # enum switches missing cases
- makezero # append to preallocated slice
- rowserrcheck # sql.Rows error after iteration
- durationcheck # multiplied time.Duration
- prealloc # slice growth in loops
- copyloopvar # loop-var capture
- nonamedreturns
- funcorder # struct methods scattered across files
- nolintlint # suppression audit (must stay 0)
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
issues:
max-issues-per-linter: 0
max-same-issues: 0
-129
View File
@@ -1,129 +0,0 @@
# Deferred proposals — autoresearch run (iterations 27-36)
Five verified findings deliberately **not** changed by the loop: each needs either a
contract/API decision or a multi-package restructure, which this run was scoped to
propose rather than perform. Evidence is from reading the cited files in this
checkout at commit `ea97b64` plus iterations 27-35.
---
## P1 — Cross-driver storage migration cannot move objects (severity: data availability)
`plugins/domain/upload/task/storage_migration.go` computes `target` from the payload,
then calls:
```go
migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total)
```
`sourceBackend` and `targetBackend` are the **same** `contracts.StorageService`. That
service resolves its backend per call and only ever to the currently active one
(`plugins/infra/storage/plugin.go:89` → `s.backend` or `objectstore.Active(ctx)`), and
the target config is persisted **after** the migration loop
(`uploadstorage.SaveActiveConfig(ctx, target)`).
Consequence for a non-empty source: `migrateSingleObject` reads and writes the same
backend; `shouldSkipMigration` finds every object already "present in the target" and
skips it, yet `migrated` is still incremented, so the task returns
`存储迁移完成,共迁移 N 个对象,活动存储已切换为 <driver>` having copied **zero** bytes,
and then points the platform at an empty backend. The same-driver and
`total == 0` branches are harmless and legitimately need no copying.
Why the tests miss it: `shared.MockStorageService` is one instance serving both
parameters, so a copy-to-self looks correct.
Proposed fix (needs a contract decision — this is a feature, not a patch):
1. Extend `contracts.StorageService` with the ability to operate against an explicitly
supplied `StorageConfigDTO` (e.g. `BackendFor(ctx, cfg) (StorageReader, error)`),
implemented in `plugins/infra/storage` where the `objectstore` backends live. They
are unexported today and `plugins/domain/upload` must not import them (cross-plugin
import ban), so the contract is the only correct route.
2. In the task, build the target from `target` and pass distinct source/target.
3. Only save the active config after a verified copy, and assert `src != dst` at
entry.
4. Interim safety option if a decision is needed sooner: make the
`target.Driver != active.Driver && total > 0` branch return an explicit
not-implemented error instead of reporting success. Rejected by this loop because
it disables an advertised admin operation, which is a product call, and because
the machinery it would strand (`migrateObjects`, `migrateSingleObject`,
`shouldSkipMigration`) becomes dead code the project gate then rejects.
Size: contract + infra impl + task wiring + a two-backend test double. Roughly one
focused session, not a loop iteration.
---
## P2 — `w_system_configs` has one migration owner and many writers (Cordis single-owner)
Owner per migrations: `plugins/domain/admin`. Still read/written with raw SQL from
`plugins/domain/system/repository.go:31`, `plugins/domain/cap/repository.go:50`,
`plugins/domain/auth/repository.go:122`, `plugins/domain/upload/storage/migration.go:96,108`,
`plugins/domain/upload/ingest/helpers.go:55`,
`plugins/domain/message_gateway/repository/push.go` and `plugins/drivers/driver_http`.
Iteration 34 fixed one instance of the real damage this causes (a failed read looked
identical to "unconfigured", silently dropping notifications); iteration 35 fixed
another (a failed read cached a narrowed whitelist for a whole TTL). The remaining
sites carry the same trap.
Proposed fix: one settings accessor contract (`Get(ctx, key) (string, error)` /
`GetAll(ctx, keys...)`) owned by the settings subsystem, then delete the raw table
access. Keys should be declared where they are used rather than string-matched.
Size: medium, touches seven plugins; do it key-group by key-group so each step is
independently revertable.
---
## P3 — Two tables are modelled twice (schema drift hazard)
* `w_task_executions`: `plugins/domain/admin/model/entity.go:207` **and**
`plugins/drivers/driver_asynq_worker/types.go:49`. The two `TaskExecution` structs
and their status enums are byte-for-byte identical today.
* `w_schedules`: `plugins/domain/admin/model/entity.go:169` **and**
`plugins/drivers/driver_asynq_cron/schedule.go:25`.
Nothing is broken yet — that is the risk: the migration owner was only recently moved
to `admin` (`49f9d10`), and a column added to one struct will silently diverge from
the other, so whichever writer holds the stale struct zeroes or omits the new column.
Proposed fix: pick the single owner per P2's rules and have the other side go through
a contract (execution recording already has DTOs in `contracts`), then delete the
duplicate model. Consider a gate check rejecting two non-`testhelper` packages
declaring the same `w_` table — it will fail until these two are resolved, so land it
with the fix (the pattern that worked in iterations 5 and 19).
---
## P4 — `user` deletes rows from tables owned by `auth`
`plugins/domain/user/repository.go:327,330` issues `DELETE` against `w_access_tokens`
and `w_external_accounts`, both owned and migrated by `plugins/domain/auth`, inside
user deletion. It works, but ownership is inverted: revoke-on-delete is auth's
invariant, and encoding it in `user` means any other deletion path silently skips it.
Proposed fix: emit a typed `user:deleted` event from `user` and let `auth` cascade
within its own transaction boundary, or expose an explicit `AuthService.RevokeForUser`.
Size: small-to-medium; needs a test that the revocation still happens on delete.
---
## P5 — Package `cap` shadows the predeclared identifier (8 of 62 debt)
Every file in `plugins/domain/cap` declares `package cap`, which shadows the builtin.
It is the single largest block of non-cosmetic lint debt this run declined to chase,
and it is also a readability cost (`cap.Something` reads as a builtin call).
Proposed fix: rename to a non-shadowing identifier (e.g. `capacity` / `proofwork`,
matching what the plugin actually does) across its own files and importers. Mechanical
but wide; needs a decision on the new name first, which is why it is not done here.
---
## Explicitly rejected as metric-chasing
23 `funcorder`, 5 `exhaustive` (both flagged only because
`default-signifies-exhaustive` defaults to false), 4 `nonamedreturns` and the 17
`forcetypeassert` cluster in `core/events.go` and `core/extpoints/config_resolve.go`
— verified guarded by construction (`convertString` etc. return `(any, error)` and
always yield the asserted type when `err == nil`). Reordering functions or adding
unreachable `if !ok` branches would raise the score and lower the code.
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
# Mechanically prove a FIX iteration is load-bearing.
#
# Usage: .auto/prove_fix.sh <package> <changed source file> [<more files>...]
#
# Run immediately AFTER committing the fix, with a clean worktree. It reverts
# only the non-test source files to their pre-fix state (keeping the new test),
# runs the package tests, and requires them to FAIL. Then it restores HEAD.
# A fix nobody can break with a revert is not a fix.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then
echo "PROVE ABORT: worktree must be clean (commit the change first)"
exit 2
fi
PKG="$1"; shift
SRC_FILES=("$@")
if [ "${#SRC_FILES[@]}" -eq 0 ]; then
echo "PROVE ABORT: no source files given"
exit 2
fi
cd "${ROOT}/backend" || exit 2
restore() {
git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null
}
trap restore EXIT
for f in "${SRC_FILES[@]}"; do
if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then
git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; }
else
# File did not exist before this commit — removing it is the revert.
rm -f "${ROOT}/${f}"
fi
done
echo "--- tests against pre-fix source ---"
OUT=$(go test -count=1 "${PKG}" 2>&1)
RC=$?
echo "${OUT}" | tail -15
if [ "${RC}" -eq 0 ]; then
echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix"
exit 1
fi
if echo "${OUT}" | grep -q 'build failed'; then
KIND="compile (signature changed; behaviour proven by inspection)"
elif echo "${OUT}" | grep -qE '^--- FAIL'; then
KIND="assertion"
else
KIND="failure"
fi
echo "PROVED: test fails without the fix (${KIND})"
exit 0
-37
View File
@@ -1,37 +0,0 @@
iteration commit metric delta status guard description
0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers)
1 1c5731b 100 -2.0 keep pass core: Using2/Using3 now wrap dependency causes via errors.Join (proven: test fails on revert)
2 37ad586 95 -5.0 keep pass sentinel == comparisons -> errors.Is across admin/upload/cap-pow (5 sites)
3 686e3ef 93 -2.0 keep pass filesrv.AbortUploadRecordError dedups error mapping + errors.As (2 sites, drops dead ErrInvalidUploadID)
4 7e6b9e7 93 0.0 keep pass PROVEN FIX: singleflight image generation no longer dies with the first caller canceled ctx (test fails on revert)
5 381c794 93 0.0 keep pass CORDIS: gate widened to catch bare "go call()" + 4 unprotected cleanup goroutines moved to util.Go (arch violations 4->0)
6 ce33997 92 -1.0 keep pass BUGFIX admin logs: negative cursor was accepted (bool ignored by callers) -> error-only contract; proven via revert (compile-level) + contract test
7 c66399e 89 -3.0 keep pass push channels share title/content/level extraction (3 dead inits gone, ~20 fewer lines)
8 18820b1 89 0.0 keep pass BUGFIX push: synthesized notification content had random field order (map iteration); sorted keys, test observed failing pre-fix
9 22ecafd 87 -2.0 keep pass unparam: always-nil error returns dropped, 4 unreachable branches removed
10 c4068ef 84 -3.0 keep pass errorlint cleared to 0: %%w at push test + telegram fallback, errors.As in config loader
11 3d2038a 80 -4.0 keep pass nilnil: unimplemented auth mocks now return a sentinel instead of (nil,nil)
12 101cb2f 79 -1.0 keep pass nilnil: inproc driver GetExecution returns error, matching asynq driver semantics
14 6932b54 79 0.0 keep pass DATA-LOSS BUGFIX: cache read error no longer clobbers buffered task log (proven: assertion fails on revert)
15 2c41563 79 0.0 keep pass PERF: CORS origin check no longer hits DB per request (5s cached read); proven - loader count 0 vs 1 on revert
16 976f9b1 79 0.0 keep pass PERF: contract-level batch user lookup replaces N+1 in access-log enrichment (test proves 1 query vs 3)
17 1b1c452 79 0.0 keep pass BUGFIX: orphan cron message_gateway:cleanup_pairing_codes now has a handler; invariant test added (proven by stash-revert)
18 8c4955c 79 0.0 keep pass BUGFIX: removed phantom user:daily_audit cron (dispatched to unregistered task); cross-plugin invariant test added
19 84eaf3f 79 0.0 keep pass CORDIS+BUGFIX: task handlers were asynq-typed so 4 upload tasks could not run under the in-process worker; made driver-agnostic + gate check 7 (proven: gate names all 3 files pre-fix)
20 efa7555 79 0.0 keep pass BUGFIX telegram: LongPoller.Timeout was 10 nanoseconds -> getUpdates timeout=0 -> busy polling; now 10s (proven by reverting the constant)
21 1023fa3 79 0.0 keep pass DISK LEAK: telegram inbound media scratch dirs were never removed (no consumer reads them); cleanup on handler exit. No test possible (needs live download)
22 ad83841 54 -25.0 keep pass dead lint suppressions removed (24); 2 were load-bearing -> restored+narrowed with reasons after guard veto exposed verified contextcheck FPs
23 de938de 54 0.0 keep pass SECURITY/BUGFIX fail-open auth: user+message_gateway consumed contracts.AuthService in Apply but declared only DBService, so reconcile mounted user before auth and loginMW degraded to a pass-through (user change-password/profile/access-tokens unguarded in production, deterministically); declared the dep + added reconcile-level ordering test (PROVED: assertion fails on revert)
24 62b48e9 54 0.0 keep pass SECURITY: all three auth-middleware fallbacks were c.Next() (fail-open). Reachable at runtime in admin: OnDispose->ResetServices() nils the global the per-request guard reads, so in-flight requests pass as authenticated. Added ginutil.AuthUnavailable() + table test driving each registered guard (PROVED: abort assertion fails on revert to 577d795)
25 f58f5a4 54 0.0 keep pass staticcheck ST1023 x4 from iter 24 (redundant gin.HandlerFunc on typed-RHS decls) - caught by GUARD only, go build/go test both stayed green; lesson: run checks.sh after EVERY commit, not just before ship
26 - 64 +10.0 rebaseline pass upstream config-extension + auth/user/task work raised debt 54->64; re-measured at HEAD ea97b64, 47 pkgs pass, arch 0 viol. Run focus agreed: real defects primary, debt secondary (proven-fix gate keeps delta-0 fixes)
27 31f3af6 63 -1.0 keep pass dead contextcheck suppression on cmd.newWaveletApp removed; the core.App.Run one was load-bearing (guard veto: project gate contextcheck Run->Start, verified FP on variadic ctx) -> restored narrowed + documented. Lesson 8 trap re-hit: nolintlint "unused" != safe to delete
28 5037097 63 0.0 discard fail CORDIS gate: contracts DTO must not carry TableName + removed UserDTO.TableName(). DISCARDED: my grep used -g !*_test.go and missed upload/handler/routers_test.go:669 which does db.Create(&contracts.UserDTO{}) into w_users - that suppression exists precisely to enable the cross-plugin write. Lesson: contracts-purity changes must scan test files too.
29 2ff0cb8 63 0.0 keep pass CORDIS contracts purity: gate check 2.2 forbids TableName()/gorm tags in core/contracts + removed UserDTO.TableName(); upload/handler test now seeds via explicit .Table("w_users") (precedent: filesrv test). Proven twice over: gate named auth.go:33 pre-fix, and iter-28 revert broke 1 package without the test fix. Delta-0 keep under the agreed real-defect gate
30 9ea0e2b 63 0.0 keep pass SECURITY (assertion-proven): FindUserByFieldRecord interpolated its column arg into WHERE with only a prose comment as guard. Pre-fix the tautology "username = '' OR 1=1 --" EXECUTED and returned a row with err=<nil> (filter bypass). Now an allow-list rejects before GetDB. Also first test in the repository pkg: tests_passed 47->48, funcs 282
31 5193bd0 63 0.0 keep pass HARNESS INTEGRITY: measure.sh and checks.sh now key GOLANGCI_LINT_CACHE per checkout. The default cache is machine-wide, so entries written by a sibling worktree replayed here carrying ITS absolute paths (12 of 63 lines pointed at an outside checkout), misattributing findings and risking a stale Guard verdict. Proven count-neutral: cold and warm both 63; foreign paths now 0. Delta 0 by design, kept under the agreed real-defect gate
32 f7a86d3 63 0.0 keep pass PERF+DEDUP: three packages hand-rolled mutex+[]string+MatchPathPattern loop, re-normalising and re-splitting immutable patterns per request. New extpoints.PathWhitelist compiles patterns at registration and absorbs all three. Mechanically asserted: 14 allocs/op -> 1 allocs/op; equivalence test pins Match against the legacy loop over a full pattern x path matrix; race-clean. funcs 282->288, coverage 35.02
33 99fca9e 62 -1.0 keep pass BUGFIX+DEDUP: task.loadActiveStorageConfig and saveActiveStorageConfig duplicated uploadstorage.LoadStorageConfig/SaveActiveConfig but swallowed all three failures (nil db, read error, json parse) returning zero config + nil error, making the caller-s already-written error branch dead: a storage migration could run from an unknown active driver. Now routed through the canonical accessors; regression test corrupts the stored config and asserts Execute errors (assertion-proven via revert). funcs 289
34 b22f863 62 0.0 keep pass BUGFIX+PERF: LoadSMTPConfigRecord fired four single-key queries and discarded every error with underscore assignment, so an unreadable w_system_configs returned four blank strings both callers could only read as "SMTP not configured" -> notification silently dropped. Now one IN query plus a real error channel; callers log at the boundary and keep their own values. Proof is signature-level and exact: the old API had no error return, so the failure was unrepresentable. 4 queries -> 1, funcs 291
35 d7c851b 62 0.0 keep pass BUGFIX+PERF: access_cache discarded the whitelist read error with underscore assignment, then unconditionally set valid=true and CheckedAt=now, so one transient DB failure pinned the RESTRICTED default public-access list for the whole TTL and silently narrowed an admin-configured whitelist. Now the error is logged, last-good is served when known, and a cold failure stays invalid so the next request retries. Assertion-proven by dropping and restoring the table mid-test. funcs 292
36 - 62 0.0 keep skip PROPOSALS (.auto/proposals.md): five verified items deliberately not auto-fixed per the agreed scope. Headline P1: cross-driver storage migration passes the SAME service as source and target (getBackend only ever resolves the active backend) and saves the target config afterwards, so it reports "migrated N objects" having copied zero bytes and then points the platform at an empty backend. Needs a contracts.StorageService capability decision, not a patch.
Can't render this file because it contains an unexpected character in line 7 and column 63.
-1
View File
@@ -1 +0,0 @@
.agents
-1
View File
@@ -33,4 +33,3 @@ frontend/*.tsbuildinfo
frontend/package-lock.json
internal/router/dist/
internal/router/root/dist/
backend/plugins/drivers/driver_http/dist/
+1 -8
View File
@@ -1,8 +1 @@
* -text
backend/openflare/** merge=ours
frontend/** merge=ours
docs/changelog/** merge=ours
docs/superpowers/** merge=ours
.github/workflows/build-image.yml merge=ours
docker-compose.yml merge=ours
.gitconfig merge=ours
* -text
-10
View File
@@ -1,10 +0,0 @@
# Repo-local Git settings. Git does not load this file automatically.
# From the clone (or worktree) root:
# git config include.path ../.gitconfig
# Worktree-safe:
# git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
# Relative include.path is resolved against .git/config, so ../.gitconfig
# is the repo root when .git is a directory (non-worktree clone).
[merge "ours"]
driver = true
@@ -18,7 +18,7 @@ permissions:
env:
IMAGE_NAME: openflare-agent
DOCKERFILE: manifest/docker/Dockerfile.agent
DOCKERFILE: docker/Dockerfile.agent
jobs:
build:
@@ -18,7 +18,7 @@ permissions:
env:
IMAGE_NAME: openflare-relay
DOCKERFILE: manifest/docker/Dockerfile.relay
DOCKERFILE: docker/Dockerfile.relay
jobs:
build:
+1 -1
View File
@@ -24,7 +24,7 @@ permissions:
env:
IMAGE_NAME: openflare
DOCKERFILE: manifest/docker/Dockerfile
DOCKERFILE: docker/Dockerfile
jobs:
# Resolve version / registries once. No checkout: triggers alone determine the tag.
+1 -1
View File
@@ -18,7 +18,7 @@ permissions:
env:
IMAGE_NAME: openflared
DOCKERFILE: manifest/docker/Dockerfile.flared
DOCKERFILE: docker/Dockerfile.flared
jobs:
build:
-22
View File
@@ -1,22 +0,0 @@
name: Build Image (Wavelet upstream — isolated)
# Isolated: OpenFlare publishes images via build-image-openflare.yml
# (IMAGE_NAME: openflare). This Wavelet workflow is kept under the same
# path so `git merge wavelet/main` cannot restore a canary wavelet image.
on:
workflow_dispatch:
inputs:
confirm:
description: "Disabled on OpenFlare. Use build-image-openflare.yml."
required: true
jobs:
isolated:
name: Isolated
runs-on: ubuntu-latest
steps:
- name: Refuse Wavelet image publish
run: |
echo "This Wavelet image workflow is isolated on OpenFlare."
echo "Use .github/workflows/build-image-openflare.yml"
exit 1
+15 -23
View File
@@ -12,7 +12,6 @@ on:
env:
APP_NAME: openflare-server
GO_DIR: backend
GO_MAIN: ./main.go
GO_BUILD_TAGS: embed_frontend
GO_LDFLAGS: -s -w
@@ -21,12 +20,12 @@ env:
FRONTEND_DIR: frontend
FRONTEND_BUILD_COMMAND: pnpm build:embed
FRONTEND_OUT_DIR: frontend/out
EMBED_DIST_DIR: backend/plugins/drivers/driver_http/dist
EMBED_DIST_DIR: internal/router/root/dist
EXTRA_FILES: |
LICENSE
README.md
README_zh.md
manifest/config/config.default.yaml
config.example.yaml
DEPLOYMENT_zh.md
permissions:
@@ -146,7 +145,6 @@ jobs:
rm -rf "$EMBED_DIST_DIR"
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
test -f "$EMBED_DIST_DIR/index.html"
- name: Upload embedded frontend
uses: actions/upload-artifact@v4
@@ -194,7 +192,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ env.GO_DIR }}/go.mod
go-version-file: go.mod
cache: true
- name: Build binary
@@ -214,21 +212,18 @@ jobs:
binary_name="${binary_name}.exe"
fi
# Assert the embedded UI is present so a release cannot ship an API-only binary.
test -f "$EMBED_DIST_DIR/index.html"
ldflags="$GO_LDFLAGS -X Wavelet/pkg/buildinfo.Version=$VERSION -X Wavelet/pkg/buildinfo.BuildTime=$BUILD_DATE"
ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE"
build_args=(
-trimpath
-ldflags "$ldflags"
-o "$GITHUB_WORKSPACE/dist/$binary_name"
-o "dist/$binary_name"
)
if [[ -n "$GO_BUILD_TAGS" ]]; then
build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}")
fi
(cd "$GO_DIR" && go build "${build_args[@]}" "$GO_MAIN")
go build "${build_args[@]}" "$GO_MAIN"
- name: Package artifact
id: package
@@ -301,7 +296,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ env.GO_DIR }}/go.mod
go-version-file: go.mod
# GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start.
- name: Build Agent
@@ -312,10 +307,9 @@ jobs:
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/agent/main.go
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/agent/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
@@ -350,7 +344,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ env.GO_DIR }}/go.mod
go-version-file: go.mod
- name: Build Relay
env:
@@ -360,10 +354,9 @@ jobs:
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/relay/main.go
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/relay/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
@@ -398,7 +391,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ env.GO_DIR }}/go.mod
go-version-file: go.mod
- name: Build Flared
env:
@@ -408,10 +401,9 @@ jobs:
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/flared/main.go
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/flared/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
-13
View File
@@ -11,7 +11,6 @@
# config
config.yaml
manifest/config/config.yaml
.env
.env.*
!.env.example
@@ -88,15 +87,3 @@ profile.cov
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
frontend/messages/zh-CN.json
frontend/messages/en.json
# 上游 vendoring 目录内禁止出现运行期产物
backend/plugins/**/uploads/
backend/plugins/**/dist/
backend/core/**/dist/
backend/pkg/**/uploads/
/backend/openflare/plugins/server/upload/filesrv/uploads/
/backend/plugins/domain/upload/filesrv/uploads/
/backend/plugins/domain/upload/task/uploads/
/backend/data/
/backend/plugins/drivers/driver_http/dist/
/backend/uploads/
+11 -58
View File
@@ -72,7 +72,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 |
| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` |
| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 |
@@ -82,66 +82,19 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
## 硬性约束
### 上游/下游改动归属(Cordis)
- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属:
- **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改,
本仓库通过 `git fetch wavelet && git merge wavelet/main` 取得,不得长期持有本地补丁。
- **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/openflare/plugins/<name>/`)实现,
或新建一个下游插件,禁止塞进上游目录。
- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`);
发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。
- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md`
(merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。
### Cordis 架构核心防线与分层规范
- **微内核 (`backend/core/`)**:
- 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。
- **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。
- **服务契约 (`backend/core/contracts/`)**:
- 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。
- **严禁**包含任何具体业务实现或 SQL 操作。
- **自包含插件 (`backend/plugins/`)**:
- 所有业务功能与驱动实现均以插件形式存在(`backend/plugins/drivers/`、`backend/plugins/infra/`、`backend/plugins/domain/` 或下游 `backend/openflare/plugins/`)。
- 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。
- **统一插件分层架构与标准模板**:
- **开发模板唯一基准**:所有插件统一以 `backend/downstream/plugins/custom_example` 为基准模板构建。
- **物理子包隔离规范**:统一采用物理子包结构(`plugin.go`, `consts/`, `controller/`, `service/`, `dao/`, `model/` [含 `entity/`, `do/`], `migrations/` [含 `postgres/`, `sqlite/`])。**严禁在根包平铺 `handlers_*`、`service_*`、`dao_*` 等前缀文件**,子包内文件直接按业务实体命名(如 `hello.go`, `user.go`),严格约束 `controller -> service -> dao -> model` 单向依赖。
- **插件通信与依赖隔离**:
- **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。
- **单向服务契约调用**:调用方仅面向 `backend/core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。
- **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。
- **扩展点自包含注册**:
- **HTTP 路由与白名单机制**:
- 插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
- **白名单机制**:`driver_http` 与微内核扩展点提供路由白名单支持(`ctx.Router().RegisterWhitelist(patterns...)`),支持精确路径与通配符(如 `/api/v1/oauth/*`)。
- **所有权主动声明**:认证域(`auth` 插件)与各业务插件必须在 `Apply` 中主动注册其公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/oauth/callback`、`/api/v1/cap/*` 等)。
- **鉴权中间件放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)必须先执行白名单匹配并自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
- **表单一所有者原则 (Single Owner Principle)**:
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
- 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。
- **平台服务复用**:
- 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。
- 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。
- 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。
- 禁止删除 `frontend/node_modules`。
- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。
- `pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `internal/apps/oauth/session.go`)。
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
- HTTP 路由仅在 `internal/router/router.go` 注册;`Serve()` 只挂路由与中间件,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。
- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。
- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。
- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。
- `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。
- `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/openflare/plugins/server/kernel/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/openflare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/openflare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `internal/listener` 发域事件,push 在 bootstrap 订阅。
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
@@ -179,7 +132,7 @@ Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support
- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。
- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。
- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `backend/internal/apps/user/logics.go`。
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `internal/apps/user/logics.go`。
### API 响应
@@ -205,7 +158,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
### 日志
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
- 吞错、转通用响应、worker 忽略前必须先记日志。
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
- 只在处理/抑制边界记一次,避免重复刷日志。
@@ -213,7 +166,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
### 路由与装配
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。
### 中间件
@@ -224,13 +177,13 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort
### 配置
- 运行时只读 `config.Config`,禁止 `os.Getenv()`。
- 新增配置同步 `config.example.yaml` 与 `backend/internal/infra/config/model.go`。
- 新增配置同步 `config.example.yaml` 与 `internal/infra/config/model.go`。
### 数据库
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
- repository 内用 `db.DB(ctx)`(链路追踪)。
- 迁移:`backend/internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
- 迁移:`internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
- 不建物理外键,关系字段加显式索引。
- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。
-10
View File
@@ -20,16 +20,6 @@
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
## 合并上游
`.gitattributes` 对 `backend/openflare/`、`frontend/` 等路径使用 `merge=ours`。该驱动不会自动生效,请在仓库根目录执行一次:
```bash
git config include.path ../.gitconfig
# worktree 安全写法:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
## 贡献步骤
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
+26 -27
View File
@@ -2,7 +2,7 @@
VERSION ?= dev
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
MODULE := $(shell cd backend && go list -m)
MODULE := $(shell go list -m)
swagger:
scripts/swagger.sh
@@ -19,7 +19,7 @@ format:
echo "goimports not found, installing..."; \
go install golang.org/x/tools/cmd/goimports@latest; \
}
goimports -w $$(find backend -type f -name '*.go')
goimports -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source and removing unused imports..."
cd frontend && pnpm format
@@ -29,51 +29,50 @@ build-embedded:
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
pnpm build:embed
rm -rf backend/plugins/drivers/driver_http/dist
cp -R frontend/out backend/plugins/drivers/driver_http/dist
test -f backend/plugins/drivers/driver_http/dist/index.html
cd backend && go build \
rm -rf internal/router/root/dist
cp -R frontend/out internal/router/root/dist
go build \
-tags embed_frontend \
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o ../bin/openflare-server \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/openflare-server \
main.go
code-check:
@echo "==> Architecture guards..."
scripts/check_cordis_architecture.sh
@if rg -n 'db\.DB\(|db\.Redis' backend/openflare/plugins/server/kernel/model --glob '*.go' -g '!*_test.go' ; then \
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
@if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \
echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \
exit 1; \
fi
cd backend && golangci-lint run
golangci-lint run
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o ../bin/openflare-server \
go build \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/openflare-server \
main.go
build-agent:
@echo "==> Building agent version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/agent/config.Version=$(VERSION)'" \
-o ../bin/openflare-agent \
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/agent/config.Version=$(VERSION)'" \
-o bin/openflare-agent \
cmd/agent/main.go
build-relay:
@echo "==> Building relay version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/relay/config.Version=$(VERSION)'" \
-o ../bin/openflare-relay \
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/relay/config.Version=$(VERSION)'" \
-o bin/openflare-relay \
cmd/relay/main.go
build-flared:
@echo "==> Building flared version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/flared/config.Version=$(VERSION)'" \
-o ../bin/flared \
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/flared/config.Version=$(VERSION)'" \
-o bin/flared \
cmd/flared/main.go
build-all: build-backend build-agent build-relay build-flared
@@ -90,7 +89,7 @@ build-test:
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm build:embed 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
( cd backend && go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
@@ -108,7 +107,7 @@ cross-build:
(version=$(or $(VERSION),dev))..."
@mkdir -p bin
docker build \
--file manifest/docker/Dockerfile.cross \
--file docker/Dockerfile.cross \
--target export \
--build-arg VERSION=$(or $(VERSION),dev) \
--build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" \
@@ -125,14 +124,14 @@ dev-f:
dev-b:
@echo "==> Starting backend development server..."
cd backend && go run main.go all
go run main.go all
dev:
@echo "==> Starting frontend and backend development servers in parallel..."
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm dev 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
( cd backend && go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
( go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
-12
View File
@@ -165,18 +165,6 @@ docker run -d --name openflare-agent --restart unless-stopped \
ghcr.io/rain-kl/openflare-agent:latest
```
## Cordis / Wavelet upstream
OpenFlare is built on Wavelet Cordis. After cloning, enable `merge=ours` from `.gitattributes` so `git merge wavelet/main` keeps OpenFlare-owned paths:
```bash
git config include.path ../.gitconfig
# worktree-safe:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
`docker compose` uses `docker-compose.yaml`. `docker-compose.wavelet.yml` is the upstream Wavelet stack and is not the product default. Image publishes go through `.github/workflows/build-image-openflare*.yml`; the Wavelet `build-image.yml` is isolated.
## Open Source License
This project is licensed under the [Apache License 2.0](./LICENSE).
-12
View File
@@ -165,18 +165,6 @@ docker run -d --name openflare-agent --restart unless-stopped \
ghcr.io/rain-kl/openflare-agent:latest
```
## Cordis / Wavelet 上游
OpenFlare 构建在 Wavelet Cordis 之上。克隆后请启用 `.gitattributes` 中的 `merge=ours`,这样 `git merge wavelet/main` 会保留 OpenFlare 自有路径:
```bash
git config include.path ../.gitconfig
# worktree 安全写法:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
`docker compose` 使用 `docker-compose.yaml`。`docker-compose.wavelet.yml` 是上游 Wavelet 编排,不是本产品的默认栈。镜像发布走 `.github/workflows/build-image-openflare*.yml`;Wavelet 的 `build-image.yml` 已隔离。
## 开源协议
本项目采用 [Apache License 2.0](./LICENSE) 开源。
-351
View File
@@ -1,351 +0,0 @@
# wavelet
🚀 现代化、生产就绪的全栈应用脚手架
[English](./README.md)
[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/)
[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/)
## 📖 项目简介
**wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
### ✨ 主要特性
- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源)
- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头
- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能
- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作
- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板
- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存
- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry)
- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统
- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款
## 🏗️ 架构概览
```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ 前端 │ │ 后端 │ │ 数据库 │
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
│ │ │ │ │ │
│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │
│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │
│ • Tailwind 4 │ │ • 多认证源适配 │ │ │
│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │
│ │ │ • Asynq 任务队列 │ │ │
│ │ │ • OpenTelemetry 链路追踪 │ │ │
│ │ │ • Swagger 接口文档 │ │ │
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
│
┌──────────┴──────────┐
│ 多进程 CLI 入口 │
│ (Cobra + Viper) │
│ • api (HTTP) │
│ • worker (队列) │
│ • scheduler(定时) │
└─────────────────────┘
```
## 🛠️ 技术栈
### 后端
- **[Go 1.25+](https://go.dev/doc)** — 主语言
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架
- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse
- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端
- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动)
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理
- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性
- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成
### 前端
- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router)
- **[React 19](https://github.com/facebook/react)** — UI 库
- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库
## 📋 环境要求
- **Go** >= 1.25
- **Node.js** >= 18.0
- **PostgreSQL** >= 14
- **Redis** >= 6.0
- **pnpm** >= 8.0(推荐)
## 🚀 快速开始
### 1. 克隆仓库
```bash
git clone https://github.com/Rain-kl/Wavelet.git refreshing
cd refreshing
```
### 2. 配置环境
```bash
cp manifest/config/config.default.yaml manifest/config/config.yaml
```
编辑 `manifest/config/config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
### 3. 初始化数据库
```bash
# 启动本地依赖服务(PostgreSQL + Redis)
docker compose up -d
# 可选:同时启动 ClickHouse
docker compose --profile clickhouse up -d
# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库
createdb -h <主机> -p 5432 -U postgres refreshing
# 数据库表结构在首次启动时自动迁移,无需手动执行
```
### 4. 启动后端
```bash
# 安装 Go 依赖
go mod tidy
# 生成 Swagger 接口文档
make swagger
# 启动 HTTP API 服务器
go run main.go api
```
> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务:
> ```bash
> go run main.go scheduler # 定时任务调度器
> go run main.go worker # Asynq 任务处理工作进程
> ```
### 5. 启动前端
```bash
cd frontend
# 安装依赖
pnpm install
# 启动开发服务器(Turbopack)
pnpm dev
```
### 6. 访问应用
| 服务 | 地址 |
|------|------|
| 前端界面 | http://localhost:3000 |
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
| 健康检查 | http://localhost:8000/api/healthz |
## ⚙️ 配置说明
主要配置项(完整说明请参考 `manifest/config/config.default.yaml`):
| 配置项 | 说明 | 示例 |
|--------|------|------|
| `app.addr` | 后端监听地址 | `:8000` |
| `database.host` | PostgreSQL 主机 | `127.0.0.1` |
| `database.database` | 数据库名称 | `refreshing` |
| `redis.host` | Redis 主机 | `127.0.0.1` |
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
## 🔧 开发指南
### 后端
```bash
# 运行 API 服务器
go run main.go api
# 运行定时任务调度器
go run main.go scheduler
# 运行异步任务工作进程
go run main.go worker
# 修改 Controller 后重新生成 Swagger 文档(必须执行)
make swagger
# 代码格式化与检查
make tidy
```
### 前端
```bash
cd frontend
# 开发模式(Turbopack)
pnpm dev
# 构建生产版本
pnpm build
# 启动生产服务器
pnpm start
# 代码 Lint 和格式化
pnpm lint
pnpm format
```
## 📁 项目结构
```
wavelet/
├── main.go # 程序入口(委托给 internal/cmd)
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
├── manifest/ # 项目清单与编排:docker 镜像构建、deploy (k8s)、config 配置(默认/覆盖)
├── docs/ # Swagger 自动生成文档
├── frontend/ # Next.js 前端应用
│ ├── app/ # App Router 页面
│ ├── components/ # React 组件(ui、common、layout)
│ ├── lib/services/ # API 服务层
│ └── types/ # TypeScript 类型定义
└── internal/ # Go 后端(private)
├── cmd/ # CLI 命令(api、scheduler、worker)
├── apps/ # 业务模块(oauth、user、admin、upload)
├── model/ # GORM 实体与业务方法
├── router/ # HTTP 路由注册
├── task/ # 异步任务定义与工作进程
├── db/ # 数据库与 Redis 初始化
├── storage/ # S3 文件存储抽象层
└── common/ # 公共工具与响应封装
```
## 📚 接口文档
Swagger 接口文档在后端启动后自动可用:
```
http://localhost:8000/swagger/index.html
```
前端文档中心(路径 `/docs`)内置以下内容:
- **使用指南** — 分步入门教程
- **接口文档** — 详细接口说明
- **隐私政策** — 隐私政策模板(请按需自定义)
- **服务条款** — 服务条款模板
## 🧪 测试
```bash
# 后端测试
go test ./...
# 前端 Lint
cd frontend && pnpm lint
```
## 🚀 部署
### 跨平台二进制编译
一条命令构建全部 6 个平台的静态二进制文件(Linux / macOS / Windows × amd64 / arm64)。
前端已内嵌到每个二进制文件中,无需单独部署。
**前提条件:** 已安装 Docker 且启用 BuildKit(Docker 23+ 默认开启)。
```bash
# 构建全部 6 个二进制文件 → ./bin/
make cross-build
# 指定版本号
make cross-build VERSION=v1.2.3
# 只构建指定系统(两种架构均会构建)
make cross-build GOOS=linux
make cross-build GOOS=darwin
make cross-build GOOS=windows
# 只构建指定架构(所有系统均会构建)
make cross-build GOARCH=amd64
make cross-build GOARCH=arm64
# 同时指定系统和架构 — 只生成单个文件
make cross-build GOOS=linux GOARCH=arm64
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
```
输出到 `./bin/` 目录:
| 文件名 | 平台 |
|--------|------|
| `wavelet_linux_amd64` | Linux x86-64 |
| `wavelet_linux_arm64` | Linux ARM64 |
| `wavelet_darwin_amd64` | macOS Intel |
| `wavelet_darwin_arm64` | macOS Apple Silicon |
| `wavelet_windows_amd64.exe` | Windows x86-64 |
| `wavelet_windows_arm64.exe` | Windows ARM64 |
> 版本号可通过 `wavelet --version` 在运行时查看。
### Docker
```bash
# 构建镜像
docker build -t refreshing .
# 运行(通过卷挂载传入配置文件)
docker run -d -p 8000:8000 \
-v $(pwd)/config.yaml:/app/config.yaml \
refreshing api
```
### 生产环境
1. 构建前端资源:
```bash
cd frontend && pnpm build
```
2. 编译后端程序:
```bash
go build -o refreshing main.go
```
3. 配置生产环境的 `config.yaml`。
4. 启动服务:
```bash
./refreshing api # HTTP API
./refreshing scheduler # 定时调度器(可选)
./refreshing worker # 任务工作进程(可选)
```
## 🤝 贡献指南
我们欢迎社区贡献!请在提交代码前阅读以下文档:
- [贡献指南](CONTRIBUTING.md)
- [行为准则](CODE_OF_CONDUCT.md)
- [贡献者许可协议](CLA.md)
### 贡献流程
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/your-feature`)
3. 提交更改 (`git commit -am 'Add your feature'`)
4. 推送到分支 (`git push origin feature/your-feature`)
5. 创建 Pull Request
## 📄 许可证
本项目基于 [Apache 2.0 许可证](LICENSE) 开源。
-41
View File
@@ -1,41 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command agent runs the OpenFlare edge agent daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
agentplugin "Wavelet/openflare/plugins/agent"
"Wavelet/openflare/plugins/agent/logging"
)
// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
logging.Setup()
configPath := flag.String("config", "./agent.json", "agent config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(agentplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("agent startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("agent process exited with error", "error", err)
os.Exit(1)
}
}
-19
View File
@@ -1,19 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cmd 提供 CLI 命令入口
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var allCmd = &cobra.Command{
Use: "all",
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
},
}
-18
View File
@@ -1,18 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var apiCmd = &cobra.Command{
Use: "api",
Short: "wavelet API",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileAPI, "api", true)
},
}
-417
View File
@@ -1,417 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"Wavelet/core/contracts"
ofserver "Wavelet/openflare/plugins/server"
"Wavelet/openflare/plugins/server/migrate"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/upload"
"Wavelet/plugins/domain/user"
"Wavelet/plugins/drivers/driver_asynq_cron"
"Wavelet/plugins/drivers/driver_asynq_worker"
"Wavelet/plugins/drivers/driver_http"
"Wavelet/plugins/drivers/driver_inproc_cron"
"Wavelet/plugins/drivers/driver_inproc_worker"
"Wavelet/plugins/infra/cache"
"Wavelet/plugins/infra/cache_memory"
"Wavelet/plugins/infra/config"
"Wavelet/plugins/infra/logger"
"Wavelet/plugins/infra/storage"
"context"
"database/sql"
"fmt"
"io/fs"
"log"
"path/filepath"
"time"
"github.com/pressly/goose/v3"
goosedb "github.com/pressly/goose/v3/database"
"gorm.io/gorm"
infradb "Wavelet/plugins/infra/database"
)
const (
defaultShutdownTimeout = 15 * time.Second
defaultHTTPAddr = "127.0.0.1:8000"
// migrationAdvisoryLockKey serializes baseline + plugin Up across Postgres
// sessions (ASCII "wave"). SQLite is single-writer and needs no extra lock.
migrationAdvisoryLockKey int64 = 0x77617665
)
// runProfileApp prepares and runs the application for a given profile.
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
app := newOpenFlareApp(profile)
if err := app.Prepare(); err != nil {
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
}
state := startupState{
mode: mode,
listensForHTTP: listensForHTTP,
env: app.Context().Config().String("app.env", "production"),
}
if listensForHTTP {
state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr)
}
printStartupBanner(state)
if err := app.Run(); err != nil {
log.Fatalf("[%s] run failed: %v\n", mode, err)
}
}
// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin.
func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
src, err := config.NewSource()
if err != nil {
log.Fatalf("[App] load config source failed: %v\n", err)
}
appOpts := []core.AppOption{
core.WithProfile(profile),
core.WithConfigSource(src),
core.WithShutdownTimeout(defaultShutdownTimeout),
core.WithMigrationBaseline(migrate.Legacy),
}
appOpts = append(appOpts, opts...)
app := core.NewApp(appOpts...)
// 1. Register standard infrastructure plugins
app.Use(
infradb.New(),
logger.New(),
storage.New(),
)
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
app.Use(
cache.New(),
cache_memory.New(),
driver_asynq_worker.New(),
driver_inproc_worker.New(),
driver_asynq_cron.New(),
driver_inproc_cron.New(),
)
// 3. Register all 7 domain business plugins (admin first to ensure schema and base config tables exist)
app.Use(
admin.New(),
user.New(),
auth.New(),
msg_gateway.New(),
risk_control.New(),
upload.New(),
system.New(),
)
// 4. OpenFlare business routes (after domain plugins, before the HTTP driver)
app.Use(
ofserver.New(),
)
// 5. Bind Goose migration engine
app.SetMigrationEngine(&gooseEngine{})
// 6. Mount HTTP runtime driver
app.Use(
driver_http.New(),
)
return app
}
// ─── Schema Version Store ──────────────────────────────────────────────────────
// sharedStore implements database.Store using a single w_schema_versions table.
// All plugins share this table, with plugin_id as the discriminator.
//
// Schema:
//
// w_schema_versions (
// plugin_id VARCHAR(64) NOT NULL,
// version_id BIGINT NOT NULL,
// applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// PRIMARY KEY (plugin_id, version_id)
// )
type sharedStore struct {
pluginID string
dialect string // "postgres" or "sqlite3"
}
func (s *sharedStore) Tablename() string { return "w_schema_versions" }
func (s *sharedStore) CreateVersionTable(ctx context.Context, db goosedb.DBTxConn) error {
_, err := db.ExecContext(ctx, schemaVersionsDDL(s.dialect))
return err
}
func schemaVersionsDDL(dialect string) string {
timeType := "TIMESTAMPTZ"
if dialect == "sqlite3" || dialect == "sqlite" {
timeType = "DATETIME"
}
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS w_schema_versions (
plugin_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin_id, version_id)
)`, timeType)
}
//nolint:mnd
func (s *sharedStore) Insert(ctx context.Context, db goosedb.DBTxConn, req goosedb.InsertRequest) error {
p := s.placeholder
_, err := db.ExecContext(ctx,
fmt.Sprintf("INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (%s, %s) ON CONFLICT (plugin_id, version_id) DO NOTHING", p(1), p(2)),
s.pluginID, req.Version)
return err
}
//nolint:mnd
func (s *sharedStore) Delete(ctx context.Context, db goosedb.DBTxConn, version int64) error {
p := s.placeholder
_, err := db.ExecContext(ctx,
fmt.Sprintf("DELETE FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
s.pluginID, version)
return err
}
//nolint:mnd
func (s *sharedStore) GetMigration(ctx context.Context, db goosedb.DBTxConn, version int64) (*goosedb.GetMigrationResult, error) {
p := s.placeholder
var t time.Time
err := db.QueryRowContext(ctx,
fmt.Sprintf("SELECT applied_at FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
s.pluginID, version).Scan(&t)
if err == sql.ErrNoRows {
return nil, goosedb.ErrVersionNotFound
}
if err != nil {
return nil, err
}
return &goosedb.GetMigrationResult{Timestamp: t, IsApplied: true}, nil
}
func (s *sharedStore) GetLatestVersion(ctx context.Context, db goosedb.DBTxConn) (int64, error) {
p := s.placeholder
var version int64
err := db.QueryRowContext(ctx,
fmt.Sprintf("SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = %s", p(1)),
s.pluginID).Scan(&version)
if err != nil {
return 0, err
}
return version, nil
}
func (s *sharedStore) ListMigrations(ctx context.Context, db goosedb.DBTxConn) ([]*goosedb.ListMigrationsResult, error) {
p := s.placeholder
rows, err := db.QueryContext(ctx,
fmt.Sprintf("SELECT version_id, TRUE FROM w_schema_versions WHERE plugin_id = %s ORDER BY version_id DESC", p(1)),
s.pluginID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var results []*goosedb.ListMigrationsResult
for rows.Next() {
var r goosedb.ListMigrationsResult
if err := rows.Scan(&r.Version, &r.IsApplied); err != nil {
return nil, err
}
results = append(results, &r)
}
return results, rows.Err()
}
func (s *sharedStore) placeholder(n int) string {
if s.dialect == "postgres" {
return fmt.Sprintf("$%d", n)
}
return "?"
}
// ─── Migration Engine ──────────────────────────────────────────────────────────
// gooseEngine implements core.MigrationEngine by iterating all plugin-registered
// migration entries and applying each plugin's migrations against the shared DB.
//
// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed
// and registered via ctx.Migrations().Register(pluginID, embedFS).
//
// Version tracking: all plugins share a single w_schema_versions table with
// plugin_id as the discriminator column. Querying this table shows the current
// migration version of every plugin at a glance.
type gooseEngine struct{}
func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error {
if len(entries) == 0 {
return nil
}
// Resolve DBService from the IoC container.
var dbSvc contracts.DBService
if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) {
dbSvc = svc
}); err != nil {
return fmt.Errorf("migration: resolve DBService: %w", err)
}
gormDB := dbSvc.GORM()
if gormDB == nil {
return fmt.Errorf("migration: DBService.GORM() returned nil")
}
sqlDB, err := gormDB.DB()
if err != nil {
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
}
dialect := gooseDialectFromGORM(gormDB, ctx)
dialectStr := string(dialect)
goCtx := context.Background()
if ctx != nil {
goCtx = ctx.GoContext()
}
if goCtx == nil {
goCtx = context.Background()
}
bootstrap := &sharedStore{dialect: dialectStr}
if err := bootstrap.CreateVersionTable(goCtx, sqlDB); err != nil {
return fmt.Errorf("migration: create version table: %w", err)
}
if dialect == goose.DialectPostgres {
conn, lockErr := sqlDB.Conn(goCtx)
if lockErr != nil {
return fmt.Errorf("migration: pin connection for advisory lock: %w", lockErr)
}
defer func() { _ = conn.Close() }()
if _, lockErr = conn.ExecContext(goCtx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); lockErr != nil {
return fmt.Errorf("migration: advisory lock: %w", lockErr)
}
defer func() {
_, _ = conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey)
}()
}
if fn := ctx.MigrationBaseline(); fn != nil {
if err := fn(ctx); err != nil {
return fmt.Errorf("migration baseline: %w", err)
}
}
for _, entry := range entries {
store := &sharedStore{
pluginID: entry.PluginID,
dialect: dialectStr,
}
migrationFS := findMigrationFS(entry.FS, dialect)
provider, err := goose.NewProvider(goose.DialectCustom, sqlDB, migrationFS, goose.WithStore(store))
if err != nil {
return fmt.Errorf("migration %s: create provider: %w", entry.PluginID, err)
}
results, err := provider.Up(context.Background())
if err != nil {
return fmt.Errorf("migration %s: apply %w", entry.PluginID, err)
}
version, vErr := provider.GetDBVersion(context.Background())
if vErr != nil {
version = 0
}
if len(results) > 0 {
log.Printf("[migrate] %s: applied %d migration(s) (v%d)", entry.PluginID, len(results), version)
} else {
log.Printf("[migrate] %s: v%d", entry.PluginID, version)
}
}
return nil
}
// gooseDialectFromGORM prefers the live driver; config is only a fallback when
// GORM has no dialector yet (tests that inject a stub DBService).
func gooseDialectFromGORM(gormDB *gorm.DB, ctx *core.Context) goose.Dialect {
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "postgres" {
return goose.DialectPostgres
}
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "sqlite" {
return goose.DialectSQLite3
}
return gooseDialect(ctx)
}
// gooseDialect returns the goose dialect based on the configured database engine.
func gooseDialect(ctx *core.Context) goose.Dialect {
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
return goose.DialectPostgres
}
return goose.DialectSQLite3
}
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
dialectDir := "postgres"
if dialect == goose.DialectSQLite3 {
dialectDir = "sqlite"
}
// 1. Direct search for dialect folder (e.g., "sqlite", "migrations/sqlite", "logstore/migrations/sqlite")
for _, subDir := range []string{
dialectDir,
"migrations/" + dialectDir,
"logstore/migrations/" + dialectDir,
} {
if sub, err := fs.Sub(rootFS, subDir); err == nil {
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
return sub
}
}
}
// 2. Recursive walk to find a directory named dialectDir with *.sql files
var foundDir string
_ = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error {
if err == nil && d.IsDir() && filepath.Base(path) == dialectDir {
if sub, subErr := fs.Sub(rootFS, path); subErr == nil {
if matches, globErr := fs.Glob(sub, "*.sql"); globErr == nil && len(matches) > 0 {
foundDir = path
return fs.SkipAll
}
}
}
return nil
})
if foundDir != "" && foundDir != "." {
if sub, err := fs.Sub(rootFS, foundDir); err == nil {
return sub
}
}
// 3. Fallback to generic migrations / root if dialect specific is not present
for _, subDir := range []string{"migrations", "logstore/migrations"} {
if sub, err := fs.Sub(rootFS, subDir); err == nil {
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
return sub
}
}
}
return rootFS
}
-133
View File
@@ -1,133 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"path/filepath"
"testing"
"Wavelet/core"
)
func testSource(t *testing.T) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": false,
"sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"),
},
})
}
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, p := range app.Plugins() {
names[p.Name()] = true
}
for _, n := range []string{"user", "auth", "admin", "server"} {
if !names[n] {
t.Errorf("missing plugin %s", n)
}
}
if err := app.Reconcile(); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, rd := range app.Context().Router().Routes() {
got[rd.Method+" "+rd.Path] = true
}
for _, want := range []string{
"GET /api/healthz",
"GET /api/v1/user/self",
"GET /api/v1/d/nodes",
"POST /api/v1/cap/challenge",
} {
if !got[want] {
t.Errorf("missing route %s", want)
}
}
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
} {
if got[drop] {
t.Errorf("removed route still registered: %s", drop)
}
}
}
func TestFreshInstallSeedsOpenFlareDefaults(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh.db")
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
t.Cleanup(func() { _ = app.Context().Dispose() })
db := openInspectDB(t, dbPath, "")
defer func() { _ = db.Close() }()
var tables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%'`).Scan(&tables); err != nil {
t.Fatal(err)
}
if tables == 0 {
t.Fatal("fresh install created no of_* tables")
}
rows, err := db.Query(`SELECT task_type FROM w_schedules WHERE task_type LIKE 'of_%' ORDER BY 1`)
if err != nil {
t.Fatal(err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var taskType string
if err := rows.Scan(&taskType); err != nil {
t.Fatal(err)
}
got = append(got, taskType)
}
want := []string{
"of_pages_source_scan",
"of_ssl_renew",
"of_uptime_kuma_sync",
"of_waf_ip_group_sync",
}
if len(got) != len(want) {
t.Fatalf("of_* schedules = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("of_* schedules = %v, want %v", got, want)
}
}
var cleanup int
if err := db.QueryRow(`SELECT COUNT(*) FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'`).Scan(&cleanup); err != nil {
t.Fatal(err)
}
if cleanup != 0 {
t.Fatal("must not seed of_database_auto_cleanup")
}
var geoip string
if err := db.QueryRow(`SELECT value FROM w_system_configs WHERE key = 'geoip_provider'`).Scan(&geoip); err != nil {
t.Fatalf("geoip_provider: %v", err)
}
if geoip != "ipinfo" {
t.Fatalf("geoip_provider = %q, want ipinfo", geoip)
}
}
-41
View File
@@ -1,41 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command flared runs the OpenFlare tunnel client daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
flaredplugin "Wavelet/openflare/plugins/flared"
edgelogging "Wavelet/openflare/share/edge/logging"
)
// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./flared.json", "flared config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(flaredplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("flared startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("flared process exited with error", "error", err)
os.Exit(1)
}
}
-400
View File
@@ -1,400 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type migrateTestDB struct {
db *gorm.DB
}
func (s migrateTestDB) GORM() *gorm.DB { return s.db }
func (s migrateTestDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) }
func (s migrateTestDB) Named(string) *gorm.DB { return s.db }
type migrateTestPlugin struct {
name string
db *gorm.DB
fs fstest.MapFS
}
func (p *migrateTestPlugin) Name() string {
if p.name != "" {
return p.name
}
return "t"
}
func (p *migrateTestPlugin) Apply(ctx *core.Context) error {
core.Provide[contracts.DBService](ctx, migrateTestDB{db: p.db})
ctx.Migrations().Register(p.Name(), p.fs)
return nil
}
func sqliteTableExists(t *testing.T, db *gorm.DB, name string) bool {
t.Helper()
var n int
err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", name).Scan(&n).Error
require.NoError(t, err)
return n > 0
}
func testMigrationFS() fstest.MapFS {
return fstest.MapFS{
"migrations/sqlite/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`)},
}
}
func openMigrateTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "migrate.db")
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
return gdb
}
func TestGooseEngineMigrateOrderCreateTableBaselineUp(t *testing.T) {
gdb := openMigrateTestDB(t)
var order []string
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithMigrationBaseline(func(*core.Context) error {
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
require.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run before baseline")
order = append(order, "create-table", "baseline")
return nil
}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
require.True(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must run after baseline")
order = append(order, "up")
assert.Equal(t, []string{"create-table", "baseline", "up"}, order)
}
func TestGooseEngineBaselineErrorSkipsUp(t *testing.T) {
gdb := openMigrateTestDB(t)
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithMigrationBaseline(func(*core.Context) error {
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
return assert.AnError
}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
err := app.RunMigrations()
require.Error(t, err)
assert.ErrorContains(t, err, "migration baseline")
assert.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run when baseline fails")
}
func TestGooseEngineNilBaselineStillMigrates(t *testing.T) {
gdb := openMigrateTestDB(t)
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
assert.True(t, sqliteTableExists(t, gdb, "w_schema_versions"))
assert.True(t, sqliteTableExists(t, gdb, "t_up"))
}
func TestGooseEngineUpgradesFrom00001To00002(t *testing.T) {
gdb := openMigrateTestDB(t)
runTestMigrations(t, gdb, testMigrationFS(), "")
require.True(t, sqliteTableExists(t, gdb, "t_up"))
require.False(t, sqliteTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.True(t, sqliteTableExists(t, gdb, "t_up"), "00001 table must survive 00002")
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "00002 must create t_v2")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"), "second 00002 run must be a no-op")
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"), "00002 INSERT must not run twice")
}
func TestGooseEngineStampedV1AppliesOnly00002(t *testing.T) {
gdb := openMigrateTestDB(t)
require.NoError(t, gdb.Exec(`CREATE TABLE w_schema_versions (
plugin_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin_id, version_id)
)`).Error)
require.NoError(t, gdb.Exec(`INSERT INTO w_schema_versions (plugin_id, version_id) VALUES ('t', 1)`).Error)
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.False(t, sqliteTableExists(t, gdb, "t_up"), "stamped v1 must not re-run 00001")
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "stamped v1 must still apply 00002")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
}
func TestOpenFlareServerUpgradesFrom00001To00002(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "of.db")
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
require.NoError(t, app.Context().Dispose())
inspect := openInspectDB(t, dbPath, "")
if !pluginHasVersion(t, inspect, false, serverPluginStamp, 1) {
t.Fatal("fresh install did not apply server 00001")
}
_ = inspect.Close()
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
runTestMigrations(t, gdb, serverFollowupFS("sqlite"), "server")
require.False(t, sqliteTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
require.True(t, sqliteTableExists(t, gdb, "of_upgrade_probe"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
require.True(t, sqliteTableExists(t, gdb, "of_zones"), "existing of_* tables must survive 00002")
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
}
func TestGooseEngineUpgradesFrom00001To00002Postgres(t *testing.T) {
gdb := openMigratePostgresDB(t)
opts := postgresMigrateOpt()
runTestMigrations(t, gdb, testPostgresMigrationFS(), "", opts)
require.True(t, pgTableExists(t, gdb, "t_up"))
require.False(t, pgTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
require.True(t, pgTableExists(t, gdb, "t_up"))
require.True(t, pgTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
}
func TestOpenFlareServerUpgradesFrom00001To00002Postgres(t *testing.T) {
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
t.Cleanup(cleanup)
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
app := cordisPrepare(t, cordisPostgresSource(t, host, port, user, pass, dbName, sslMode))
require.NoError(t, app.Context().Dispose())
inspect := openInspectDB(t, "", dsn)
if !pluginHasVersion(t, inspect, true, serverPluginStamp, 1) {
t.Fatal("fresh install did not apply server 00001")
}
_ = inspect.Close()
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
runTestMigrations(t, gdb, serverFollowupFS("postgres"), "server", postgresMigrateOpt())
require.False(t, pgTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
require.True(t, pgTableExists(t, gdb, "of_upgrade_probe"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
require.True(t, pgTableExists(t, gdb, "of_zones"))
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
}
func runTestMigrations(t *testing.T, gdb *gorm.DB, fs fstest.MapFS, pluginName string, opts ...core.AppOption) {
t.Helper()
plugin := &migrateTestPlugin{name: pluginName, db: gdb, fs: fs}
appOpts := []core.AppOption{
core.WithMigrationEngine(&gooseEngine{}),
core.WithPlugins(plugin),
}
appOpts = append(appOpts, opts...)
app := core.NewApp(appOpts...)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
}
func postgresMigrateOpt() core.AppOption {
return core.WithConfigSource(core.NewMapSource(map[string]any{
"database": map[string]any{"enabled": true},
}))
}
func testPostgresMigrationFS() fstest.MapFS {
return fstest.MapFS{
"migrations/postgres/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`)},
}
}
func testMigrationFSWithV2(dialect string) fstest.MapFS {
v1 := `-- +goose Up
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`
v2 := `-- +goose Up
CREATE TABLE t_v2 (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE t_v2;
`
if dialect == "sqlite" {
v1 = `-- +goose Up
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`
v2 = `-- +goose Up
CREATE TABLE t_v2 (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE t_v2;
`
}
return fstest.MapFS{
"migrations/" + dialect + "/00001_init.sql": &fstest.MapFile{Data: []byte(v1)},
"migrations/" + dialect + "/00002_add_t_v2.sql": &fstest.MapFile{Data: []byte(v2)},
}
}
func serverFollowupFS(dialect string) fstest.MapFS {
v1 := `-- +goose Up
CREATE TABLE should_not_exist_from_00001_rerun (id INTEGER);
-- +goose Down
DROP TABLE should_not_exist_from_00001_rerun;
`
v2 := `-- +goose Up
CREATE TABLE of_upgrade_probe (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE of_upgrade_probe;
`
if dialect == "postgres" {
v1 = `-- +goose Up
CREATE TABLE should_not_exist_from_00001_rerun (id BIGINT);
-- +goose Down
DROP TABLE should_not_exist_from_00001_rerun;
`
v2 = `-- +goose Up
CREATE TABLE of_upgrade_probe (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE of_upgrade_probe;
`
}
return fstest.MapFS{
"migrations/" + dialect + "/00001_initial.sql": &fstest.MapFile{Data: []byte(v1)},
"migrations/" + dialect + "/00002_upgrade_probe.sql": &fstest.MapFile{Data: []byte(v2)},
}
}
func pluginSchemaVersion(t *testing.T, db *gorm.DB, pluginID string) int64 {
t.Helper()
var v int64
err := db.Raw(`SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = ?`, pluginID).Scan(&v).Error
require.NoError(t, err)
return v
}
func tableRowCount(t *testing.T, db *gorm.DB, name string) int {
t.Helper()
if !safePGIdent(name) {
t.Fatalf("unsafe table name %q", name)
}
var n int
err := db.Raw("SELECT COUNT(*) FROM " + name).Scan(&n).Error
require.NoError(t, err)
return n
}
func pgTableExists(t *testing.T, db *gorm.DB, name string) bool {
t.Helper()
var n int
err := db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?`, name).Scan(&n).Error
require.NoError(t, err)
return n > 0
}
func openMigratePostgresDB(t *testing.T) *gorm.DB {
t.Helper()
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
t.Cleanup(cleanup)
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
return gdb
}
func createMigratePostgresDB(t *testing.T) (host string, port int, user, pass, dbName, sslMode string, cleanup func()) {
t.Helper()
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
if dsn == "" {
t.Skip("TEST_PG_DSN is not set")
}
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
admin := openInspectDB(t, "", adminDSN)
dbName = fmt.Sprintf("of_mig_%d", time.Now().UnixNano())
if !safePGIdent(dbName) {
t.Fatalf("generated database name %q is not a safe identifier", dbName)
}
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
}
cleanup = func() {
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
_ = admin.Close()
}
return host, port, user, pass, dbName, sslMode, cleanup
}
-108
View File
@@ -1,108 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"Wavelet/core"
)
// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。
const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt"
func TestPluginRoutesContainGoldenBaseline(t *testing.T) {
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
if err := app.Reconcile(); err != nil {
t.Fatal(err)
}
got := routeSet(app.Context())
want := loadBaseline(t)
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
"POST /api/cap/redeem",
} {
delete(want, drop)
}
for k := range want {
if !got[k] {
t.Errorf("missing golden route %s", k)
}
}
for _, must := range []string{
"GET /api/healthz",
"POST /api/v1/cap/challenge",
"POST /api/v1/cap/redeem",
} {
if !got[must] {
t.Errorf("missing required route %s", must)
}
}
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
"POST /api/cap/redeem",
} {
if got[drop] {
t.Errorf("removed route still registered: %s", drop)
}
}
}
func routeSet(ctx *core.Context) map[string]bool {
set := make(map[string]bool)
for _, rd := range ctx.Router().Routes() {
set[rd.Method+" "+rd.Path] = true
}
return set
}
func loadBaseline(t *testing.T) map[string]bool {
t.Helper()
path := locateFile(t, baselineRoutesFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read baseline %s: %v", path, err)
}
set := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" {
set[line] = true
}
}
if len(set) == 0 {
t.Fatalf("baseline %s is empty", path)
}
return set
}
func locateFile(t *testing.T, rel string) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
dir := filepath.Dir(thisFile)
for range 8 {
candidate := filepath.Join(dir, rel)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
dir = filepath.Join(dir, "..")
}
t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile))
return ""
}
-41
View File
@@ -1,41 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command relay runs the OpenFlare relay node daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
relayplugin "Wavelet/openflare/plugins/relay"
edgelogging "Wavelet/openflare/share/edge/logging"
)
// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./relay.json", "relay config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(relayplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("relay startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("relay process exited with error", "error", err)
os.Exit(1)
}
}
-109
View File
@@ -1,109 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core/extpoints"
"Wavelet/pkg/buildinfo"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/pkg/trace"
"Wavelet/plugins/infra/config"
"context"
"log"
"time"
"github.com/spf13/cobra"
)
const traceShutdownTimeout = 10 * time.Second
type hostConfig struct {
App struct {
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
Env string `config:"env" env:"APP_ENV" default:"production"`
NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"`
Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"`
} `config:"app"`
Log struct {
Level string `config:"level" env:"LOG_LEVEL" default:"info"`
Format string `config:"format" env:"LOG_FORMAT" default:"json"`
Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"`
FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"`
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"`
MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"`
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"`
Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"`
} `config:"log"`
OTel struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"`
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
} `config:"otel"`
}
var rootCmd = &cobra.Command{
Use: "wavelet",
PersistentPreRun: func(_ *cobra.Command, _ []string) {
src, err := config.NewSource()
if err != nil {
log.Fatalf("[CMD] load config source failed: %v", err)
}
var cfg hostConfig
reg := extpoints.NewConfigRegistry(src)
_ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg})
if err := reg.Resolve(); err != nil {
log.Fatalf("[CMD] resolve host config failed: %v", err)
}
_ = reg.Bind("", &cfg)
// Initialize idgen snowflake generator
if err := idgen.Init(cfg.App.NodeID); err != nil {
log.Fatalf("[CMD] init idgen failed: %v", err)
}
logger.Init(logger.Config{
Level: cfg.Log.Level,
Format: cfg.Log.Format,
Output: cfg.Log.Output,
FilePath: cfg.Log.FilePath,
MaxSize: cfg.Log.MaxSize,
MaxAge: cfg.Log.MaxAge,
MaxBackups: cfg.Log.MaxBackups,
Compress: cfg.Log.Compress,
})
trace.Init(trace.Config{
AppName: cfg.App.AppName,
SamplingRate: cfg.OTel.SamplingRate,
TracerName: cfg.OTel.TracerName,
})
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
shutdownTraceProvider()
},
Run: func(_ *cobra.Command, args []string) {
// 无参数时默认以融合模式启动所有服务
allCmd.Run(allCmd, args)
},
}
func shutdownTraceProvider() {
ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout)
defer cancel()
trace.Shutdown(ctx)
}
func init() {
rootCmd.Version = buildinfo.Version
rootCmd.CompletionOptions.DisableDefaultCmd = true
// 集中将子命令注册到根命令,以解决 Cobra 的 unknown command 校验限制
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
}
// Execute 执行根命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatalf("[CMD] execute failed; %s\n", err)
}
}
-18
View File
@@ -1,18 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var schedulerCmd = &cobra.Command{
Use: "scheduler",
Short: "wavelet Scheduler",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileSchedule, "scheduler", false)
},
}
-771
View File
@@ -1,771 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"Wavelet/core"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
const (
goldenRoot = "/Users/ryan/Code/Go/OpenFlare"
goldCommit = "9f79fb99"
goldGooseVersion = int64(202608090003)
sampleZoneDomain = "l3-upgrade-golden.example"
goldMigrateWait = 75 * time.Second
legacyPluginStamp = "openflare/legacy"
serverPluginStamp = "server"
)
var (
goldBinOnce sync.Once
goldBinPath string
goldSrcDir string
goldBinErr error
)
func TestUpgradeFromGolden(t *testing.T) {
t.Run("sqlite", func(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "a.db")
runGoldenAPI(t, tmp, goldSQLiteEnv(t, tmp, dbPath), func() bool {
return sqliteReady(dbPath)
})
assertUpgradeFromGolden(t, upgradeDB{
sqlitePath: dbPath,
source: cordisSQLiteSource(t, dbPath),
})
})
}
func TestUpgradePostgresFromGolden(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
if dsn == "" {
t.Skip("TEST_PG_DSN is not set")
}
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
admin := openInspectDB(t, "", adminDSN)
t.Cleanup(func() { _ = admin.Close() })
dbName := fmt.Sprintf("of_l3_%d", time.Now().UnixNano())
if !safePGIdent(dbName) {
t.Fatalf("generated database name %q is not a safe identifier", dbName)
}
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
}
t.Cleanup(func() {
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
})
tmp := t.TempDir()
testDSN := postgresDSN(host, port, user, pass, dbName, sslMode)
runGoldenAPI(t, tmp, goldPostgresEnv(t, tmp, host, port, user, pass, dbName, sslMode), func() bool {
return postgresReady(testDSN)
})
assertUpgradeFromGolden(t, upgradeDB{
pgDSN: testDSN,
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
})
}
func TestUpgradePostgresFromExistingDump(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_PG_EXISTING_DSN"))
if dsn == "" {
t.Skip("TEST_PG_EXISTING_DSN is not set")
}
host, port, user, pass, dbName, sslMode := parsePostgresDSN(t, dsn)
spec := upgradeDB{
pgDSN: dsn,
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
}
inspect := openInspectDB(t, "", spec.pgDSN)
beforeCounts := countNamedTables(t, inspect, productionCountTables)
beforeTables := listPublicTables(t, inspect)
_ = inspect.Close()
assertUpgradeFromGolden(t, spec)
inspect = openInspectDB(t, "", spec.pgDSN)
defer func() { _ = inspect.Close() }()
afterCounts := countNamedTables(t, inspect, productionCountTables)
for _, name := range productionCountTables {
if afterCounts[name] < beforeCounts[name] {
t.Errorf("row count dropped for %s: before %d after %d", name, beforeCounts[name], afterCounts[name])
}
}
afterTables := listPublicTables(t, inspect)
for name := range beforeTables {
if !afterTables[name] {
t.Errorf("table %s dropped", name)
}
}
for _, name := range []string{"w_schema_versions", "w_message_channels", "w_message_bindings", "w_message_pairing_codes"} {
if !afterTables[name] {
t.Errorf("expected upgrade to create %s", name)
}
}
var n int
if err := inspect.QueryRow(`SELECT COUNT(*) FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhparent WHERE c.relname IN ('of_node_access_logs', 'w_user_access_logs')`).Scan(&n); err != nil {
t.Fatalf("count partitions: %v", err)
}
if n < 8 {
t.Errorf("partition children = %d, want at least 8", n)
}
}
var productionCountTables = []string{
"of_zones", "of_zone_domains", "of_proxy_routes", "of_nodes", "of_origins",
"of_tls_certificates", "of_waf_rule_groups", "of_pages_projects",
"w_users", "w_schedules", "w_system_configs", "w_templates", "w_uploads",
"of_node_access_logs", "w_user_access_logs",
}
func countNamedTables(t *testing.T, db *sql.DB, tables []string) map[string]int {
t.Helper()
out := make(map[string]int, len(tables))
for _, name := range tables {
if !safePGIdent(name) {
t.Fatalf("unsafe table name %q", name)
}
var n int
if err := db.QueryRow("SELECT COUNT(*) FROM " + name).Scan(&n); err != nil {
t.Fatalf("count %s: %v", name, err)
}
out[name] = n
}
return out
}
func listPublicTables(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
rows, err := db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
t.Fatalf("list public tables: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan table name: %v", err)
}
out[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("list public tables: %v", err)
}
return out
}
type upgradeDB struct {
sqlitePath string
pgDSN string
source core.ConfigSource
}
func assertUpgradeFromGolden(t *testing.T, spec upgradeDB) {
t.Helper()
inspect := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
before := dumpOfSchema(t, inspect, spec.pgDSN != "")
insertSQL := `INSERT INTO of_zones (domain) VALUES (?)`
if spec.pgDSN != "" {
insertSQL = `INSERT INTO of_zones (domain) VALUES ($1)`
}
if _, err := inspect.Exec(insertSQL, sampleZoneDomain); err != nil {
t.Fatalf("insert sample of_zones row: %v", err)
}
_ = inspect.Close()
app := cordisPrepare(t, spec.source)
legacyRows := schemaPluginRows(t, spec, legacyPluginStamp)
assertStampedUpgrade(t, spec, before, legacyRows)
if err := app.Context().Dispose(); err != nil {
t.Fatalf("dispose first app: %v", err)
}
app2 := cordisPrepare(t, spec.source)
t.Cleanup(func() { _ = app2.Context().Dispose() })
if got := schemaPluginRows(t, spec, legacyPluginStamp); got != legacyRows {
t.Fatalf("second Prepare increased %s rows: got %d, want %d", legacyPluginStamp, got, legacyRows)
}
assertStampedUpgrade(t, spec, before, legacyRows)
}
func cordisPrepare(t *testing.T, src core.ConfigSource) *core.App {
t.Helper()
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(src))
if err := app.Prepare(); err != nil {
t.Fatalf("Prepare: %v", err)
}
if err := app.ApplyPlugins(); err != nil {
t.Fatalf("ApplyPlugins: %v", err)
}
if err := app.RunMigrations(); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
return app
}
func assertStampedUpgrade(t *testing.T, spec upgradeDB, before map[string][]string, legacyRows int) {
t.Helper()
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
defer func() { _ = db.Close() }()
postgres := spec.pgDSN != ""
if got := gooseMaxVersion(t, db); got != goldGooseVersion {
t.Errorf("goose_db_version max = %d, want %d", got, goldGooseVersion)
}
if legacyRows < 2 {
t.Errorf("w_schema_versions %s rows = %d, want at least 2 (0 and %d)", legacyPluginStamp, legacyRows, goldGooseVersion)
}
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, 0) {
t.Errorf("missing w_schema_versions (%s, 0)", legacyPluginStamp)
}
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, goldGooseVersion) {
t.Errorf("missing w_schema_versions (%s, %d)", legacyPluginStamp, goldGooseVersion)
}
if !pluginHasVersion(t, db, postgres, serverPluginStamp, 1) {
t.Errorf("missing w_schema_versions (%s, 1)", serverPluginStamp)
}
var domain string
q := `SELECT domain FROM of_zones WHERE domain = ?`
if postgres {
q = `SELECT domain FROM of_zones WHERE domain = $1`
}
if err := db.QueryRow(q, sampleZoneDomain).Scan(&domain); err != nil {
t.Errorf("sample of_zones row missing after upgrade: %v", err)
}
after := dumpOfSchema(t, db, postgres)
for table, cols := range before {
got, ok := after[table]
if !ok {
t.Errorf("of_* table %s dropped", table)
continue
}
have := make(map[string]bool, len(got))
for _, c := range got {
have[c] = true
}
for _, c := range cols {
if !have[c] {
t.Errorf("of_* column %s.%s dropped", table, c)
}
}
}
}
func runGoldenAPI(t *testing.T, workDir string, env []string, ready func() bool) {
t.Helper()
bin := buildGoldenBinary(t)
ctx, cancel := context.WithTimeout(context.Background(), goldMigrateWait)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "api")
cmd.Dir = workDir
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Start(); err != nil {
t.Fatalf("start golden api: %v", err)
}
waitErr := make(chan error, 1)
go func() { waitErr <- cmd.Wait() }()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
if ready() {
killGolden(cmd)
<-waitErr
return
}
select {
case err := <-waitErr:
if ready() {
return
}
t.Fatalf("golden api exited before goose %d: %v\n%s", goldGooseVersion, err, out.String())
case <-ctx.Done():
killGolden(cmd)
<-waitErr
t.Fatalf("timeout waiting for golden goose %d\n%s", goldGooseVersion, out.String())
case <-ticker.C:
}
}
}
func killGolden(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
func buildGoldenBinary(t *testing.T) string {
t.Helper()
goldBinOnce.Do(func() {
src, err := os.MkdirTemp("", "of-gold-src-")
if err != nil {
goldBinErr = err
return
}
archive := exec.Command("git", "-C", goldenRoot, "archive", goldCommit)
extract := exec.Command("tar", "-x", "-C", src)
pipe, err := archive.StdoutPipe()
if err != nil {
goldBinErr = fmt.Errorf("gold archive pipe: %w", err)
return
}
extract.Stdin = pipe
var archiveErr, extractErr bytes.Buffer
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := archive.Start(); err != nil {
goldBinErr = fmt.Errorf("git archive %s: %w", goldCommit, err)
return
}
if err := extract.Start(); err != nil {
_ = archive.Process.Kill()
goldBinErr = fmt.Errorf("extract gold %s: %w", goldCommit, err)
return
}
if err := extract.Wait(); err != nil {
_ = archive.Wait()
goldBinErr = fmt.Errorf("extract gold %s: %w\n%s", goldCommit, err, extractErr.String())
return
}
if err := archive.Wait(); err != nil {
goldBinErr = fmt.Errorf("git archive %s: %w\n%s", goldCommit, err, archiveErr.String())
return
}
if _, err := os.Stat(filepath.Join(src, "main.go")); err != nil {
goldBinErr = fmt.Errorf("gold %s at %s: %w", goldCommit, src, err)
return
}
goldSrcDir = src
dir, err := os.MkdirTemp("", "of-gold-bin-")
if err != nil {
goldBinErr = err
return
}
out := filepath.Join(dir, "gold")
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Dir = src
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String())
return
}
goldBinPath = out
})
if goldBinErr != nil {
t.Fatalf("%v", goldBinErr)
}
return goldBinPath
}
func copyGoldConfig(t *testing.T, dir string) string {
t.Helper()
buildGoldenBinary(t)
dst := filepath.Join(dir, "config.yaml")
src, err := os.Open(filepath.Join(goldSrcDir, "config.example.yaml")) //nolint:gosec // extracted gold snapshot
if err != nil {
t.Fatalf("open golden config.example.yaml: %v", err)
}
defer func() { _ = src.Close() }()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // test temp file
if err != nil {
t.Fatalf("create temp config.yaml: %v", err)
}
if _, err := io.Copy(out, src); err != nil {
_ = out.Close()
t.Fatalf("copy golden config: %v", err)
}
if err := out.Close(); err != nil {
t.Fatalf("close temp config.yaml: %v", err)
}
return dst
}
func goldSQLiteEnv(t *testing.T, dir, dbPath string) []string {
t.Helper()
cfg := copyGoldConfig(t, dir)
addr := freeLocalAddr(t)
return filteredGoldEnv(
"CONFIG_PATH="+cfg,
"SQLITE_PATH="+dbPath,
"DB_ENABLED=false",
"REDIS_ENABLED=false",
"CLICKHOUSE_ENABLED=false",
"APP_ENV=testing",
"APP_ADDR="+addr,
)
}
func goldPostgresEnv(t *testing.T, dir, host string, port int, user, pass, dbName, sslMode string) []string {
t.Helper()
cfg := copyGoldConfig(t, dir)
addr := freeLocalAddr(t)
return filteredGoldEnv(
"CONFIG_PATH="+cfg,
"DB_ENABLED=true",
"DB_HOST="+host,
"DB_PORT="+strconv.Itoa(port),
"DB_USERNAME="+user,
"DB_PASSWORD="+pass,
"DB_NAME="+dbName,
"DB_SSL_MODE="+sslMode,
"REDIS_ENABLED=false",
"CLICKHOUSE_ENABLED=false",
"APP_ENV=testing",
"APP_ADDR="+addr,
)
}
func filteredGoldEnv(extra ...string) []string {
drop := map[string]bool{
"CONFIG_PATH": true,
"SQLITE_PATH": true,
"DB_ENABLED": true,
"DB_HOST": true,
"DB_PORT": true,
"DB_USERNAME": true,
"DB_PASSWORD": true,
"DB_NAME": true,
"DB_SSL_MODE": true,
"REDIS_ENABLED": true,
"REDIS_ADDR": true,
"CLICKHOUSE_ENABLED": true,
"CLICKHOUSE_HOST": true,
"APP_ENV": true,
"APP_ADDR": true,
}
env := make([]string, 0, len(os.Environ())+len(extra))
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if drop[k] {
continue
}
env = append(env, kv)
}
return append(env, extra...)
}
func freeLocalAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for free port: %v", err)
}
addr := ln.Addr().String()
_ = ln.Close()
return addr
}
func cordisSQLiteSource(t *testing.T, dbPath string) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"clickhouse": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": false,
"sqlite_path": dbPath,
},
})
}
func cordisPostgresSource(t *testing.T, host string, port int, user, pass, dbName, sslMode string) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"clickhouse": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": true,
"host": host,
"port": port,
"username": user,
"password": pass,
"database": dbName,
"ssl_mode": sslMode,
},
})
}
func sqliteReady(path string) bool {
if _, err := os.Stat(path); err != nil {
return false
}
gdb, err := gorm.Open(sqlite.Open("file:"+path+"?mode=ro&_pragma=busy_timeout(1000)"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
return false
}
sqlDB, err := gdb.DB()
if err != nil {
return false
}
defer func() { _ = sqlDB.Close() }()
return migratedReady(sqlDB, false)
}
func postgresReady(dsn string) bool {
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
return false
}
sqlDB, err := gdb.DB()
if err != nil {
return false
}
defer func() { _ = sqlDB.Close() }()
return migratedReady(sqlDB, true)
}
func migratedReady(db *sql.DB, postgres bool) bool {
if gooseMaxVersionSilent(db) != goldGooseVersion {
return false
}
var n int
var err error
if postgres {
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'of_nodes'`).Scan(&n)
} else {
err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'of_nodes'`).Scan(&n)
}
return err == nil && n > 0
}
func openInspectDB(t *testing.T, sqlitePath, pgDSN string) *sql.DB {
t.Helper()
var gdb *gorm.DB
var err error
if pgDSN != "" {
gdb, err = gorm.Open(postgres.Open(pgDSN), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
} else {
gdb, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
}
if err != nil {
t.Fatalf("open inspect db: %v", err)
}
sqlDB, err := gdb.DB()
if err != nil {
t.Fatalf("inspect sql.DB: %v", err)
}
return sqlDB
}
func dumpOfSchema(t *testing.T, db *sql.DB, postgres bool) map[string][]string {
t.Helper()
tables := ofTables(t, db, postgres)
out := make(map[string][]string, len(tables))
for _, table := range tables {
out[table] = ofColumns(t, db, postgres, table)
}
if len(out) == 0 {
t.Fatal("no of_* tables in golden database")
}
return out
}
func ofTables(t *testing.T, db *sql.DB, postgres bool) []string {
t.Helper()
var rows *sql.Rows
var err error
if postgres {
rows, err = db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'of_%' ORDER BY tablename`)
} else {
rows, err = db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%' ORDER BY name`)
}
if err != nil {
t.Fatalf("list of_* tables: %v", err)
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan of_* table: %v", err)
}
tables = append(tables, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("list of_* tables: %v", err)
}
return tables
}
func ofColumns(t *testing.T, db *sql.DB, postgres bool, table string) []string {
t.Helper()
var rows *sql.Rows
var err error
if postgres {
rows, err = db.Query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, table)
} else {
rows, err = db.Query(`SELECT name FROM pragma_table_info(?)`, table)
}
if err != nil {
t.Fatalf("list columns for %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
var cols []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan column for %s: %v", table, err)
}
cols = append(cols, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("list columns for %s: %v", table, err)
}
return cols
}
func gooseMaxVersion(t *testing.T, db *sql.DB) int64 {
t.Helper()
v := gooseMaxVersionSilent(db)
if v < 0 {
t.Fatal("read goose_db_version max failed")
}
return v
}
func gooseMaxVersionSilent(db *sql.DB) int64 {
var v int64
if err := db.QueryRow(`SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version`).Scan(&v); err != nil {
return -1
}
return v
}
func schemaPluginRows(t *testing.T, spec upgradeDB, pluginID string) int {
t.Helper()
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
defer func() { _ = db.Close() }()
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ?`
if spec.pgDSN != "" {
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1`
}
var n int
if err := db.QueryRow(q, pluginID).Scan(&n); err != nil {
t.Fatalf("count w_schema_versions %s: %v", pluginID, err)
}
return n
}
func pluginHasVersion(t *testing.T, db *sql.DB, postgres bool, pluginID string, version int64) bool {
t.Helper()
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ? AND version_id = ?`
if postgres {
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1 AND version_id = $2`
}
var n int
if err := db.QueryRow(q, pluginID, version).Scan(&n); err != nil {
t.Fatalf("lookup w_schema_versions (%s, %d): %v", pluginID, version, err)
}
return n > 0
}
func parsePostgresDSN(t *testing.T, dsn string) (host string, port int, user, pass, dbName, sslMode string) {
t.Helper()
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("TEST_PG_DSN: %v", err)
}
host = u.Hostname()
if host == "" {
host = "127.0.0.1"
}
port = 5432
if p := u.Port(); p != "" {
port, err = strconv.Atoi(p)
if err != nil {
t.Fatalf("TEST_PG_DSN port: %v", err)
}
}
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
}
dbName = strings.Trim(u.Path, "/")
if dbName == "" {
dbName = "postgres"
}
sslMode = u.Query().Get("sslmode")
if sslMode == "" {
sslMode = "disable"
}
return
}
func postgresDSN(host string, port int, user, pass, dbName, sslMode string) string {
u := &url.URL{
Scheme: "postgres",
Host: net.JoinHostPort(host, strconv.Itoa(port)),
Path: dbName,
}
if user != "" {
u.User = url.UserPassword(user, pass)
}
q := url.Values{}
q.Set("sslmode", sslMode)
u.RawQuery = q.Encode()
return u.String()
}
var pgIdent = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
func safePGIdent(name string) bool {
return pgIdent.MatchString(name)
}
-18
View File
@@ -1,18 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "wavelet Worker",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileWorker, "worker", false)
},
}
-707
View File
@@ -1,707 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
const (
defaultShutdownTimeout = 10 * time.Second
)
// AppOption configures an App instance during construction.
type AppOption func(*App)
// WithContext sets a custom root Context for the App.
func WithContext(ctx *Context) AppOption {
return func(a *App) {
if ctx != nil {
a.ctx = ctx
}
}
}
// WithProfile sets the runtime profile for the App.
func WithProfile(profile Profile) AppOption {
return func(a *App) {
a.profile = normalizeProfile(profile)
}
}
// WithPlugins registers initial plugins for the App.
func WithPlugins(plugins ...Plugin) AppOption {
return func(a *App) {
a.Use(plugins...)
}
}
// WithMigrationEngine sets the database migration engine for the App.
func WithMigrationEngine(engine MigrationEngine) AppOption {
return func(a *App) {
a.migrationEngine = engine
}
}
// WithMigrationRunner sets the migration runner function for the App.
func WithMigrationRunner(runner MigrationRunner) AppOption {
return func(a *App) {
a.migrationEngine = runner
}
}
// WithMigrationBaseline registers a hook the migration engine runs after the
// shared version table exists and before any plugin Up.
func WithMigrationBaseline(fn func(*Context) error) AppOption {
return func(a *App) {
a.migrationBaseline = fn
}
}
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
func WithShutdownTimeout(timeout time.Duration) AppOption {
return func(a *App) {
if timeout > 0 {
a.shutdownTimeout = timeout
}
}
}
// WithConfigSource installs the raw configuration source adapter, typically built by an
// infrastructure package outside the kernel, before any plugin is applied.
func WithConfigSource(src ConfigSource) AppOption {
return func(a *App) {
if src == nil {
return
}
// Installed during Prepare so the option order, including WithContext, is irrelevant.
a.configSource = src
}
}
// WithConfigDecl lets the composition root declare the configuration it reads itself,
// so host-level values take part in conflict validation and the redacted report. The
// bindings are registered during Prepare, so option order does not matter.
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
return func(a *App) {
if len(bindings) == 0 {
return
}
if a.hostDeclOwner == "" {
a.hostDeclOwner = pluginID
}
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
}
}
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
// and graceful signal-driven LIFO shutdown.
type App struct {
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
migrationBaseline func(*Context) error
shutdownTimeout time.Duration
configSource ConfigSource
hostDeclOwner string
hostDeclBindings []ConfigBinding
prepared bool
applyErr error
}
// NewApp creates a new Cordis application instance with default options.
func NewApp(opts ...AppOption) *App {
app := &App{
ctx: NewContext(context.Background()),
profile: ProfileAll,
pluginMap: make(map[string]Plugin),
fiberMap: make(map[string]*Fiber),
shutdownTimeout: defaultShutdownTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(app)
}
}
return app
}
// Context returns the root micro-kernel Context of the application.
func (a *App) Context() *Context {
return a.ctx
}
// Profile returns the current runtime profile of the application.
func (a *App) Profile() Profile {
a.mu.RLock()
defer a.mu.RUnlock()
return a.profile
}
// WithProfile sets the application runtime profile and returns the App for fluent chaining.
func (a *App) WithProfile(profile Profile) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.profile = normalizeProfile(profile)
return a
}
// SetProfile sets the application runtime profile.
func (a *App) SetProfile(profile Profile) *App {
return a.WithProfile(profile)
}
// Use registers one or more plugins into the application in registration order.
// Duplicate plugins (by Name) update existing registrations in-place to preserve order.
func (a *App) Use(plugins ...Plugin) *App {
a.mu.Lock()
defer a.mu.Unlock()
for _, p := range plugins {
if p == nil {
continue
}
name := p.Name()
if name == "" {
continue
}
if _, exists := a.pluginMap[name]; exists {
for i, existing := range a.plugins {
if existing.Name() == name {
a.plugins[i] = p
break
}
}
if existingFiber, ok := a.fiberMap[name]; ok {
existingFiber.plugin = p
}
} else {
a.plugins = append(a.plugins, p)
f := NewFiber(a.ctx, p)
a.fibers = append(a.fibers, f)
a.fiberMap[name] = f
}
a.pluginMap[name] = p
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
// Gates are evaluated before Apply, so their keys must be declared at mount time.
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
}
}
return a
}
// Plugins returns a copy of all registered plugins in registration order.
func (a *App) Plugins() []Plugin {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Plugin, len(a.plugins))
copy(res, a.plugins)
return res
}
// Plugin retrieves a registered plugin by its unique name.
func (a *App) Plugin(name string) (Plugin, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
p, ok := a.pluginMap[name]
return p, ok
}
// Fibers returns a copy of all plugin Fibers.
func (a *App) Fibers() []*Fiber {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]*Fiber, len(a.fibers))
copy(res, a.fibers)
return res
}
// Fiber retrieves a Fiber by its unique plugin name.
func (a *App) Fiber(name string) (*Fiber, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
f, ok := a.fiberMap[name]
return f, ok
}
// SetMigrationEngine sets the migration engine for the application.
func (a *App) SetMigrationEngine(engine MigrationEngine) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.migrationEngine = engine
return a
}
// SetMigrationRunner sets the migration runner function for the application.
func (a *App) SetMigrationRunner(runner MigrationRunner) *App {
return a.SetMigrationEngine(runner)
}
// Reconcile evaluates all pending Fibers and reactively transitions them to ACTIVE
// as their declared dependencies become satisfied.
func (a *App) Reconcile() error {
a.mu.Lock()
defer a.mu.Unlock()
return a.reconcileLocked()
}
func (a *App) reconcileLocked() error {
if err := a.prepareLocked(); err != nil {
return err
}
for {
progress := false
for _, f := range a.fibers {
if f.State() != FiberPending {
continue
}
gated, skip, err := a.evaluateGateLocked(f)
if err != nil {
return err
}
if gated && skip {
if err := f.Skip(); err != nil {
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
}
continue
}
if f.DependenciesSatisfied(a.ctx) {
if err := f.Load(); err != nil {
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
}
progress = true
// Rescan from the head of the Use() list so earlier pending
// plugins run before later ones that became ready in this pass.
break
}
}
if !progress {
break
}
}
var unsatisfied []string
for _, f := range a.fibers {
if f.State() == FiberPending {
unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies()))
}
}
if len(unsatisfied) > 0 {
return fmt.Errorf("core: unsatisfied dependencies for plugins: %s", strings.Join(unsatisfied, ", "))
}
return nil
}
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
// resolved values. Plugins that do not implement the gate interface are never skipped.
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
if !ok {
return false, false, nil
}
view := a.ctx.Config()
if !view.Resolved() {
return true, false, fmt.Errorf(
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
}
return true, !gatedPlugin.ConfigEnabled(view), nil
}
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
// It is idempotent and only applies plugins once per App instance.
func (a *App) ApplyPlugins() error {
a.mu.Lock()
if a.applied {
a.mu.Unlock()
return nil
}
a.applied = true
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
a.mu.Unlock()
if declaredErr != nil {
return declaredErr
}
if prepareErr != nil {
return prepareErr
}
return a.Reconcile()
}
// Prepare resolves declared configuration and establishes the resolution barrier that
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
// ApplyPlugins; callers that need resolved values earlier — for example to size a
// shutdown budget — invoke it explicitly right after mounting plugins.
func (a *App) Prepare() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.applyErr != nil {
return a.applyErr
}
return a.prepareLocked()
}
// prepareLocked installs the injected source, registers host declarations and resolves
// every declared key once. An App without a ConfigSource leaves configuration unused,
// so kernel-level usage stays opt-in for embedders that configure nothing.
func (a *App) prepareLocked() error {
if a.prepared {
return nil
}
if a.configSource != nil {
config := a.ctx.Config()
config.SetSource(a.configSource)
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
return err
}
if err := config.Resolve(); err != nil {
return err
}
}
a.ctx.setMigrationBaseline(a.migrationBaseline)
a.prepared = true
return nil
}
// ShutdownTimeout returns the graceful shutdown budget for the application.
func (a *App) ShutdownTimeout() time.Duration {
a.mu.RLock()
defer a.mu.RUnlock()
return a.shutdownTimeout
}
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
// values so a missing configuration key can never shrink the kernel fallback to zero.
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
a.mu.Lock()
defer a.mu.Unlock()
if timeout > 0 {
a.shutdownTimeout = timeout
}
return a
}
// RunMigrations dispatches migration execution across all registered plugin migration entries.
func (a *App) RunMigrations() error {
entries := a.ctx.Migrations().Entries()
if len(entries) == 0 {
return nil
}
a.mu.RLock()
engine := a.migrationEngine
a.mu.RUnlock()
if engine == nil {
// Attempt to resolve from IoC container
if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil {
engine = resolved
}
}
if engine == nil {
return nil
}
if err := engine.Migrate(a.ctx, entries); err != nil {
return fmt.Errorf("core: migration failed: %w", err)
}
return nil
}
// Start executes the application boot pipeline:
// 1. Applies all registered plugins to populate services, routes, tasks, and drivers.
// 2. Dispatches database migrations via MigrationEngine.
// 3. Filters and starts drivers matching the active Profile.
// 4. Emits "app:ready" on the EventBus.
func (a *App) Start(ctx ...context.Context) error {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return ErrAppRunning
}
a.running = true
a.mu.Unlock()
var baseCtx context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
baseCtx = ctx[0]
case a.ctx != nil:
baseCtx = a.ctx.GoContext()
default:
baseCtx = context.Background()
}
// 1. Apply plugins
if err := a.ApplyPlugins(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 2. Run migrations
if err := a.RunMigrations(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 3. Filter drivers matching active profile
a.mu.RLock()
prof := a.profile
a.mu.RUnlock()
allDrivers := a.ctx.Drivers()
var driversToStart []Driver
for _, d := range allDrivers {
if matchesProfile(prof, d.Type()) {
driversToStart = append(driversToStart, d)
}
}
// 4. Start matching drivers
for _, d := range driversToStart {
if err := d.Start(baseCtx); err != nil {
// Rollback already started drivers in reverse order
a.mu.Lock()
started := a.startedDrivers
a.startedDrivers = nil
a.running = false
a.mu.Unlock()
for i := len(started) - 1; i >= 0; i-- {
_ = started[i].Stop(context.Background())
}
return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err)
}
a.mu.Lock()
a.startedDrivers = append(a.startedDrivers, d)
a.mu.Unlock()
}
// 5. Emit app:ready event
_ = a.ctx.Events().Emit(baseCtx, "app:ready", a)
return nil
}
// Stop gracefully shuts down the application:
// 1. Emits "app:stopping" on the EventBus.
// 2. Stops all started drivers in LIFO (reverse) order.
// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order).
// 4. Emits "app:stopped" on the EventBus.
func (a *App) Stop(ctx ...context.Context) error {
a.mu.Lock()
if !a.running {
a.mu.Unlock()
return nil
}
a.running = false
started := a.startedDrivers
a.startedDrivers = nil
timeout := a.shutdownTimeout
a.mu.Unlock()
var shutdownCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
shutdownCtx = ctx[0]
} else {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a)
var errs []error
// 1. Stop drivers in reverse order
for i := len(started) - 1; i >= 0; i-- {
d := started[i]
if err := d.Stop(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err))
}
}
// 2. Unload fibers in reverse order
a.mu.RLock()
fibers := make([]*Fiber, len(a.fibers))
copy(fibers, a.fibers)
a.mu.RUnlock()
for i := len(fibers) - 1; i >= 0; i-- {
if err := fibers[i].Unload(); err != nil {
errs = append(errs, fmt.Errorf("core: unload fiber %s failed: %w", fibers[i].Name(), err))
}
}
// 3. Dispose root context
if a.ctx != nil && !a.ctx.IsDisposed() {
if err := a.ctx.Dispose(); err != nil {
errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err))
}
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a)
return errors.Join(errs...)
}
// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received,
// then executes graceful shutdown. It forwards a sigCtx derived from the caller's context to Start.
//
//nolint:contextcheck // the caller's ctx does reach Start via sigCtx; the rule cannot follow Run's variadic context parameter
func (a *App) Run(ctx ...context.Context) error {
var parent context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
parent = ctx[0]
case a.ctx != nil:
parent = a.ctx.GoContext()
default:
parent = context.Background()
}
sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
defer stopSignals()
if err := a.Start(sigCtx); err != nil {
return err
}
// Wait for OS signal or context cancellation
<-sigCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
return a.Stop(shutdownCtx)
}
// IsRunning returns whether the application is currently running.
func (a *App) IsRunning() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.running
}
// StartedDrivers returns a copy of currently running drivers.
func (a *App) StartedDrivers() []Driver {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Driver, len(a.startedDrivers))
copy(res, a.startedDrivers)
return res
}
// ExecuteCLI parses CLI arguments to configure the profile and runs the application.
func (a *App) ExecuteCLI(args ...string) error {
var ctx context.Context
if a.ctx != nil {
ctx = a.ctx.GoContext()
} else {
ctx = context.Background()
}
return a.ExecuteCLIWithContext(ctx, args...)
}
// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context.
func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error {
cliArgs := args
if len(cliArgs) == 0 {
cliArgs = os.Args[1:]
}
profile := ProfileAll
if len(cliArgs) > 0 {
first := strings.TrimSpace(cliArgs[0])
switch {
case strings.HasPrefix(first, "--profile="):
profile = Profile(strings.TrimPrefix(first, "--profile="))
case strings.HasPrefix(first, "-p="):
profile = Profile(strings.TrimPrefix(first, "-p="))
case !strings.HasPrefix(first, "-"):
profile = Profile(first)
}
}
a.WithProfile(profile)
return a.Run(ctx)
}
func matchesProfile(profile Profile, dt DriverType) bool {
norm := normalizeProfile(profile)
switch norm {
case ProfileAll, "":
return true
case ProfileAPI:
return dt == DriverTypeHTTP
case ProfileWorker:
return dt == DriverTypeWorker
case ProfileSchedule:
return dt == DriverTypeScheduler
default:
return string(norm) == string(dt)
}
}
func normalizeProfile(p Profile) Profile {
switch strings.ToLower(strings.TrimSpace(string(p))) {
case "api", "http":
return ProfileAPI
case "worker":
return ProfileWorker
case "schedule", "scheduler", "cron":
return ProfileSchedule
case "all", "fused", "full", "":
return ProfileAll
default:
return p
}
}
-654
View File
@@ -1,654 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"context"
"errors"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// appMockDriver is a test driver tracking its start/stop lifecycle.
type appMockDriver struct {
mu sync.Mutex
driverType core.DriverType
startCalled bool
stopCalled bool
startErr error
stopErr error
}
func newAppMockDriver(dt core.DriverType) *appMockDriver {
return &appMockDriver{driverType: dt}
}
func (m *appMockDriver) Type() core.DriverType {
return m.driverType
}
func (m *appMockDriver) Start(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.startErr != nil {
return m.startErr
}
m.startCalled = true
return nil
}
func (m *appMockDriver) Stop(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopErr != nil {
return m.stopErr
}
m.stopCalled = true
return nil
}
func (m *appMockDriver) isStarted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.startCalled
}
func (m *appMockDriver) isStopped() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.stopCalled
}
// appMockPlugin is a test plugin.
type appMockPlugin struct {
name string
applyFn func(ctx *core.Context) error
}
func (p *appMockPlugin) Name() string {
return p.name
}
func (p *appMockPlugin) Apply(ctx *core.Context) error {
if p.applyFn != nil {
return p.applyFn(ctx)
}
return nil
}
func TestAppNewAndConfiguration(t *testing.T) {
customCtx := core.NewContext(context.Background())
p1 := &appMockPlugin{name: "plugin1"}
p2 := &appMockPlugin{name: "plugin2"}
app := core.NewApp(
core.WithContext(customCtx),
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p1, p2),
core.WithShutdownTimeout(5*time.Second),
)
assert.Equal(t, customCtx, app.Context())
assert.Equal(t, core.ProfileAPI, app.Profile())
assert.Len(t, app.Plugins(), 2)
retrieved, ok := app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1, retrieved)
_, ok = app.Plugin("non_existent")
assert.False(t, ok)
// Update existing plugin in-place
p1Updated := &appMockPlugin{name: "plugin1"}
app.Use(p1Updated, nil)
assert.Len(t, app.Plugins(), 2)
retrieved, ok = app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1Updated, retrieved)
// Test SetProfile
app.SetProfile(core.ProfileWorker)
assert.Equal(t, core.ProfileWorker, app.Profile())
}
func TestAppProfileDispatch(t *testing.T) {
tests := []struct {
name string
profile core.Profile
expectedHTTP bool
expectedWorker bool
expectedCron bool
expectedCustom bool
}{
{
name: "ProfileAPI only starts HTTP driver",
profile: core.ProfileAPI,
expectedHTTP: true,
expectedWorker: false,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileWorker only starts Worker driver",
profile: core.ProfileWorker,
expectedHTTP: false,
expectedWorker: true,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileSchedule only starts Schedule driver",
profile: core.ProfileSchedule,
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "Profile 'scheduler' alias starts Schedule driver",
profile: core.Profile("scheduler"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "ProfileAll starts all drivers",
profile: core.ProfileAll,
expectedHTTP: true,
expectedWorker: true,
expectedCron: true,
expectedCustom: true,
},
{
name: "Custom profile starts custom driver",
profile: core.Profile("custom_rpc"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: false,
expectedCustom: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
cronD := newAppMockDriver(core.DriverTypeScheduler)
customD := newAppMockDriver(core.DriverType("custom_rpc"))
p := &appMockPlugin{
name: "drivers_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
_ = ctx.RegisterDriver(cronD)
_ = ctx.RegisterDriver(customD)
return nil
},
}
app := core.NewApp(
core.WithProfile(tt.profile),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
err = app.Stop(context.Background())
require.NoError(t, err)
})
}
}
func TestAppLifecycleStartStop(t *testing.T) {
var stopOrder []string
var stopOrderMu sync.Mutex
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
httpD.stopErr = nil
workerD.stopErr = nil
// Wrap stop to record order
origHttpStop := httpD.Stop
_ = origHttpStop
p := &appMockPlugin{
name: "test_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
ctx.OnDispose(func() error {
stopOrderMu.Lock()
stopOrder = append(stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
return nil
})
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
var readyReceived, stoppingReceived, stoppedReceived bool
app.Context().Events().On("app:ready", func() {
readyReceived = true
})
app.Context().Events().On("app:stopping", func() {
stoppingReceived = true
})
app.Context().Events().On("app:stopped", func() {
stoppedReceived = true
})
err := app.Start(context.Background())
require.NoError(t, err)
assert.True(t, app.IsRunning())
assert.Len(t, app.StartedDrivers(), 2)
assert.True(t, readyReceived)
err = app.Stop(context.Background())
require.NoError(t, err)
assert.False(t, app.IsRunning())
assert.Empty(t, app.StartedDrivers())
assert.True(t, stoppingReceived)
assert.True(t, stoppedReceived)
assert.True(t, httpD.isStopped())
assert.True(t, workerD.isStopped())
assert.True(t, app.Context().IsDisposed())
stopOrderMu.Lock()
assert.Contains(t, stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
}
func TestAppStartDriverFailureRollback(t *testing.T) {
driver1 := newAppMockDriver(core.DriverTypeHTTP)
driver2 := newAppMockDriver(core.DriverTypeWorker)
driver2.startErr = errors.New("worker listen port conflict")
driver3 := newAppMockDriver(core.DriverTypeScheduler)
p := &appMockPlugin{
name: "fail_driver_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(driver1)
_ = ctx.RegisterDriver(driver2)
_ = ctx.RegisterDriver(driver3)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "worker listen port conflict")
assert.False(t, app.IsRunning())
// Driver 1 was started then rolled back (stopped)
assert.True(t, driver1.isStarted())
assert.True(t, driver1.isStopped())
// Driver 3 was never started
assert.False(t, driver3.isStarted())
}
func TestAppMigrationEngineExecution(t *testing.T) {
var migratedEntries []extpoints.MigrationEntry
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
migratedEntries = entries
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
}
p := &appMockPlugin{
name: "auth",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("auth", sqlFS)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
core.WithMigrationRunner(runner),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
require.Len(t, migratedEntries, 1)
assert.Equal(t, "auth", migratedEntries[0].PluginID)
}
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
var executed bool
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
executed = true
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
}
p := &appMockPlugin{
name: "logstore",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("logstore", sqlFS)
core.Provide[core.MigrationEngine](ctx, runner)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
assert.True(t, executed)
}
func TestAppRunContextCancellation(t *testing.T) {
d := newAppMockDriver(core.DriverTypeHTTP)
p := &appMockPlugin{
name: "http_plugin",
applyFn: func(ctx *core.Context) error {
return ctx.RegisterDriver(d)
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p),
core.WithShutdownTimeout(1*time.Second),
)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- app.Run(ctx)
}()
// Wait for app and driver to become ready
assert.Eventually(t, func() bool {
return app.IsRunning() && d.isStarted()
}, 2*time.Second, 10*time.Millisecond)
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
assert.False(t, app.IsRunning())
assert.True(t, d.isStopped())
case <-time.After(3 * time.Second):
t.Fatal("app.Run did not terminate upon context cancellation")
}
}
func TestAppExecuteCLI(t *testing.T) {
// Test CLI argument parsing logic
tests := []struct {
args []string
expectedProfile core.Profile
}{
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
{args: []string{"all"}, expectedProfile: core.ProfileAll},
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
}
for _, tt := range tests {
t.Run(tt.args[0], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// Use custom root context to control cancellation
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
_ = customApp.ExecuteCLI(tt.args...)
assert.Equal(t, tt.expectedProfile, customApp.Profile())
})
}
}
func TestAppIdempotencyAndErrorStates(t *testing.T) {
app := core.NewApp()
// Double start returns error
err := app.Start(context.Background())
require.NoError(t, err)
err = app.Start(context.Background())
assert.ErrorIs(t, err, core.ErrAppRunning)
// Stop clears running state
err = app.Stop(context.Background())
require.NoError(t, err)
// Double stop succeeds
err = app.Stop(context.Background())
require.NoError(t, err)
// Plugin apply failure
failPlugin := &appMockPlugin{
name: "failing_plugin",
applyFn: func(ctx *core.Context) error {
return errors.New("plugin init boom")
},
}
app2 := core.NewApp(core.WithPlugins(failPlugin))
err = app2.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "plugin init boom")
assert.False(t, app2.IsRunning())
// Migration failure
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
return errors.New("sql migrate error")
})
sqlFS := fstest.MapFS{
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
}
migPlugin := &appMockPlugin{
name: "db_plugin",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("db_plugin", sqlFS)
return nil
},
}
app3 := core.NewApp(
core.WithPlugins(migPlugin),
core.WithMigrationRunner(migFailRunner),
)
err = app3.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "sql migrate error")
assert.False(t, app3.IsRunning())
}
// newGateSource builds a configuration source whose only key decides the test gates.
func newGateSource(enabled bool) *mapSource {
return &mapSource{
values: map[string]any{"gate.enabled": enabled},
env: map[string]string{},
}
}
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
primary := &gatedPlugin{name: "cache", enabled: true}
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
app.Use(primary, fallback)
require.NoError(t, app.Prepare())
cacheFiber, ok := app.Fiber("cache")
require.True(t, ok)
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
assert.True(t, app.Context().Config().Resolved())
require.NoError(t, app.Reconcile())
assert.Equal(t, core.FiberActive, cacheFiber.State())
memoryFiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
}
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
require.NoError(t, app.Prepare())
late := &gatedPlugin{name: "cache_memory", enabled: false}
app.Use(late)
require.NoError(t, app.Reconcile())
fiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"plugins mounted after Prepare must still be gated")
}
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
app.Use(&gatedPlugin{name: "cache", enabled: true})
require.NoError(t, app.ApplyPlugins())
fiber, ok := app.Fiber("cache")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"ApplyPlugins must resolve and gate without an explicit Prepare call")
}
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
src := &mapSource{
values: map[string]any{"gate.enabled": "yes"},
env: map[string]string{},
}
app := core.NewApp(core.WithConfigSource(src))
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.Prepare()
require.Error(t, err)
assert.Contains(t, err.Error(), "gate.enabled")
}
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
app := core.NewApp()
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.ApplyPlugins()
require.Error(t, err)
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "ConfigSource")
}
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
app := core.NewApp()
app.SetShutdownTimeout(0)
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
func TestWithMigrationBaselineVisibleAfterPrepare(t *testing.T) {
var called bool
fn := func(*core.Context) error {
called = true
return nil
}
app := core.NewApp(core.WithMigrationBaseline(fn))
require.Nil(t, app.Context().MigrationBaseline(), "baseline must be copied during Prepare")
require.NoError(t, app.Prepare())
got := app.Context().MigrationBaseline()
require.NotNil(t, got, "Prepare must copy the baseline onto the root Context")
require.NoError(t, got(app.Context()))
assert.True(t, called)
}
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
var order []string
engine := core.MigrationRunner(func(ctx *core.Context, _ []extpoints.MigrationEntry) error {
order = append(order, "engine")
if ctx.MigrationBaseline() == nil {
t.Fatal("baseline must be visible on context inside Migrate")
}
return ctx.MigrationBaseline()(ctx)
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("-- +goose Up\nSELECT 1;\n")},
}
app := core.NewApp(
core.WithMigrationEngine(engine),
core.WithMigrationBaseline(func(*core.Context) error {
order = append(order, "baseline")
return nil
}),
core.WithPlugins(&appMockPlugin{
name: "t",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("t", sqlFS)
return nil
},
}),
)
require.NoError(t, app.Start(context.Background()))
defer func() { _ = app.Stop(context.Background()) }()
assert.Equal(t, []string{"engine", "baseline"}, order)
}
func TestWithMigrationBaselineNilByDefault(t *testing.T) {
app := core.NewApp()
require.NoError(t, app.Prepare())
assert.Nil(t, app.Context().MigrationBaseline())
}
-43
View File
@@ -1,43 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import "context"
type appContextKey struct{}
// WithAppContext attaches the micro-kernel Context to a standard context.Context
// so request and worker handlers can Inject services without package-level setters.
func WithAppContext(ctx context.Context, app *Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if app == nil {
return ctx
}
return context.WithValue(ctx, appContextKey{}, app.Root())
}
// AppContext extracts the micro-kernel Context from ctx, if present.
func AppContext(ctx context.Context) *Context {
if ctx == nil {
return nil
}
if c, ok := ctx.(*Context); ok {
return c
}
app, _ := ctx.Value(appContextKey{}).(*Context)
return app
}
// InjectFrom resolves T from ctx when it carries a micro-kernel Context
// (*Context itself, or a value attached by WithAppContext).
func InjectFrom[T any](ctx context.Context) (T, error) {
var zero T
app := AppContext(ctx)
if app == nil {
return zero, ErrNilContext
}
return Inject[T](app)
}
-100
View File
@@ -1,100 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"strings"
"Wavelet/core/extpoints"
)
// ConfigGet reads one resolved configuration value with its declared type. It is the
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
// distinguish "unset" from "set to the zero value".
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
var zero T
if view == nil {
return zero, extpoints.ErrConfigNotResolved
}
raw, ok := view.Value(key)
if !ok {
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
}
value, ok := raw.(T)
if !ok {
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
}
return value, nil
}
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
type MapSource struct {
values map[string]any
env map[string]string
}
// NewMapSource creates a new MapSource with the provided key-value mappings.
func NewMapSource(values map[string]any) *MapSource {
vals := make(map[string]any, len(values))
for k, v := range values {
vals[k] = v
}
return &MapSource{
values: vals,
env: make(map[string]string),
}
}
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
func (m *MapSource) Lookup(path string) (any, bool) {
if m == nil || m.values == nil {
return nil, false
}
if v, ok := m.values[path]; ok {
return v, true
}
parts := strings.Split(path, ".")
var cur any = m.values
for _, part := range parts {
mCur, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = mCur[part]
if !ok {
return nil, false
}
}
return cur, true
}
// LookupEnv returns the environment variable value.
func (m *MapSource) LookupEnv(name string) (string, bool) {
if m == nil || m.env == nil {
return "", false
}
v, ok := m.env[name]
return v, ok
}
// SetEnv sets an environment variable for testing.
func (m *MapSource) SetEnv(name, value string) {
if m.env == nil {
m.env = make(map[string]string)
}
m.env[name] = value
}
// Describe describes the MapSource.
func (m *MapSource) Describe() string {
return "<map source>"
}
// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings.
func WithConfigValues(values map[string]any) AppOption {
return WithConfigSource(NewMapSource(values))
}
-92
View File
@@ -1,92 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core"
"Wavelet/core/extpoints"
)
// mapSource implements extpoints.ConfigSource over static maps.
type mapSource struct {
values map[string]any
env map[string]string
}
func (m *mapSource) Lookup(path string) (any, bool) {
v, ok := m.values[path]
return v, ok
}
func (m *mapSource) LookupEnv(name string) (string, bool) {
v, ok := m.env[name]
return v, ok
}
func (m *mapSource) Describe() string { return "map" }
type otelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
}
// newOtelRegistry declares the otel section against a source carrying the given file values.
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
t.Helper()
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
require.NoError(t, r.Resolve())
return r
}
func TestConfigGetReturnsDeclaredType(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
require.NoError(t, err)
assert.Equal(t, 0.25, rate)
}
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Empty(t, text)
}
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
view := newOtelRegistry(t, nil)
_, err := core.ConfigGet[float64](view, "otel.unregistered")
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
}
func TestConfigGetRejectsNilView(t *testing.T) {
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
}
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
ctx := core.NewContext(nil)
child := ctx.Fork()
require.NotNil(t, ctx.Config())
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
require.NoError(t, child.Config().Declare("cache",
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
declared := false
for _, entry := range ctx.Config().Entries() {
declared = declared || entry.Key == "otel.sampling_rate"
}
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
assert.False(t, ctx.Config().Resolved())
}
-265
View File
@@ -1,265 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions.
package core
import (
"errors"
"fmt"
"reflect"
"sync"
)
// Container manages service registration and resolution using Go reflection and generics.
type Container struct {
mu sync.RWMutex
parent *Container
services map[reflect.Type]any
interfaceCache map[reflect.Type]any
listeners map[reflect.Type][]func(any)
}
// NewContainer creates a new IoC container instance with an optional parent container.
func NewContainer(parent *Container) *Container {
return &Container{
parent: parent,
services: make(map[reflect.Type]any),
interfaceCache: make(map[reflect.Type]any),
listeners: make(map[reflect.Type][]func(any)),
}
}
func isNil(i any) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
default:
return false
}
}
func (c *Container) remove(targetType reflect.Type) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.services, targetType)
c.interfaceCache = make(map[reflect.Type]any)
}
// Provide registers a typed service implementation into the Context hierarchy's root IoC container.
func Provide[T any](ctx *Context, service T) {
if ctx == nil {
panic("core: nil context provided to Provide")
}
if isNil(service) {
panic("core: cannot provide nil service")
}
targetType := reflect.TypeFor[T]()
targetContainer := ctx.Root().Container()
targetContainer.provide(targetType, service)
ctx.OnDispose(func() error {
targetContainer.remove(targetType)
return nil
})
}
// ProvideScoped registers a typed service implementation strictly in the local Context container.
func ProvideScoped[T any](ctx *Context, service T) {
if ctx == nil {
panic("core: nil context provided to ProvideScoped")
}
if isNil(service) {
panic("core: cannot provide nil service")
}
targetType := reflect.TypeFor[T]()
targetContainer := ctx.Container()
targetContainer.provide(targetType, service)
ctx.OnDispose(func() error {
targetContainer.remove(targetType)
return nil
})
}
func (c *Container) provide(targetType reflect.Type, service any) {
c.mu.Lock()
c.services[targetType] = service
c.interfaceCache = make(map[reflect.Type]any)
// Collect any matching listeners to invoke outside the lock
var callbacks []func(any)
svcType := reflect.TypeOf(service)
for lType, cbs := range c.listeners {
if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) {
callbacks = append(callbacks, cbs...)
}
}
c.mu.Unlock()
for _, cb := range callbacks {
cb(service)
}
}
// Inject resolves a registered service of type T from the Context.
func Inject[T any](ctx *Context) (T, error) {
var zero T
if ctx == nil {
return zero, ErrNilContext
}
targetType := reflect.TypeFor[T]()
val, err := ctx.Container().resolve(targetType)
if err != nil {
return zero, err
}
typedVal, ok := val.(T)
if !ok {
return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType)
}
return typedVal, nil
}
func (c *Container) resolve(targetType reflect.Type) (any, error) {
c.mu.RLock()
// 1. Direct type match
if val, ok := c.services[targetType]; ok {
c.mu.RUnlock()
return val, nil
}
c.mu.RUnlock()
// 2. Interface assignment scan & cache
if targetType.Kind() == reflect.Interface {
if val, found := c.resolveInterface(targetType); found {
return val, nil
}
}
// 3. Fallback to parent container
if c.parent != nil {
return c.parent.resolve(targetType)
}
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
}
func (c *Container) resolveInterface(targetType reflect.Type) (any, bool) {
c.mu.RLock()
if val, ok := c.interfaceCache[targetType]; ok {
c.mu.RUnlock()
return val, true
}
var matched any
for _, val := range c.services {
if reflect.TypeOf(val).Implements(targetType) {
matched = val
break
}
}
c.mu.RUnlock()
if matched == nil {
return nil, false
}
c.mu.Lock()
if c.interfaceCache == nil {
c.interfaceCache = make(map[reflect.Type]any)
}
c.interfaceCache[targetType] = matched
c.mu.Unlock()
return matched, true
}
// MustInject resolves a service of type T or panics if the service is not found.
func MustInject[T any](ctx *Context) T {
s, err := Inject[T](ctx)
if err != nil {
panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err))
}
return s
}
// Has returns true if a service of type T is registered and resolvable in the Context.
func Has[T any](ctx *Context) bool {
_, err := Inject[T](ctx)
return err == nil
}
// Using executes the given function synchronously if the required dependency is ready.
func Using[T1 any](ctx *Context, fn func(s1 T1)) error {
s1, err := Inject[T1](ctx)
if err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1)
return nil
}
// Using2 executes the given function synchronously if both required dependencies are ready.
func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
if err := errors.Join(err1, err2); err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1, s2)
return nil
}
// Using3 executes the given function synchronously if all 3 required dependencies are ready.
func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
s3, err3 := Inject[T3](ctx)
if err := errors.Join(err1, err2, err3); err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1, s2, s3)
return nil
}
// When registers a reactive hook that is called immediately if T is already provided,
// or called as soon as T is provided in the future.
//
// Listeners are stored on the root container so they observe core.Provide, which
// always writes to the root. Registering on a Fiber child container would miss
// services provided by plugins that load later.
func When[T any](ctx *Context, fn func(s T)) {
if ctx == nil {
panic("core: nil context provided to When")
}
targetType := reflect.TypeFor[T]()
c := ctx.Root().Container()
// If already ready, execute immediately
if s, err := Inject[T](ctx); err == nil {
fn(s)
}
// Also register listener for future calls / updates
c.mu.Lock()
defer c.mu.Unlock()
c.listeners[targetType] = append(c.listeners[targetType], func(val any) {
if typed, ok := val.(T); ok {
fn(typed)
}
})
}
// Bind is When with a name that matches plugin wiring: fill a dependency as
// soon as the root container provides it.
func Bind[T any](ctx *Context, fn func(s T)) {
When(ctx, fn)
}
-416
View File
@@ -1,416 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"Wavelet/core/extpoints"
"context"
"errors"
"fmt"
"sync"
"time"
)
// Context is the central micro-kernel service bus and runtime lifecycle container.
// It embeds Go standard context.Context compatibility, hierarchical scoping,
// service resolution, and LIFO disposer teardown.
type Context struct {
goCtx context.Context
cancel context.CancelFunc
parent *Context
container *Container
events *EventBus
router extpoints.RouterExtension
migrations extpoints.MigrationExtension
tasks extpoints.TaskExtension
schedules extpoints.ScheduleExtension
settings extpoints.SettingExtension
config extpoints.ConfigExtension
mu sync.RWMutex
children []*Context
disposers []Disposer
drivers []Driver
values map[any]any
disposed bool
migrationBaseline func(*Context) error
}
// NewContext creates a new root Context wrapping a standard Go context.
// If base is nil, context.Background() is used by default.
//
//nolint:contextcheck
func NewContext(base context.Context) *Context {
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
return &Context{
goCtx: ctx,
cancel: cancel,
container: NewContainer(nil),
events: NewEventBus(),
router: extpoints.NewRouterRegistry(),
migrations: extpoints.NewMigrationRegistry(),
tasks: extpoints.NewTaskRegistry(),
schedules: extpoints.NewScheduleRegistry(),
settings: extpoints.NewSettingRegistry(),
config: extpoints.NewConfigRegistry(nil),
values: make(map[any]any),
}
}
// Deadline returns the time when work done on behalf of this context should be canceled.
func (c *Context) Deadline() (deadline time.Time, ok bool) {
return c.goCtx.Deadline()
}
// Done returns a channel that's closed when work done on behalf of this context should be canceled.
func (c *Context) Done() <-chan struct{} {
return c.goCtx.Done()
}
// Err returns a non-nil error value after Done is closed.
func (c *Context) Err() error {
return c.goCtx.Err()
}
// Value returns the value associated with key, searching the local values map,
// the underlying Go context, and fallback parent Contexts.
func (c *Context) Value(key any) any {
c.mu.RLock()
if v, ok := c.values[key]; ok {
c.mu.RUnlock()
return v
}
c.mu.RUnlock()
if v := c.goCtx.Value(key); v != nil {
return v
}
if c.parent != nil {
return c.parent.Value(key)
}
return nil
}
// GoContext returns the underlying standard Go context.Context.
func (c *Context) GoContext() context.Context {
return c.goCtx
}
// Set stores an arbitrary key-value pair in this Context's local storage.
func (c *Context) Set(key, val any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.values == nil {
c.values = make(map[any]any)
}
c.values[key] = val
}
// Get retrieves a key-value pair from this Context's local storage.
func (c *Context) Get(key any) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.values == nil {
return nil, false
}
v, ok := c.values[key]
return v, ok
}
// Container returns the underlying IoC container for this Context.
func (c *Context) Container() *Container {
return c.container
}
// Parent returns the parent Context, or nil if this is a root Context.
func (c *Context) Parent() *Context {
return c.parent
}
// Root returns the root Context in the hierarchy.
func (c *Context) Root() *Context {
curr := c
for curr.parent != nil {
curr = curr.parent
}
return curr
}
// Fork creates a child Context with its own scoped IoC container and values,
// linked to this Context for hierarchical fallback resolution and cascading teardown.
func (c *Context) Fork() *Context {
return c.ForkWithContext(c.goCtx)
}
// ForkWithContext creates a child Context using a specific standard Go context.
//
//nolint:contextcheck
func (c *Context) ForkWithContext(base context.Context) *Context {
if base == nil {
base = c.goCtx
}
ctx, cancel := context.WithCancel(base)
child := &Context{
goCtx: ctx,
cancel: cancel,
parent: c,
container: NewContainer(c.container),
events: c.events,
router: c.router,
migrations: c.migrations,
tasks: c.tasks,
schedules: c.schedules,
settings: c.settings,
config: c.config,
values: make(map[any]any),
migrationBaseline: c.MigrationBaseline(),
}
c.mu.Lock()
c.children = append(c.children, child)
c.mu.Unlock()
return child
}
// Events returns the domain EventBus associated with this Context hierarchy.
func (c *Context) Events() *EventBus {
return c.events
}
// On registers an event listener on the EventBus and automatically attaches its Disposer
// to this Context's teardown stack for automatic revocation when disposed.
func (c *Context) On(topic string, handler any) Disposer {
disposer := c.events.On(topic, handler)
c.OnDispose(disposer)
return disposer
}
// Effect registers a reversible side-effect cleanup callback on this Context.
func (c *Context) Effect(fn any) {
c.OnDispose(fn)
}
// Router returns the scoped RouterExtension registry with automatic disposer tracking.
func (c *Context) Router() extpoints.RouterExtension {
return newScopedRouterExtension(c, c.router)
}
// Migrations returns the MigrationExtension registry.
func (c *Context) Migrations() extpoints.MigrationExtension {
return c.migrations
}
// MigrationBaseline returns the hook copied onto this Context during App.Prepare.
// Child contexts fall back to their parent so forks still see the root hook.
func (c *Context) MigrationBaseline() func(*Context) error {
if c == nil {
return nil
}
c.mu.RLock()
fn := c.migrationBaseline
c.mu.RUnlock()
if fn != nil {
return fn
}
if c.parent != nil {
return c.parent.MigrationBaseline()
}
return nil
}
func (c *Context) setMigrationBaseline(fn func(*Context) error) {
if c == nil {
return
}
c.mu.Lock()
c.migrationBaseline = fn
c.mu.Unlock()
}
// Tasks returns the scoped TaskExtension registry with automatic disposer tracking.
func (c *Context) Tasks() extpoints.TaskExtension {
return newScopedTaskExtension(c, c.tasks)
}
// Task is an alias for Tasks().
func (c *Context) Task() extpoints.TaskExtension {
return c.Tasks()
}
// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking.
func (c *Context) Schedules() extpoints.ScheduleExtension {
return newScopedScheduleExtension(c, c.schedules)
}
// Schedule is an alias for Schedules().
func (c *Context) Schedule() extpoints.ScheduleExtension {
return c.Schedules()
}
// Settings returns the scoped SettingExtension registry with automatic disposer tracking.
func (c *Context) Settings() extpoints.SettingExtension {
return newScopedSettingExtension(c, c.settings)
}
// Setting is an alias for Settings().
func (c *Context) Setting() extpoints.SettingExtension {
return c.Settings()
}
// Config returns the process-level configuration extension point. The registry is
// shared by every fork because configuration declarations are global facts, and it
// carries no per-scope disposers: values are resolved once before Apply runs.
func (c *Context) Config() extpoints.ConfigExtension {
return c.config
}
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
// It accepts func() error, func(), or Disposer.
func (c *Context) OnDispose(fn any) {
if fn == nil {
return
}
var d Disposer
switch f := fn.(type) {
case Disposer:
d = f
case func() error:
d = f
case func():
d = func() error {
f()
return nil
}
default:
panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn))
}
c.mu.Lock()
defer c.mu.Unlock()
c.disposers = append(c.disposers, d)
}
// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order.
func (c *Context) Dispose() error {
c.mu.Lock()
if c.disposed {
c.mu.Unlock()
return nil
}
c.disposed = true
// Copy children and disposers under lock
children := make([]*Context, len(c.children))
copy(children, c.children)
disposers := make([]Disposer, len(c.disposers))
copy(disposers, c.disposers)
c.mu.Unlock()
var errs []error
// 1. Dispose all child contexts in reverse order
for i := len(children) - 1; i >= 0; i-- {
if err := children[i].Dispose(); err != nil {
errs = append(errs, err)
}
}
// 2. Run local disposers in LIFO order
for i := len(disposers) - 1; i >= 0; i-- {
if err := disposers[i](); err != nil {
errs = append(errs, err)
}
}
// 3. Cancel the Go context
if c.cancel != nil {
c.cancel()
}
// 4. Detach from parent
if c.parent != nil {
c.parent.removeChild(c)
}
return errors.Join(errs...)
}
func (c *Context) removeChild(target *Context) {
c.mu.Lock()
defer c.mu.Unlock()
for i, child := range c.children {
if child == target {
c.children = append(c.children[:i], c.children[i+1:]...)
break
}
}
}
// IsDisposed returns true if this Context has been disposed.
func (c *Context) IsDisposed() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.disposed
}
// RegisterDriver registers a runtime driver engine on this Context hierarchy.
func (c *Context) RegisterDriver(d Driver) error {
if d == nil {
return ErrNilService
}
root := c.Root()
root.mu.Lock()
root.drivers = append(root.drivers, d)
root.mu.Unlock()
c.OnDispose(func() error {
root.mu.Lock()
defer root.mu.Unlock()
for i, drv := range root.drivers {
if drv == d {
root.drivers = append(root.drivers[:i], root.drivers[i+1:]...)
break
}
}
return nil
})
return nil
}
// Drivers returns a copy of all drivers registered on this Context.
func (c *Context) Drivers() []Driver {
root := c.Root()
root.mu.RLock()
defer root.mu.RUnlock()
result := make([]Driver, len(root.drivers))
copy(result, root.drivers)
return result
}
// Driver looks up a registered driver by its driver type.
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
root := c.Root()
root.mu.RLock()
defer root.mu.RUnlock()
for _, d := range root.drivers {
if d.Type() == driverType {
return d, true
}
}
return nil, false
}
-646
View File
@@ -1,646 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Sample services for testing
type SampleService interface {
Greet(name string) string
}
type sampleServiceImpl struct {
prefix string
}
func (s *sampleServiceImpl) Greet(name string) string {
if s.prefix != "" {
return s.prefix + " " + name
}
return "Hello, " + name
}
type LogService interface {
Log(msg string)
}
type logServiceImpl struct {
logs []string
}
func (l *logServiceImpl) Log(msg string) {
l.logs = append(l.logs, msg)
}
type ConfigService interface {
Get(key string) string
}
type configServiceImpl struct {
data map[string]string
}
func (c *configServiceImpl) Get(key string) string {
return c.data[key]
}
// Sample plugin for testing
type samplePlugin struct {
name string
}
func (p *samplePlugin) Name() string {
return p.name
}
func (p *samplePlugin) Apply(ctx *core.Context) error {
core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"})
return nil
}
func (p *samplePlugin) Manifest() core.Manifest {
return core.Manifest{
Name: p.name,
Version: "1.0.0",
Description: "Sample plugin",
}
}
// Sample driver for testing
type mockDriver struct {
driverType core.DriverType
started bool
stopped bool
}
func (m *mockDriver) Type() core.DriverType {
return m.driverType
}
func (m *mockDriver) Start(ctx context.Context) error {
m.started = true
return nil
}
func (m *mockDriver) Stop(ctx context.Context) error {
m.stopped = true
return nil
}
func TestContextProvideAndInject(t *testing.T) {
ctx := core.NewContext(context.Background())
// Before providing, Inject should fail
_, err := core.Inject[SampleService](ctx)
require.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotFound))
assert.False(t, core.Has[SampleService](ctx))
// MustInject should panic
assert.Panics(t, func() {
core.MustInject[SampleService](ctx)
})
// Provide service
svcImpl := &sampleServiceImpl{prefix: "Hello,"}
core.Provide[SampleService](ctx, svcImpl)
// Inject should succeed
assert.True(t, core.Has[SampleService](ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet"))
// MustInject should succeed
mustSvc := core.MustInject[SampleService](ctx)
assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis"))
}
func TestContextProvideNilPanics(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.Provide[SampleService](nil, &sampleServiceImpl{})
})
assert.Panics(t, func() {
var nilSvc SampleService
core.Provide[SampleService](ctx, nilSvc)
})
assert.Panics(t, func() {
var nilImpl *sampleServiceImpl
core.Provide[*sampleServiceImpl](ctx, nilImpl)
})
// Inject with nil context
var nilCtx *core.Context
_, err := core.Inject[SampleService](nilCtx)
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextUsing(t *testing.T) {
ctx := core.NewContext(context.Background())
var called bool
// Using when service not ready should return ErrServiceNotReady
err := core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotReady))
assert.False(t, called)
// Provide service and try Using again
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.NoError(t, err)
assert.True(t, called)
}
func TestContextUsingMultiple(t *testing.T) {
ctx := core.NewContext(context.Background())
// Using2 with missing dependencies
var called2 bool
err := core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 1 of 2
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 2 of 2
logSvc := &logServiceImpl{}
core.Provide[LogService](ctx, logSvc)
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
l.Log(s.Greet("World"))
})
assert.NoError(t, err)
assert.True(t, called2)
assert.Equal(t, []string{"Hello, World"}, logSvc.logs)
// Using3 test - error condition
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {})
assert.Error(t, err)
// Using3 test - success condition
var called3 bool
cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}}
core.Provide[ConfigService](ctx, cfgSvc)
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
called3 = true
assert.Equal(t, "test", c.Get("env"))
})
assert.NoError(t, err)
assert.True(t, called3)
}
// UsingN must keep every dependency failure reachable through the error chain,
// not just report that something went wrong.
func TestContextUsingMultipleErrorChain(t *testing.T) {
ctx := core.NewContext(context.Background())
err := core.Using2(ctx, func(s SampleService, l LogService) {
t.Fatal("callback must not run when dependencies are missing")
})
require.Error(t, err)
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
// Only LogService is missing now, so exactly one joined cause must be present.
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using2(ctx, func(s SampleService, l LogService) {
t.Fatal("callback must not run when a dependency is missing")
})
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
t.Fatal("callback must not run when a dependency is missing")
})
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
}
func TestContextHierarchyAndFork(t *testing.T) {
parent := core.NewContext(nil) // nil base context test
core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"})
child := parent.ForkWithContext(nil) // nil child context test
require.NotNil(t, child)
assert.Equal(t, parent, child.Parent())
// Child can resolve service from parent
svc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan"))
// Child provides LogService
childLog := &logServiceImpl{}
core.ProvideScoped[LogService](child, childLog)
// Child has LogService, parent does not
assert.True(t, core.Has[LogService](child))
assert.False(t, core.Has[LogService](parent))
// Child overrides SampleService locally
core.ProvideScoped[SampleService](child, &sampleServiceImpl{prefix: "Child:"})
childSvc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan"))
parentSvc, err := core.Inject[SampleService](parent)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan"))
}
func TestContextReactiveWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.When[SampleService](nil, func(s SampleService) {})
})
var whenCalled atomic.Bool
var greeted string
// Register When before service is provided
core.When[SampleService](ctx, func(s SampleService) {
whenCalled.Store(true)
greeted = s.Greet("Reactive")
})
assert.False(t, whenCalled.Load())
// Now Provide the service - listener should trigger
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, whenCalled.Load())
assert.Equal(t, "Hello, Reactive", greeted)
// Register another When after service is already provided - should trigger immediately
var immediateCalled bool
core.When[SampleService](ctx, func(s SampleService) {
immediateCalled = true
})
assert.True(t, immediateCalled)
}
func TestWhenObservesProvideFromForkedFiberContext(t *testing.T) {
root := core.NewContext(context.Background())
adminFiber := root.Fork()
lateFiber := root.Fork()
var got atomic.Bool
core.When[SampleService](adminFiber, func(s SampleService) {
if s != nil {
got.Store(true)
}
})
assert.False(t, got.Load())
core.Provide[SampleService](lateFiber, &sampleServiceImpl{})
assert.True(t, got.Load(), "When on a Fiber child must observe Provide on the root")
}
func TestBindIsWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
var called atomic.Bool
core.Bind[SampleService](ctx, func(s SampleService) {
called.Store(true)
})
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, called.Load())
}
func TestInjectFromAppContext(t *testing.T) {
app := core.NewContext(context.Background())
core.Provide[SampleService](app, &sampleServiceImpl{prefix: "Hi:"})
req := core.WithAppContext(context.Background(), app)
svc, err := core.InjectFrom[SampleService](req)
require.NoError(t, err)
assert.Equal(t, "Hi: Ada", svc.Greet("Ada"))
_, err = core.InjectFrom[SampleService](context.Background())
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextDisposerLifecycle(t *testing.T) {
parent := core.NewContext(context.Background())
child := parent.Fork()
var order []string
// Test nil disposer
parent.OnDispose(nil)
// Test Disposer type
var customDisposer core.Disposer = func() error {
order = append(order, "parent-custom")
return nil
}
parent.OnDispose(customDisposer)
parent.OnDispose(func() error {
order = append(order, "parent-1")
return nil
})
parent.OnDispose(func() {
order = append(order, "parent-2")
})
child.OnDispose(func() error {
order = append(order, "child-1")
return errors.New("child-1 error")
})
child.OnDispose(func() {
order = append(order, "child-2")
})
assert.Panics(t, func() {
parent.OnDispose("invalid-func")
})
assert.False(t, parent.IsDisposed())
assert.False(t, child.IsDisposed())
// Disposing parent should cascade to children first, and execute disposers in LIFO order
err := parent.Dispose()
assert.Error(t, err) // child-1 error should be joined
assert.Contains(t, err.Error(), "child-1 error")
assert.True(t, parent.IsDisposed())
assert.True(t, child.IsDisposed())
// Child disposers run in LIFO: child-2, child-1
// Parent disposers run in LIFO: parent-2, parent-1, parent-custom
expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"}
assert.Equal(t, expected, order)
// Disposing again should be idempotent and return nil
err = parent.Dispose()
assert.NoError(t, err)
}
func TestContextStandardGoContext(t *testing.T) {
baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()
parentCtx := core.NewContext(baseCtx)
parentCtx.Set("parent_key", "parent_val")
childCtx := parentCtx.Fork()
// Deadline
dl, ok := childCtx.Deadline()
assert.True(t, ok)
assert.False(t, dl.IsZero())
// Value fallback: child has no key, falls back to parentCtx
assert.Equal(t, "parent_val", childCtx.Value("parent_key"))
// GoContext getter
assert.NotNil(t, childCtx.GoContext())
// Value not found in either
assert.Nil(t, childCtx.Value("non_existent_key"))
// Cancellation propagation
select {
case <-childCtx.Done():
t.Fatal("ctx should not be done yet")
default:
}
cancel()
select {
case <-childCtx.Done():
assert.Equal(t, context.Canceled, childCtx.Err())
case <-time.After(100 * time.Millisecond):
t.Fatal("ctx should be cancelled")
}
}
func TestManifestValidation(t *testing.T) {
mValid := core.Manifest{
Name: "auth",
Version: "1.0.0",
Description: "Auth plugin",
}
assert.NoError(t, mValid.Validate())
mInvalid := core.Manifest{
Version: "1.0.0",
}
assert.Error(t, mInvalid.Validate())
}
func TestDriverRegistration(t *testing.T) {
ctx := core.NewContext(context.Background())
// Register nil driver returns error
assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService)
dHTTP := &mockDriver{driverType: core.DriverTypeHTTP}
dWorker := &mockDriver{driverType: core.DriverTypeWorker}
require.NoError(t, ctx.RegisterDriver(dHTTP))
require.NoError(t, ctx.RegisterDriver(dWorker))
drivers := ctx.Drivers()
assert.Len(t, drivers, 2)
foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP)
assert.True(t, ok)
assert.Equal(t, dHTTP, foundHTTP)
foundWorker, ok := ctx.Driver(core.DriverTypeWorker)
assert.True(t, ok)
assert.Equal(t, dWorker, foundWorker)
_, ok = ctx.Driver(core.DriverTypeScheduler)
assert.False(t, ok)
}
func TestPluginInterfaces(t *testing.T) {
ctx := core.NewContext(context.Background())
var p core.Plugin = &samplePlugin{name: "sample"}
assert.Equal(t, "sample", p.Name())
require.NoError(t, p.Apply(ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan"))
var pwm core.PluginWithManifest = &samplePlugin{name: "sample"}
manifest := pwm.Manifest()
assert.Equal(t, "sample", manifest.Name)
assert.Equal(t, "1.0.0", manifest.Version)
}
func TestConcurrentAccess(t *testing.T) {
ctx := core.NewContext(context.Background())
var wg sync.WaitGroup
// Concurrently provide, inject, fork, set, and get
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx.Set(fmt.Sprintf("key-%d", idx), idx)
_, _ = ctx.Get(fmt.Sprintf("key-%d", idx))
child := ctx.Fork()
child.Set("child_key", idx)
}(i)
}
core.Provide[SampleService](ctx, &sampleServiceImpl{})
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc, err := core.Inject[SampleService](ctx)
if err == nil {
_ = svc.Greet("Concurrency")
}
_ = core.Using(ctx, func(s SampleService) {
_ = s.Greet("Safe")
})
}()
}
wg.Wait()
}
func TestContextExtensionPointsAccessors(t *testing.T) {
ctx := core.NewContext(nil)
assert.NotNil(t, ctx.Events())
assert.NotNil(t, ctx.Router())
assert.NotNil(t, ctx.Migrations())
assert.NotNil(t, ctx.Tasks())
assert.NotNil(t, ctx.Task())
assert.NotNil(t, ctx.Schedules())
assert.NotNil(t, ctx.Schedule())
assert.NotNil(t, ctx.Settings())
assert.NotNil(t, ctx.Setting())
child := ctx.Fork()
assert.Equal(t, ctx.Events(), child.Events())
assert.Equal(t, ctx.Migrations(), child.Migrations())
assert.NotNil(t, child.Router())
assert.NotNil(t, child.Tasks())
assert.NotNil(t, child.Task())
assert.NotNil(t, child.Schedules())
assert.NotNil(t, child.Schedule())
assert.NotNil(t, child.Settings())
assert.NotNil(t, child.Setting())
}
func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
root := core.NewContext(context.Background())
child := root.Fork()
// Register route, task, schedule, setting, event, middleware, whitelist on child
child.Router().GET("/test-route", func() {})
assert.Equal(t, 1, len(root.Router().Routes()))
child.Router().Use("scoped_middleware")
assert.Equal(t, 1, len(root.Router().Middlewares()))
child.Router().RegisterWhitelist("/api/v1/scoped/*")
assert.True(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
child.Tasks().Register("test:task", func() {})
assert.Equal(t, 1, len(root.Tasks().Tasks()))
child.Schedules().RegisterCron("@hourly", "test:cron", nil)
assert.Equal(t, 1, len(root.Schedules().Schedules()))
child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"})
assert.Equal(t, 1, len(root.Settings().Schemas()))
child.On("test:event", func() {})
assert.Equal(t, 1, root.Events().Listeners("test:event"))
// Dispose child
err := child.Dispose()
assert.NoError(t, err)
// All child effects should be cleanly revoked in LIFO order
assert.Equal(t, 0, len(root.Router().Routes()))
assert.Equal(t, 0, len(root.Router().Middlewares()))
assert.False(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
assert.Equal(t, 0, len(root.Tasks().Tasks()))
assert.Equal(t, 0, len(root.Schedules().Schedules()))
assert.Equal(t, 0, len(root.Settings().Schemas()))
assert.Equal(t, 0, root.Events().Listeners("test:event"))
}
func TestContainer_InterfaceResolutionCache(t *testing.T) {
ctx := core.NewContext(context.Background())
svc := &sampleServiceImpl{prefix: "Cached:"}
core.Provide[SampleService](ctx, svc)
// 1. Initial resolution populates interfaceCache
res1, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Cached: Alice", res1.Greet("Alice"))
// 2. Subsequent resolutions hit interfaceCache
res2, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Same(t, res1, res2)
// 3. Concurrent lookups
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r, e := core.Inject[SampleService](ctx)
assert.NoError(t, e)
assert.Equal(t, "Cached: Bob", r.Greet("Bob"))
}()
}
wg.Wait()
// 4. Overriding/providing another service invalidates cache
svc2 := &sampleServiceImpl{prefix: "Updated:"}
core.Provide[SampleService](ctx, svc2)
res3, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Updated: Alice", res3.Greet("Alice"))
}
-143
View File
@@ -1,143 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// UserDTO represents a unified user data transfer object across plugins.
type UserDTO struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Gender string `json:"gender,omitempty"`
Website string `json:"website,omitempty"`
Location string `json:"location,omitempty"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
type OAuthUserInfoDTO struct {
ID uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarURL string `json:"avatar_url"`
}
// AuthSourceDTO represents an OAuth / OIDC authentication source.
type AuthSourceDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display.
type AuthSourceViewDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthProvider defines the pluggable OAuth provider contract.
type OAuthProvider interface {
Name() string
GetAuthURL(state string) string
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
}
// AuthService defines the contract for authentication, session verification, and token management.
type AuthService interface {
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
RequireAuthMiddleware() any
// RequireAdminMiddleware returns an admin authorization middleware.
RequireAdminMiddleware() any
// GetCurrentUser retrieves the authenticated UserDTO from context.
GetCurrentUser(ctx context.Context) (*UserDTO, error)
// GetCurrentUserID retrieves the authenticated user ID from session/context.
GetCurrentUserID(ctx context.Context) (uint64, error)
// VerifyToken validates an access token and returns the associated user DTO.
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
// CreateSession establishes an authenticated session for the given user ID.
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
// RevokeToken invalidates a specific access token by its hash.
RevokeToken(ctx context.Context, tokenHash string) error
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
RevokeUserSessions(ctx context.Context, userID uint64) error
// InvalidateCachedUser invalidates cached user profile data.
InvalidateCachedUser(ctx context.Context, userID uint64)
// InvalidateCachedToken invalidates cached access token data.
InvalidateCachedToken(ctx context.Context, tokenHash string)
// ListAuthSources lists all configured authentication sources.
ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error)
// CreateAuthSource creates a new authentication source.
CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error)
// UpdateAuthSource updates an authentication source.
UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error)
// DeleteAuthSource removes an authentication source.
DeleteAuthSource(ctx context.Context, id uint64) error
// ToggleAuthSource toggles the active state of an authentication source.
ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, error)
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
DisallowTokenAuthMiddleware() any
}
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
type AuthRegistry interface {
RegisterOAuthProvider(name string, provider OAuthProvider)
GetOAuthProvider(name string) (OAuthProvider, bool)
ListOAuthProviders() []string
}
// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins.
const (
AuthUserIDKey = "user_id"
AuthUserNameKey = "username"
AuthUserObjKey = "user_obj"
AuthTokenAuthKey = "token_auth" // marks if request uses access token auth
AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges
)
-32
View File
@@ -1,32 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"errors"
"time"
)
// ErrCacheMiss is returned when an item is not found in the cache.
var ErrCacheMiss = errors.New("contracts/cache: key not found")
// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation).
type CacheService interface {
// Get retrieves an item from cache into target. Returns ErrCacheMiss if not found.
Get(ctx context.Context, key string, target any) error
// Set stores an item into cache with a specified time-to-live duration.
Set(ctx context.Context, key string, value any, ttl time.Duration) error
// Delete evicts a key from local and remote cache tiers and broadcasts invalidation.
Delete(ctx context.Context, key string) error
// GetOrSet retrieves an item from cache, or calls loader to populate and return if missing.
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
// Invalidate is a semantic alias for Delete.
Invalidate(ctx context.Context, key string) error
}
-12
View File
@@ -1,12 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
// CaptchaService defines the contract for CAPTCHA challenge issuance,
// redemption, and scoped verification middleware.
type CaptchaService interface {
VerifyMiddleware(scope string) any
ChallengeHandler() any
RedeemHandler() any
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import (
"context"
"time"
)
// SystemConfigDTO represents a system configuration key-value entry.
type SystemConfigDTO struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
Visibility int `json:"visibility"`
Description string `json:"description"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// SystemConfigService defines the unified contract for querying and mutating system configurations.
type SystemConfigService interface {
GetByKey(ctx context.Context, key string) (SystemConfigDTO, error)
ListByKeys(ctx context.Context, keys []string) (map[string]SystemConfigDTO, error)
ListVisible(ctx context.Context) ([]SystemConfigDTO, error)
ListByType(ctx context.Context, configType string) ([]SystemConfigDTO, error)
GetIntByKey(ctx context.Context, key string) (int, error)
GetBoolByKey(ctx context.Context, key string) (bool, error)
SaveOrUpdate(ctx context.Context, key, value string) error
InvalidateCache(ctx context.Context, key string) error
InvalidateAllCaches(ctx context.Context) error
}
-14
View File
@@ -1,14 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import "context"
// PublicConfigProvider supplies GET /api/v1/config/public.
// The owner of w_system_configs (admin) must provide this. The payload is a
// flat key/value map of visibility=1 rows; the frontend reads keys such as
// cap_login_enabled directly off data.
type PublicConfigProvider interface {
PublicConfig(ctx context.Context) (map[string]string, error)
}
-23
View File
@@ -1,23 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"gorm.io/gorm"
)
// DBService defines the standard contract for relational database access and multi-datasource routing.
type DBService interface {
// GORM returns the underlying GORM database instance.
GORM() *gorm.DB
// DB returns the GORM database instance bound to the given context.
DB(ctx context.Context) *gorm.DB
// Named returns a named database connection if multiple data sources or replicas are configured.
Named(name string) *gorm.DB
}
-158
View File
@@ -1,158 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
// ======================================================================
// Domain Event Topic Constants
// ======================================================================
//
// All cross-plugin domain event topics MUST be declared here so that
// producers and consumers share the same string values without importing
// each other's implementation packages.
// ======================================================================
// --- Auth & User Events ---
const (
// EventTopicAdminLoggedIn fires when an admin user logs in.
EventTopicAdminLoggedIn = "admin:logged_in"
// EventTopicUserCreated fires when a new user account is created.
EventTopicUserCreated = "user:created"
// EventTopicUserUpdated fires when a user profile is updated.
EventTopicUserUpdated = "user:updated"
// EventTopicUserDeleted fires when a user account is deleted.
EventTopicUserDeleted = "user:deleted"
// EventTopicUserStatusChanged fires when a user account active status changes.
EventTopicUserStatusChanged = "user:status_changed"
// EventTopicTokenRevoked fires when an access token is revoked.
// #nosec G101
EventTopicTokenRevoked = "auth:token_revoked"
)
// --- Admin & System Events ---
const (
// EventTopicConfigChanged fires when a system configuration value changes.
EventTopicConfigChanged = "admin:config_changed"
// EventTopicSystemCleanup fires when a periodic system cleanup completes.
EventTopicSystemCleanup = "admin:system_cleanup"
)
// --- Task Events ---
const (
// EventTopicTaskCompleted fires when an asynchronous background task execution finishes.
EventTopicTaskCompleted = "task:completed"
)
// TaskCompletedEvent carries task execution outcome details.
type TaskCompletedEvent struct {
TaskID string `json:"task_id"`
TaskName string `json:"task_name"`
TaskType string `json:"task_type"`
Status string `json:"status"`
Duration int64 `json:"duration"`
ErrorMsg string `json:"error_msg,omitempty"`
ResultMsg string `json:"result_msg,omitempty"`
Payload string `json:"payload,omitempty"`
Detail string `json:"detail,omitempty"`
}
// --- Upload / Storage Events ---
const (
// EventTopicUploadCreated fires when a new file upload is recorded.
EventTopicUploadCreated = "upload:created"
// EventTopicUploadDeleted fires when a file upload is removed.
EventTopicUploadDeleted = "upload:deleted"
// EventTopicIngestComplete fires when a programmatic file ingest finishes.
EventTopicIngestComplete = "upload:ingest_complete"
)
// --- Message Gateway Events ---
const (
// EventTopicNotificationSent fires when a push notification is dispatched.
EventTopicNotificationSent = "message:notification_sent"
// EventTopicChannelBound fires when a user binds a messaging channel.
EventTopicChannelBound = "message:channel_bound"
// EventTopicChannelUnbound fires when a user unbinds a messaging channel.
EventTopicChannelUnbound = "message:channel_unbound"
)
// --- Risk Control Events ---
const (
// EventTopicAccessLogRecorded fires when a user access log entry is recorded.
EventTopicAccessLogRecorded = "risk:access_log_recorded"
)
// ======================================================================
// Domain Event Payload DTOs
// ======================================================================
// AdminLoggedIn 管理员登录领域事件载荷
type AdminLoggedIn struct {
User *UserDTO `json:"user"`
IP string `json:"ip"`
}
// UserCreatedEvent fires when a new user account is created.
type UserCreatedEvent struct {
User *UserDTO `json:"user"`
Password string `json:"-"`
}
// ConfigChangedEvent fires when a system configuration value changes.
type ConfigChangedEvent struct {
Key string `json:"key"`
OldVal any `json:"old_val,omitempty"`
NewVal any `json:"new_val,omitempty"`
}
// UploadCreatedEvent fires when a new file upload is recorded.
type UploadCreatedEvent struct {
UploadID uint64 `json:"upload_id,string"`
UserID uint64 `json:"user_id,string"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
// NotificationSentEvent fires when a push notification is dispatched.
type NotificationSentEvent struct {
UserID uint64 `json:"user_id,string"`
Channel string `json:"channel"`
Title string `json:"title"`
Success bool `json:"success"`
ErrorInfo string `json:"error_info,omitempty"`
}
// UserStatusChangedEvent fires when a user status is enabled/disabled.
type UserStatusChangedEvent struct {
UserID uint64 `json:"user_id,string"`
IsActive bool `json:"is_active"`
}
// TokenRevokedEvent fires when an access token is revoked.
type TokenRevokedEvent struct {
UserID uint64 `json:"user_id,string"`
TokenHash string `json:"token_hash"`
}
// UserDeletedEvent fires when a user account is deleted.
type UserDeletedEvent struct {
CurrentUserID uint64 `json:"current_user_id,string"`
TargetUserID uint64 `json:"target_user_id,string"`
}
// SystemCleanupEvent fires when a periodic system cleanup is triggered.
type SystemCleanupEvent struct {
TriggeredAt string `json:"triggered_at"`
}
-36
View File
@@ -1,36 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// Rate specifies a rate limit of Limit events permitted within a Period.
type Rate struct {
Limit int `json:"limit"`
Period time.Duration `json:"period"`
}
// RateLimitResult holds the outcome of a rate limit check.
type RateLimitResult struct {
Allowed bool `json:"allowed"`
Remaining int `json:"remaining"`
ResetAfter time.Duration `json:"reset_after"`
RetryAfter time.Duration `json:"retry_after"`
}
// LimiterService defines the rate limiting service contract for cross-plugin communication.
type LimiterService interface {
// Allow checks whether 1 event for the given key is permitted under the specified rate.
Allow(ctx context.Context, key string, rate Rate) (*RateLimitResult, error)
// AllowN checks whether n events for the given key are permitted under the specified rate.
AllowN(ctx context.Context, key string, rate Rate, n int) (*RateLimitResult, error)
// Reset clears the rate limit state for the given key.
Reset(ctx context.Context, key string) error
}
-39
View File
@@ -1,39 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// LoggerService defines the contract for structured logging with trace ID and context correlation.
type LoggerService interface {
// Debug logs a debug message with optional key-value structured fields.
Debug(ctx context.Context, msg string, keysAndValues ...any)
// Info logs an informational message with optional key-value structured fields.
Info(ctx context.Context, msg string, keysAndValues ...any)
// Warn logs a warning message with optional key-value structured fields.
Warn(ctx context.Context, msg string, keysAndValues ...any)
// Error logs an error message with optional key-value structured fields.
Error(ctx context.Context, msg string, keysAndValues ...any)
// Debugf logs a formatted debug message.
Debugf(ctx context.Context, format string, args ...any)
// Infof logs a formatted informational message.
Infof(ctx context.Context, format string, args ...any)
// Warnf logs a formatted warning message.
Warnf(ctx context.Context, format string, args ...any)
// Errorf logs a formatted error message.
Errorf(ctx context.Context, format string, args ...any)
// With returns a child logger enriched with additional key-value attributes.
With(keysAndValues ...any) LoggerService
}
-29
View File
@@ -1,29 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import "context"
// PushNotificationTemplate defines notification message template payload.
type PushNotificationTemplate struct {
Title string
Content string
Level string
Ext map[string]any
}
// PushEventMeta defines metadata for a system push event.
type PushEventMeta struct {
Key string
Name string
Description string
DefaultTemplate PushNotificationTemplate
}
// PushRegistry defines the interface for registering built-in events.
type PushRegistry interface {
RegisterBuiltInEvent(meta PushEventMeta)
SyncEvents(ctx context.Context) error
}
-66
View File
@@ -1,66 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// AccessLogFilterDTO defines filter criteria for querying user access logs.
type AccessLogFilterDTO struct {
UserIDs []uint64
Path string
StartTime *time.Time
EndTime *time.Time
}
// AccessLogDTO represents a single access log entry.
type AccessLogDTO struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Method string `json:"method"`
Path string `json:"path"`
Status int32 `json:"status"`
Latency int64 `json:"latency"`
CreatedAt time.Time `json:"created_at"`
}
// AccessLogDailyStatsDTO represents aggregate access statistics for a single day.
type AccessLogDailyStatsDTO struct {
Date string `json:"date"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IPCount uint64 `json:"ip_count"`
ErrorCount uint64 `json:"error_count"`
AvgLatencyMs int64 `json:"avg_latency_ms"`
SlowReqCount uint64 `json:"slow_req_count"`
MaxLatencyMs int64 `json:"max_latency_ms"`
P95LatencyMs int64 `json:"p95_latency_ms"`
P99LatencyMs int64 `json:"p99_latency_ms"`
}
// RiskControlService defines the contract for accessing security risk control and audit logstore.
type RiskControlService interface {
// QueryAccessLogs retrieves paginated access logs matching the filter.
QueryAccessLogs(ctx context.Context, filter AccessLogFilterDTO, page, pageSize int) ([]AccessLogDTO, uint64, error)
// QueryAccessLogStats returns aggregate daily statistics for the last N days.
QueryAccessLogStats(ctx context.Context, days int) ([]AccessLogDailyStatsDTO, error)
// ActiveLogEngine returns the current active logstore engine name.
ActiveLogEngine(ctx context.Context) string
// IsLogEngineMigrating reports whether a log engine migration is in progress.
IsLogEngineMigrating(ctx context.Context) bool
// Drain flushes pending in-flight log buffers.
Drain(ctx context.Context) error
// SwitchLogEngine migrates and switches the active log storage engine.
SwitchLogEngine(ctx context.Context, targetEngine string) error
}
-112
View File
@@ -1,112 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"io"
)
// StorageObject represents a retrieved file object from the storage backend.
type StorageObject struct {
Key string
CachePath string
Body io.ReadCloser
ContentLength int64
ContentType string
}
// StoragePutResult describes the output of a successful Put operation.
type StoragePutResult struct {
Key string
Bucket string
}
// IngestOptions configures programmatic ingest of files into the platform storage.
type IngestOptions struct {
UserID uint64
Type string
FileName string
MimeType string
Extension string
Size int64
Policy int
Metadata map[string]any
}
// IngestResult reports the outcome of a programmatic file ingest operation.
type IngestResult struct {
ID uint64
Key string
URL string
Created bool
Stored bool
Resolved bool
}
// StorageDriver identifies a supported storage backend.
type StorageDriver string
// Storage drivers supported by the platform. Values persist in storage configs.
const (
StorageDriverLocal StorageDriver = "local"
StorageDriverS3 StorageDriver = "s3"
StorageDriverR2 StorageDriver = "r2"
StorageDriverMinIO StorageDriver = "minio"
StorageDriverOSS StorageDriver = "oss"
StorageDriverWebDAV StorageDriver = "webdav"
)
// LocalStorageConfigDTO configures local filesystem storage.
type LocalStorageConfigDTO struct {
Root string `json:"root"`
}
// ObjectStorageConfigDTO configures S3-compatible or OSS object storage.
type ObjectStorageConfigDTO struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
AccountID string `json:"account_id,omitempty"`
PathStyle bool `json:"path_style"`
KeyPrefix string `json:"key_prefix"`
CDNURL string `json:"cdn_url"`
}
// WebDAVStorageConfigDTO configures WebDAV storage.
type WebDAVStorageConfigDTO struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
Root string `json:"root"`
}
// StorageConfigDTO encapsulates full storage configuration across all backends.
type StorageConfigDTO struct {
Driver StorageDriver `json:"driver"`
Local LocalStorageConfigDTO `json:"local"`
S3 ObjectStorageConfigDTO `json:"s3"`
R2 ObjectStorageConfigDTO `json:"r2"`
MinIO ObjectStorageConfigDTO `json:"minio"`
OSS ObjectStorageConfigDTO `json:"oss"`
WebDAV WebDAVStorageConfigDTO `json:"webdav"`
}
// StorageService defines the contract for unified object storage and managed file ingestion.
type StorageService interface {
// Put writes an object to storage.
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoragePutResult, error)
// Get retrieves an object from storage.
Get(ctx context.Context, key string) (*StorageObject, error)
// Delete removes an object from storage.
Delete(ctx context.Context, key string) error
// Ingest performs managed file ingestion into the platform storage domain with deduplication and metadata tracking.
Ingest(ctx context.Context, reader io.Reader, opts IngestOptions) (*IngestResult, error)
}
-94
View File
@@ -1,94 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// TaskParamDTO describes a parameter accepted by a background task.
type TaskParamDTO struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"`
Required bool `json:"required"`
Placeholder string `json:"placeholder,omitempty"`
Description string `json:"description,omitempty"`
Default any `json:"default,omitempty"`
}
// TaskMetaDTO describes the metadata and configuration of a registered background task.
type TaskMetaDTO struct {
Type string `json:"type"`
AsynqTask string `json:"asynq_task"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description"`
Category string `json:"category,omitempty"`
SupportsTime bool `json:"supports_time"`
Params []TaskParamDTO `json:"params,omitempty"`
MaxRetry int `json:"max_retry"`
Timeout time.Duration `json:"timeout,omitempty"`
Queue string `json:"queue"`
Retryable bool `json:"retryable"`
Schedule string `json:"schedule,omitempty"`
}
// TaskResultDTO represents the outcome of a background task execution.
type TaskResultDTO struct {
Message string `json:"message"`
Detail any `json:"detail,omitempty"`
}
// TaskHandler is the preferred background task handler. Drivers invoke Execute
// and persist Message/Detail onto the execution record.
type TaskHandler interface {
Execute(ctx context.Context, payload []byte) (*TaskResultDTO, error)
}
// TaskExecutionDTO represents a single task execution record.
type TaskExecutionDTO struct {
ID uint64 `json:"id,string"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
TaskName string `json:"task_name"`
Status string `json:"status"`
Retryable bool `json:"retryable"`
MaxRetry int `json:"max_retry"`
RetryCount int `json:"retry_count"`
Log string `json:"log"`
ErrorMessage string `json:"error_message"`
Result string `json:"result"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Duration int64 `json:"duration"`
Payload string `json:"payload"`
TriggeredBy string `json:"triggered_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Canonical triggered_by values persisted on task executions and shown in admin UI.
const (
TaskTriggerSystem = "system"
TaskTriggerManual = "manual"
TaskTriggerRetry = "retry"
TaskTriggerSchedule = "schedule"
)
// TaskService defines the unified contract for dispatching and tracking background tasks.
type TaskService interface {
Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error)
Retry(ctx context.Context, id uint64) (string, error)
ListTasks() []TaskMetaDTO
GetTaskMeta(taskType string) (TaskMetaDTO, bool)
ValidatePayload(taskType string, payload []byte) ([]byte, error)
ReloadScheduler() error
AppendLog(ctx context.Context, format string, args ...any)
ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]TaskExecutionDTO, int64, error)
GetExecution(ctx context.Context, id uint64) (*TaskExecutionDTO, error)
GetExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecutionDTO, error)
}
-80
View File
@@ -1,80 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"io"
"time"
)
// UploadMetadataDTO represents upload metadata JSON.
type UploadMetadataDTO struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Duration float64 `json:"duration,omitempty"`
OriginalMime string `json:"original_mime,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
Bucket string `json:"bucket,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// Value implements the driver.Valuer interface for database serialization.
func (m UploadMetadataDTO) Value() (driver.Value, error) {
return json.Marshal(m)
}
// Scan implements the sql.Scanner interface for database deserialization.
func (m *UploadMetadataDTO) Scan(value any) error {
if value == nil {
*m = UploadMetadataDTO{}
return nil
}
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, m)
case string:
return json.Unmarshal([]byte(v), m)
default:
return fmt.Errorf("cannot scan type %T into UploadMetadataDTO", value)
}
}
// UploadDTO represents an uploaded file record.
type UploadDTO struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
FileName string `json:"file_name"`
FilePath string `json:"file_path"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
Hash string `json:"hash"`
Status string `json:"status"`
Type string `json:"type"`
Metadata UploadMetadataDTO `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OpenedUploadDTO encapsulates the retrieved object stream and its metadata.
type OpenedUploadDTO struct {
Upload UploadDTO
Body io.ReadCloser
ContentType string
ContentLength int64
}
// UploadService defines the unified contract for managed file uploads and media entities.
type UploadService interface {
GetByID(ctx context.Context, id uint64) (*UploadDTO, error)
OpenStoredUpload(ctx context.Context, id uint64) (*OpenedUploadDTO, error)
Remove(ctx context.Context, id uint64) error
RemoveOwned(ctx context.Context, id uint64, userID uint64) error
FindByHash(ctx context.Context, hash string, size int64) (*UploadDTO, error)
RebuildStats(ctx context.Context) error
}
-134
View File
@@ -1,134 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// CreateUserRequest contains fields to register or create a new user.
type CreateUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
}
// UpdateUserProfileRequest contains fields for updating a user's profile.
type UpdateUserProfileRequest struct {
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
Bio *string `json:"bio,omitempty"`
Phone *string `json:"phone,omitempty"`
Gender *string `json:"gender,omitempty"`
Website *string `json:"website,omitempty"`
Location *string `json:"location,omitempty"`
}
// AdminListUsersFilter contains query parameters for filtering users in admin panel.
type AdminListUsersFilter struct {
Page int
PageSize int
UserID *uint64
Username string
Email string
}
// AdminCreateUserRequest contains fields for admin to create a user.
type AdminCreateUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
// AdminUpdateUserRequest contains fields for admin to update a user.
type AdminUpdateUserRequest struct {
ID uint64 `json:"id,string"`
Nickname string `json:"nickname"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Password string `json:"password,omitempty"`
}
// UserService defines the contract for user account management and profile queries.
type UserService interface {
// GetUserByID retrieves a user by ID.
GetUserByID(ctx context.Context, id uint64) (*UserDTO, error)
// GetUsersByIDs retrieves several users in one round-trip. An empty ids
// slice yields no results and touches no storage.
GetUsersByIDs(ctx context.Context, ids []uint64) ([]*UserDTO, error)
// GetUserByUsername retrieves a user by username.
GetUserByUsername(ctx context.Context, username string) (*UserDTO, error)
// GetUserByEmail retrieves a user by email.
GetUserByEmail(ctx context.Context, email string) (*UserDTO, error)
// CreateUser registers or creates a new user account.
CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error)
// UpdateProfile updates the profile of the specified user.
UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error)
// UpdatePassword updates the password for the specified user after verifying the old password.
UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error
// VerifyPassword verifies if the given password matches the user's password.
VerifyPassword(ctx context.Context, id uint64, password string) bool
// UpdateLastLogin updates the user's last login timestamp.
UpdateLastLogin(ctx context.Context, id uint64, ip string) error
// ListUsers returns a paginated list of users with optional keyword search.
ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error)
// SetUserActive sets the active/banned status for a user.
SetUserActive(ctx context.Context, id uint64, active bool) error
// SetUserAdmin sets the admin role status for a user.
SetUserAdmin(ctx context.Context, id uint64, admin bool) error
// VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag.
VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error)
// DeleteUser removes a user and related access tokens.
DeleteUser(ctx context.Context, id uint64) error
// CountUsers returns total user count.
CountUsers(ctx context.Context) (int64, error)
// CountActiveUsers returns active user count.
CountActiveUsers(ctx context.Context) (int64, error)
// GetFirstAdminUser returns the earliest admin user.
GetFirstAdminUser(ctx context.Context) (*UserDTO, error)
// UniqueUsername generates a unique username candidate based on base.
UniqueUsername(ctx context.Context, base string) (string, error)
// AdminListUsers returns a filtered paginated list of users for admin management.
AdminListUsers(ctx context.Context, filter AdminListUsersFilter) (int64, []*UserDTO, error)
// AdminGetUser retrieves complete user details by ID for admin management.
AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error)
// AdminCreateUser creates a user with admin specified options.
AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error)
// AdminUpdateUser updates user details, email, nickname, admin role, and optional password.
AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error
// AdminUpdateUserStatus updates a user's active status (with admin protection).
AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error
// AdminDeleteUser deletes a user (with self and admin protection, cascading tokens and accounts).
AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error
}
-481
View File
@@ -1,481 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"sync/atomic"
)
const maxHandlerParams = 2
var (
ctxInterfaceType = reflect.TypeFor[context.Context]()
errInterfaceType = reflect.TypeFor[error]()
)
type eventListener struct {
id uint64
fnVal reflect.Value
numIn int
numOut int
hasCtx bool
hasPayload bool
argType reflect.Type
returnsErr bool
returnsVal bool
}
// EventBus is a thread-safe, strongly-typed in-process domain event bus supporting
// Emit, Waterfall, Parallel, and Serial dispatch semantics.
type EventBus struct {
mu sync.RWMutex
nextID atomic.Uint64
handlers map[string][]eventListener
}
// NewEventBus creates a new EventBus instance.
func NewEventBus() *EventBus {
return &EventBus{
handlers: make(map[string][]eventListener),
}
}
// On registers an event handler for the given topic.
//
// Supported handler signatures:
// - func(ctx context.Context, event T) (T, error)
// - func(ctx context.Context, event T) T
// - func(event T) (T, error)
// - func(event T) T
// - func(ctx context.Context, event T) error
// - func(ctx context.Context, event T)
// - func(event T) error
// - func(event T)
// - func(ctx context.Context) error
// - func(ctx context.Context)
// - func() error
// - func()
//
// Returns a Disposer function that unregisters the handler when called.
func (b *EventBus) On(topic string, handler any) Disposer {
if handler == nil {
panic("core/events: handler cannot be nil")
}
fnVal := reflect.ValueOf(handler)
fnType := fnVal.Type()
if fnType.Kind() != reflect.Func {
panic(fmt.Sprintf("core/events: expected func, got %s", fnType.Kind()))
}
numIn := fnType.NumIn()
if numIn > maxHandlerParams {
panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn))
}
const maxHandlerReturnValues = 2
numOut := fnType.NumOut()
if numOut > maxHandlerReturnValues {
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 2 supported (value, error)", numOut))
}
returnsErr := false
returnsVal := false
switch numOut {
case 1:
out0 := fnType.Out(0)
if out0.Implements(errInterfaceType) {
returnsErr = true
} else {
returnsVal = true
}
case 2:
out1 := fnType.Out(1)
if !out1.Implements(errInterfaceType) {
panic(fmt.Sprintf("core/events: second return value must be error, got %v", out1))
}
returnsVal = true
returnsErr = true
}
listener := eventListener{
id: b.nextID.Add(1),
fnVal: fnVal,
numIn: numIn,
numOut: numOut,
returnsErr: returnsErr,
returnsVal: returnsVal,
}
switch numIn {
case 0:
// func() or func() error
case 1:
in0 := fnType.In(0)
if in0.Implements(ctxInterfaceType) {
listener.hasCtx = true
} else {
listener.hasPayload = true
listener.argType = in0
}
case 2:
in0 := fnType.In(0)
if !in0.Implements(ctxInterfaceType) {
panic(fmt.Sprintf("core/events: first parameter must implement context.Context, got %v", in0))
}
listener.hasCtx = true
listener.hasPayload = true
listener.argType = fnType.In(1)
}
b.mu.Lock()
b.handlers[topic] = append(b.handlers[topic], listener)
b.mu.Unlock()
listenerID := listener.id
var disposed atomic.Bool
return func() error {
if disposed.Swap(true) {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
list := b.handlers[topic]
for i, l := range list {
if l.id == listenerID {
b.handlers[topic] = append(list[:i], list[i+1:]...)
break
}
}
if len(b.handlers[topic]) == 0 {
delete(b.handlers, topic)
}
return nil
}
}
// Subscribe registers a strongly-typed generic event listener on the given EventBus.
func Subscribe[T any](bus *EventBus, topic string, handler func(ctx context.Context, event T) error) Disposer {
if bus == nil {
panic("core/events: nil EventBus provided to Subscribe")
}
return bus.On(topic, handler)
}
// Emit publishes an event to all subscribers of the specified topic.
// Handlers are executed synchronously. If any handler panics or returns an error,
// the error is collected and returned via errors.Join.
//
//nolint:contextcheck
func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return nil
}
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
var errs []error
for _, l := range listeners {
args := b.buildArgs(ctx, l, payloadVal)
err := func() (resErr error) {
defer func() {
if r := recover(); r != nil {
resErr = fmt.Errorf("core/events: panic in handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr {
errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
resErr = results[errIdx].Interface().(error)
}
}
return resErr
}()
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Waterfall runs handlers sequentially as a transformation pipeline.
// The returned value of each handler becomes the payload input for the next handler.
// If any handler returns an error or panics, execution aborts immediately.
//
//nolint:contextcheck
func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error) {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return initialPayload, nil
}
currentPayload := initialPayload
for _, l := range listeners {
var payloadVal reflect.Value
if currentPayload != nil {
payloadVal = reflect.ValueOf(currentPayload)
}
args := b.buildArgs(ctx, l, payloadVal)
var stepVal any
var stepErr error
func() {
defer func() {
if r := recover(); r != nil {
stepErr = fmt.Errorf("core/events: panic in waterfall handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr {
errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
stepErr = results[errIdx].Interface().(error)
}
}
if stepErr == nil && l.returnsVal && len(results) > 0 {
stepVal = results[0].Interface()
}
}()
if stepErr != nil {
return nil, stepErr
}
if l.returnsVal {
currentPayload = stepVal
}
}
return currentPayload, nil
}
// Parallel executes all subscribers of the topic concurrently in separate goroutines.
// It waits for all handlers to complete or returns immediately if ctx is cancelled/timed out,
// collecting any handler errors via errors.Join.
//
//nolint:contextcheck
func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return nil
}
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
var wg sync.WaitGroup
errCh := make(chan error, len(listeners))
for _, l := range listeners {
wg.Add(1)
go func(listener eventListener) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("core/events: panic in parallel handler for topic %q: %v", topic, r)
}
}()
args := b.buildArgs(ctx, listener, payloadVal)
results := listener.fnVal.Call(args)
if listener.returnsErr {
errIdx := listener.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
errCh <- results[errIdx].Interface().(error)
}
}
}(l)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
close(errCh)
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
case <-ctx.Done():
return ctx.Err()
}
}
// Serial executes subscribers strictly in sequence.
// If any subscriber returns an error or panics, execution stops immediately and returns that error.
//
//nolint:contextcheck
func (b *EventBus) Serial(ctx context.Context, topic string, payload any) error {
if ctx == nil {
ctx = context.Background()
}
listeners := b.getListeners(topic)
if len(listeners) == 0 {
return nil
}
var payloadVal reflect.Value
if payload != nil {
payloadVal = reflect.ValueOf(payload)
}
for _, l := range listeners {
args := b.buildArgs(ctx, l, payloadVal)
var stepErr error
func() {
defer func() {
if r := recover(); r != nil {
stepErr = fmt.Errorf("core/events: panic in serial handler for topic %q: %v", topic, r)
}
}()
results := l.fnVal.Call(args)
if l.returnsErr {
errIdx := l.numOut - 1
if len(results) > errIdx && !results[errIdx].IsNil() {
stepErr = results[errIdx].Interface().(error)
}
}
}()
if stepErr != nil {
return stepErr
}
}
return nil
}
func (b *EventBus) getListeners(topic string) []eventListener {
b.mu.RLock()
raw := b.handlers[topic]
if len(raw) == 0 {
b.mu.RUnlock()
return nil
}
listeners := make([]eventListener, len(raw))
copy(listeners, raw)
b.mu.RUnlock()
return listeners
}
func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value {
if l.numIn == 0 {
return nil
}
args := make([]reflect.Value, 0, l.numIn)
if l.hasCtx {
args = append(args, reflect.ValueOf(ctx))
}
if l.hasPayload {
arg := b.convertPayload(payloadVal, l.argType)
args = append(args, arg)
}
return args
}
func (b *EventBus) convertPayload(payloadVal reflect.Value, targetType reflect.Type) reflect.Value {
if !payloadVal.IsValid() {
return reflect.Zero(targetType)
}
valType := payloadVal.Type()
// 1. Direct assignable
if valType.AssignableTo(targetType) {
return payloadVal
}
// 2. Direct convertible
if valType.ConvertibleTo(targetType) {
return payloadVal.Convert(targetType)
}
// 3. Payload is pointer *T, target expects T
if valType.Kind() == reflect.Pointer && valType.Elem().AssignableTo(targetType) {
if !payloadVal.IsNil() {
return payloadVal.Elem()
}
return reflect.Zero(targetType)
}
// 4. Payload is value T, target expects *T
if targetType.Kind() == reflect.Pointer && valType.AssignableTo(targetType.Elem()) {
ptr := reflect.New(valType)
ptr.Elem().Set(payloadVal)
return ptr
}
// Fallback to zero value of targetType
return reflect.Zero(targetType)
}
// Listeners returns the number of active listeners for a topic.
func (b *EventBus) Listeners(topic string) int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.handlers[topic])
}
// Topics returns all topics that have registered listeners.
func (b *EventBus) Topics() []string {
b.mu.RLock()
defer b.mu.RUnlock()
topics := make([]string, 0, len(b.handlers))
for t := range b.handlers {
topics = append(topics, t)
}
return topics
}
-415
View File
@@ -1,415 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type UserRegisteredEvent struct {
UserID string `json:"user_id"`
Username string `json:"username"`
}
type OrderCreatedEvent struct {
OrderID string `json:"order_id"`
Amount float64 `json:"amount"`
}
func TestEventBusPublishSubscribe(t *testing.T) {
bus := core.NewEventBus()
var receivedID string
disposer := bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error {
receivedID = e.UserID
return nil
})
require.NotNil(t, disposer)
assert.Equal(t, []string{"user:registered"}, bus.Topics())
err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999", Username: "alice"})
assert.NoError(t, err)
assert.Equal(t, "u_999", receivedID)
// Emit to empty topic returns nil error
err = bus.Emit(nil, "empty:topic", nil)
assert.NoError(t, err)
}
func TestEventBusGenericSubscribe(t *testing.T) {
bus := core.NewEventBus()
var receivedOrder string
assert.Panics(t, func() {
core.Subscribe[OrderCreatedEvent](nil, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
return nil
})
})
disposer := core.Subscribe(bus, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
receivedOrder = e.OrderID
return nil
})
require.NotNil(t, disposer)
err := bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_123", Amount: 99.5})
assert.NoError(t, err)
assert.Equal(t, "ord_123", receivedOrder)
// Test unsubscribe via disposer
err = disposer()
assert.NoError(t, err)
receivedOrder = ""
err = bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_456", Amount: 100})
assert.NoError(t, err)
assert.Empty(t, receivedOrder, "handler should not be called after disposal")
}
func TestEventBusHandlerSignatures(t *testing.T) {
bus := core.NewEventBus()
var (
calledWithCtxPayloadErr atomic.Bool
calledWithCtxPayload atomic.Bool
calledWithPayloadErr atomic.Bool
calledWithPayload atomic.Bool
calledWithCtxErr atomic.Bool
calledWithCtx atomic.Bool
calledWithNoArgsErr atomic.Bool
calledWithNoArgs atomic.Bool
)
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) error {
calledWithCtxPayloadErr.Store(true)
assert.Equal(t, "u_1", e.UserID)
return nil
})
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) {
calledWithCtxPayload.Store(true)
assert.Equal(t, "u_1", e.UserID)
})
bus.On("test:sig", func(e UserRegisteredEvent) error {
calledWithPayloadErr.Store(true)
assert.Equal(t, "u_1", e.UserID)
return nil
})
bus.On("test:sig", func(e UserRegisteredEvent) {
calledWithPayload.Store(true)
assert.Equal(t, "u_1", e.UserID)
})
bus.On("test:sig", func(ctx context.Context) error {
calledWithCtxErr.Store(true)
return nil
})
bus.On("test:sig", func(ctx context.Context) {
calledWithCtx.Store(true)
})
bus.On("test:sig", func() error {
calledWithNoArgsErr.Store(true)
return nil
})
bus.On("test:sig", func() {
calledWithNoArgs.Store(true)
})
err := bus.Emit(context.Background(), "test:sig", UserRegisteredEvent{UserID: "u_1", Username: "test"})
assert.NoError(t, err)
assert.True(t, calledWithCtxPayloadErr.Load())
assert.True(t, calledWithCtxPayload.Load())
assert.True(t, calledWithPayloadErr.Load())
assert.True(t, calledWithPayload.Load())
assert.True(t, calledWithCtxErr.Load())
assert.True(t, calledWithCtx.Load())
assert.True(t, calledWithNoArgsErr.Load())
assert.True(t, calledWithNoArgs.Load())
}
func TestEventBusPointerAndValueConversion(t *testing.T) {
bus := core.NewEventBus()
var (
receivedFromValueToPtr atomic.Bool
receivedFromPtrToValue atomic.Bool
receivedFromNilPtr atomic.Bool
)
// Handler expects pointer, payload emitted as value
bus.On("test:ptr", func(ctx context.Context, e *UserRegisteredEvent) error {
if e != nil && e.UserID == "u_ptr" {
receivedFromValueToPtr.Store(true)
}
return nil
})
err := bus.Emit(context.Background(), "test:ptr", UserRegisteredEvent{UserID: "u_ptr"})
assert.NoError(t, err)
assert.True(t, receivedFromValueToPtr.Load())
// Handler expects value, payload emitted as pointer
bus.On("test:val", func(ctx context.Context, e UserRegisteredEvent) error {
if e.UserID == "u_val" {
receivedFromPtrToValue.Store(true)
}
return nil
})
err = bus.Emit(context.Background(), "test:val", &UserRegisteredEvent{UserID: "u_val"})
assert.NoError(t, err)
assert.True(t, receivedFromPtrToValue.Load())
// Handler expects value, payload is nil pointer
var nilEvent *UserRegisteredEvent
bus.On("test:nil_ptr", func(ctx context.Context, e UserRegisteredEvent) error {
assert.Equal(t, "", e.UserID)
receivedFromNilPtr.Store(true)
return nil
})
err = bus.Emit(context.Background(), "test:nil_ptr", nilEvent)
assert.NoError(t, err)
assert.True(t, receivedFromNilPtr.Load())
// Convertible type test (int to int64)
var receivedConvert int64
bus.On("test:conv", func(e int64) {
receivedConvert = e
})
err = bus.Emit(context.Background(), "test:conv", int(42))
assert.NoError(t, err)
assert.Equal(t, int64(42), receivedConvert)
}
func TestEventBusErrorCollectionAndPanicRecovery(t *testing.T) {
bus := core.NewEventBus()
errHandler1 := errors.New("handler 1 failed")
errHandler2 := errors.New("handler 2 failed")
bus.On("test:err", func() error {
return errHandler1
})
bus.On("test:err", func() {
panic("something went horribly wrong")
})
bus.On("test:err", func() error {
return errHandler2
})
err := bus.Emit(context.Background(), "test:err", nil)
require.Error(t, err)
assert.True(t, errors.Is(err, errHandler1) || errors.Is(err, errHandler2))
assert.Contains(t, err.Error(), "handler 1 failed")
assert.Contains(t, err.Error(), "handler 2 failed")
assert.Contains(t, err.Error(), "panic")
}
func TestEventBusInvalidHandlerPanics(t *testing.T) {
bus := core.NewEventBus()
assert.Panics(t, func() {
bus.On("test:invalid", nil)
})
assert.Panics(t, func() {
bus.On("test:invalid", "not-a-func")
})
assert.Panics(t, func() {
// More than 2 arguments
bus.On("test:invalid", func(a, b, c string) {})
})
assert.Panics(t, func() {
// 2 args, but first is not context
bus.On("test:invalid", func(a string, b int) {})
})
assert.Panics(t, func() {
// More than 2 return values
bus.On("test:invalid", func() (int, string, error) { return 0, "", nil })
})
assert.Panics(t, func() {
// 2 return values, but second is not error
bus.On("test:invalid", func() (int, string) { return 0, "" })
})
}
func TestEventBusListenersCountAndDisposerIdempotence(t *testing.T) {
bus := core.NewEventBus()
assert.Equal(t, 0, bus.Listeners("topic1"))
d1 := bus.On("topic1", func() {})
d2 := bus.On("topic1", func() {})
assert.Equal(t, 2, bus.Listeners("topic1"))
_ = d1()
assert.Equal(t, 1, bus.Listeners("topic1"))
// Calling disposer again should be no-op
_ = d1()
assert.Equal(t, 1, bus.Listeners("topic1"))
_ = d2()
assert.Equal(t, 0, bus.Listeners("topic1"))
}
func TestEventBusConcurrentAccess(t *testing.T) {
bus := core.NewEventBus()
var wg sync.WaitGroup
var receivedCount atomic.Int64
// Concurrently subscribe and emit
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
topic := fmt.Sprintf("topic:%d", idx%5)
disposer := bus.On(topic, func(ctx context.Context, e UserRegisteredEvent) error {
receivedCount.Add(1)
return nil
})
// Emit some events
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
// Randomly dispose
if idx%2 == 0 {
_ = disposer()
}
}(i)
}
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
topic := fmt.Sprintf("topic:%d", idx%5)
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
}(i)
}
wg.Wait()
assert.Greater(t, receivedCount.Load(), int64(0))
}
func TestEventBusWaterfall(t *testing.T) {
bus := core.NewEventBus()
// Handler 1: appends "-first"
bus.On("pipeline:transform", func(ctx context.Context, s string) string {
return s + "-first"
})
// Handler 2: appends "-second" with error return
bus.On("pipeline:transform", func(s string) (string, error) {
return s + "-second", nil
})
res, err := bus.Waterfall(context.Background(), "pipeline:transform", "init")
assert.NoError(t, err)
assert.Equal(t, "init-first-second", res)
// Test short-circuit on error
expectedErr := errors.New("waterfall step failed")
bus.On("pipeline:error", func(s string) (string, error) {
return s, expectedErr
})
bus.On("pipeline:error", func(s string) string {
return s + "-should-not-run"
})
res, err = bus.Waterfall(context.Background(), "pipeline:error", "start")
assert.ErrorIs(t, err, expectedErr)
assert.Nil(t, res)
}
func TestEventBusParallel(t *testing.T) {
bus := core.NewEventBus()
var counter atomic.Int64
err1 := errors.New("parallel err 1")
bus.On("test:parallel", func(ctx context.Context, val int) error {
counter.Add(int64(val))
return nil
})
bus.On("test:parallel", func(val int) error {
counter.Add(int64(val))
return err1
})
err := bus.Parallel(context.Background(), "test:parallel", 10)
assert.ErrorIs(t, err, err1)
assert.Equal(t, int64(20), counter.Load())
}
func TestEventBusParallelContextTimeout(t *testing.T) {
bus := core.NewEventBus()
bus.On("test:timeout", func(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := bus.Parallel(ctx, "test:timeout", nil)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestEventBusSerial(t *testing.T) {
bus := core.NewEventBus()
var executed []int
errStop := errors.New("serial stop")
bus.On("test:serial", func() error {
executed = append(executed, 1)
return nil
})
bus.On("test:serial", func() error {
executed = append(executed, 2)
return errStop
})
bus.On("test:serial", func() error {
executed = append(executed, 3)
return nil
})
err := bus.Serial(context.Background(), "test:serial", nil)
assert.ErrorIs(t, err, errStop)
assert.Equal(t, []int{1, 2}, executed)
}
-284
View File
@@ -1,284 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"time"
)
// Sentinel errors returned by the configuration extension point.
var (
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
// ErrConfigType is returned when a value cannot be converted to the declared type.
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
// ErrConfigUnknownKey is returned when a configuration key was never declared.
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
// ErrConfigNotResolved is returned when typed reads happen before resolution.
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
)
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
const (
// OriginEnv marks a value that came from an environment variable.
OriginEnv = "env"
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
OriginAutoEnable = "auto-enable"
// OriginFile marks a value that came from the configuration file.
OriginFile = "file"
// OriginDefault marks a value that came from a declaration default.
OriginDefault = "default"
)
// RedactedValue replaces the printed value of keys declared with secret:"true".
const RedactedValue = "******"
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
var durationType = reflect.TypeFor[time.Duration]()
// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a
// compile error rather than a runtime surprise inside a plugin Apply.
var _ ConfigExtension = (*ConfigRegistry)(nil)
// ConfigSource abstracts where raw configuration values come from, keeping the
// micro-kernel free of concrete loaders such as viper.
type ConfigSource interface {
// Lookup returns the raw value stored at a dotted path in the configuration file.
Lookup(path string) (any, bool)
// LookupEnv returns the raw value of an environment variable.
LookupEnv(name string) (string, bool)
// Describe returns a human readable identity for the source, used in diagnostics.
Describe() string
}
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
// under a dotted configuration prefix.
type ConfigBinding struct {
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
// each field's `config` tag is already a full path.
Prefix string
// Target must be a non-nil pointer to a struct carrying `config` tags.
Target any
}
// configField is a single leaf discovered while walking a binding struct's tags.
// key is the fully qualified dotted path used for resolution; path is the raw `config`
// tag value used to locate the Go field again during Bind.
type configField struct {
key string
path string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// configDecl is the registered form of a configField, attributed to its declaring plugin.
type configDecl struct {
key string
pluginID string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
type ConfigEntry struct {
Key string
PluginID string
Env string
Origin string
Value string
}
// ConfigView is the read-only surface over effective configuration values.
// Keys are dotted paths such as "redis.enabled".
type ConfigView interface {
Value(key string) (any, bool)
String(key, fallback string) string
Bool(key string, fallback bool) bool
Int(key string, fallback int) int
Duration(key string, fallback time.Duration) time.Duration
Strings(key string) []string
WasSet(envName string) bool
Origin(key string) string
}
// ConfigExtension is the plugin-facing configuration extension point mounted on the
// root Context and shared by every forked plugin scope.
type ConfigExtension interface {
ConfigView
// SetSource installs the raw value source after construction, letting the composition
// root build the adapter once the kernel Context already exists.
SetSource(src ConfigSource)
// Declare registers plugin-owned configuration bindings before Apply runs.
Declare(pluginID string, bindings ...ConfigBinding) error
// Bind resolves and assigns the configuration values for a tagged struct.
Bind(prefix string, target any) error
// Resolve computes the effective value of every declared key once.
Resolve() error
// Resolved reports whether Resolve has already run.
Resolved() bool
// Entries returns the redacted effective configuration ordered by key.
Entries() []ConfigEntry
}
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
// computed once by Resolve and reused by every later read.
type ConfigRegistry struct {
mu sync.RWMutex
src ConfigSource
decls map[string]*configDecl
order []string
values map[string]any
origins map[string]string
resolved bool
}
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
// that the kernel can construct the registry before the composition root injects one.
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
return &ConfigRegistry{
src: src,
decls: make(map[string]*configDecl),
values: make(map[string]any),
origins: make(map[string]string),
}
}
// SetSource installs the raw value source. It is intended for the composition root,
// which builds the adapter after the kernel Context already exists.
func (r *ConfigRegistry) SetSource(src ConfigSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.src = src
}
// Declare registers every `config` tagged leaf of each binding's target struct.
// Repeated declarations of the same key are accepted only when their env, default,
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, b := range bindings {
if err := r.declareBinding(pluginID, b); err != nil {
return err
}
}
return nil
}
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
target, err := bindingStruct(b.Target, b.Prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(target.Type(), b.Prefix)
if err != nil {
return err
}
for _, f := range fields {
if err := r.addDecl(pluginID, f); err != nil {
return err
}
}
return nil
}
// bindingStruct validates that a binding or bind target is a usable struct pointer.
func bindingStruct(target any, prefix string) (reflect.Value, error) {
rv := reflect.ValueOf(target)
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
}
return rv.Elem(), nil
}
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
// A field without a `config` tag is skipped, except for embedded structs which are
// recursed into so their own tags resolve under the same prefix.
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
var out []configField
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.PkgPath != "" {
continue
}
path := sf.Tag.Get("config")
if path == "-" {
continue
}
if path == "" {
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
nested, err := walkConfigFields(sf.Type, prefix)
if err != nil {
return nil, err
}
out = append(out, nested...)
}
continue
}
out = append(out, configField{
key: joinKey(prefix, path),
path: path,
env: sf.Tag.Get("env"),
autoEnable: sf.Tag.Get("autoEnable"),
def: sf.Tag.Get("default"),
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
typ: sf.Type,
})
}
return out, nil
}
func joinKey(prefix, path string) string {
if prefix == "" {
return path
}
return prefix + "." + path
}
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
if existing, ok := r.decls[f.key]; ok {
if existing.env != f.env || existing.def != f.def ||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
return fmt.Errorf(
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
ErrConfigConflict, f.key, existing.pluginID, pluginID)
}
return nil
}
r.decls[f.key] = &configDecl{
key: f.key, pluginID: pluginID, env: f.env,
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
}
r.order = append(r.order, f.key)
return nil
}
-290
View File
@@ -1,290 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"errors"
"fmt"
"reflect"
"sort"
"time"
)
// Resolve computes the effective value of every declared key. Priority is, in order:
// an explicit environment override, an auto-enable trigger, the configuration file,
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
func (r *ConfigRegistry) Resolve() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
var errs []error
for _, key := range r.order {
if _, done := r.values[key]; done {
continue
}
if err := r.resolveLocked(key); err != nil {
errs = append(errs, err)
}
}
r.resolved = true
return errors.Join(errs...)
}
// Resolved reports whether Resolve has already run.
func (r *ConfigRegistry) Resolved() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolved
}
// resolveLocked computes the effective value of one key. The caller must hold r.mu.
func (r *ConfigRegistry) resolveLocked(key string) error {
d, ok := r.decls[key]
if !ok {
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
}
if d.env != "" {
if raw, found := r.src.LookupEnv(d.env); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
}
r.values[key], r.origins[key] = value, OriginEnv
return nil
}
}
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
if _, found := r.src.LookupEnv(d.autoEnable); found {
r.values[key], r.origins[key] = true, OriginAutoEnable
return nil
}
}
if raw, found := r.src.Lookup(key); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
}
r.values[key], r.origins[key] = value, OriginFile
return nil
}
if d.def != "" {
value, err := convertValue(d.def, d.typ)
if err != nil {
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
}
r.values[key], r.origins[key] = value, OriginDefault
return nil
}
r.values[key] = reflect.New(d.typ).Elem().Interface()
r.origins[key] = ""
return nil
}
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
// were never declared self-register, so only gates need DeclareConfig.
func (r *ConfigRegistry) Bind(prefix string, target any) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
if !r.resolved {
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
}
elem, err := bindingStruct(target, prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(elem.Type(), prefix)
if err != nil {
return err
}
for _, f := range fields {
if _, declared := r.decls[f.key]; !declared {
if err := r.addDecl("bind:"+prefix, f); err != nil {
return err
}
}
if _, done := r.values[f.key]; !done {
if err := r.resolveLocked(f.key); err != nil {
return err
}
}
}
return assignFields(elem, fields, r.values)
}
// assignFields writes resolved values into a freshly walked target struct.
func assignFields(elem reflect.Value, fields []configField, values map[string]any) error {
for _, f := range fields {
field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path))
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
}
value := reflect.ValueOf(values[f.key])
if !value.Type().AssignableTo(field.Type()) {
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
ErrConfigType, f.key, value.Type(), field.Type())
}
field.Set(value)
}
return nil
}
// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
r.mu.Lock()
defer r.mu.Unlock()
keys := append([]string(nil), r.order...)
sort.Strings(keys)
out := make([]ConfigEntry, 0, len(keys))
for _, key := range keys {
d := r.decls[key]
if _, done := r.values[key]; !done && r.src != nil {
_ = r.resolveLocked(key)
}
out = append(out, ConfigEntry{
Key: d.key,
PluginID: d.pluginID,
Env: d.env,
Origin: r.origins[key],
Value: formatEntryValue(r.values[key], d.secret),
})
}
return out
}
// formatEntryValue renders one effective value for diagnostics, masking secrets.
func formatEntryValue(value any, secret bool) string {
if secret {
return RedactedValue
}
if value == nil {
return ""
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
return fmt.Sprint(rv.Elem().Interface())
}
return fmt.Sprint(value)
}
// Value returns the resolved value for key, lazily resolving a declared key that has
// not been computed yet. Missing and unresolvable keys report false rather than an
// error so gates and diagnostics can keep using the fallback accessors.
func (r *ConfigRegistry) Value(key string) (any, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, done := r.values[key]; !done {
if r.src == nil {
return nil, false
}
if _, declared := r.decls[key]; !declared {
return nil, false
}
if err := r.resolveLocked(key); err != nil {
return nil, false
}
}
value, ok := r.values[key]
return value, ok
}
// String returns the string value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) String(key, fallback string) string {
if value, ok := r.Value(key); ok {
if converted, err := convertString(value); err == nil {
return converted.(string)
}
}
return fallback
}
// Bool returns the boolean value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
if value, ok := r.Value(key); ok {
if converted, err := convertBool(value); err == nil {
return converted.(bool)
}
}
return fallback
}
// Int returns the int value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Int(key string, fallback int) int {
if value, ok := r.Value(key); ok {
converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers)
if err == nil {
return converted.(int)
}
}
return fallback
}
// Duration returns the time.Duration value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
if value, ok := r.Value(key); ok {
if converted, err := convertDuration(value); err == nil {
return converted.(time.Duration)
}
}
return fallback
}
// Strings returns the []string value of key, or nil when absent.
func (r *ConfigRegistry) Strings(key string) []string {
value, ok := r.Value(key)
if !ok {
return nil
}
converted, err := convertSlice(value, reflect.TypeFor[[]string]())
if err != nil {
return nil
}
list, _ := converted.([]string)
return list
}
// WasSet reports whether an environment variable is present, regardless of its value.
func (r *ConfigRegistry) WasSet(envName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.src == nil {
return false
}
_, found := r.src.LookupEnv(envName)
return found
}
// Origin reports where a key's effective value came from; "" means the zero value.
func (r *ConfigRegistry) Origin(key string) string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.origins[key]
}
-293
View File
@@ -1,293 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core/extpoints"
)
// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
type fakeSource struct {
values map[string]any
env map[string]string
}
func newFakeSource() *fakeSource {
return &fakeSource{values: map[string]any{}, env: map[string]string{}}
}
func (f *fakeSource) Lookup(path string) (any, bool) {
v, ok := f.values[path]
return v, ok
}
func (f *fakeSource) LookupEnv(name string) (string, bool) {
v, ok := f.env[name]
return v, ok
}
func (f *fakeSource) Describe() string { return "fake" }
// redisConfig mirrors how a plugin declares the configuration it reads.
type redisConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
DB int `config:"db" env:"REDIS_DB"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
Ignored string `config:"-"`
private string
}
func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
keys := make([]string, 0)
for _, e := range r.Entries() {
keys = append(keys, e.Key)
}
assert.Equal(t, []string{
"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
}, keys)
}
func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
extpoints.ErrConfigTarget)
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
extpoints.ErrConfigTarget)
}
func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
require.NoError(t, r.Declare("cache", binding))
require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")
type conflictingConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
}
err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
require.ErrorIs(t, err, extpoints.ErrConfigConflict)
assert.Contains(t, err.Error(), "redis.enabled")
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "driver_http")
}
// queueConfig is a composite element mirroring worker.queues in config.yaml.
type queueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type workerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
Queues []queueConfig `config:"queues"`
}
type sessionConfig struct {
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
}
func TestResolveScalarDurationAndSlice(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.values["redis.dial_timeout"] = "5s"
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
src.env["REDIS_KEY_PREFIX"] = "refresh:"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
var got redisConfig
require.NoError(t, r.Bind("redis", &got))
assert.Equal(t, redisConfig{
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
}, got)
}
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
var got redisConfig
require.NoError(t, r.Bind("redis", &got))
assert.Equal(t, []string{"redis:6379"}, got.Addrs)
}
func TestResolveCompositeSliceOfStructs(t *testing.T) {
src := newFakeSource()
src.values["worker.concurrency"] = 20
src.values["worker.queues"] = []any{
map[string]any{"name": "webhook", "priority": 10},
map[string]any{"name": "default", "priority": 3},
}
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
require.NoError(t, r.Resolve())
var got workerConfig
require.NoError(t, r.Bind("worker", &got))
assert.Equal(t, workerConfig{
Concurrency: 20,
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
}, got)
}
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["WORKER_CONCURRENCY"] = "many"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
err := r.Resolve()
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Contains(t, err.Error(), "worker.concurrency")
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
}
func TestViewAccessorsAndOrigins(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.env["REDIS_ADDR"] = "redis:6379"
src.env["REDIS_ENABLED"] = "false"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
assert.False(t, r.Bool("redis.enabled", true))
assert.Equal(t, 1, r.Int("redis.db", 0))
assert.Equal(t, "86400", r.String("app.session_age", "0"))
assert.Equal(t, "fallback", r.String("redis.missing", "fallback"))
assert.Zero(t, r.Duration("redis.dial_timeout", 0))
assert.True(t, r.WasSet("REDIS_ADDR"))
assert.False(t, r.WasSet("REDIS_NOPE"))
}
func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
src.values["redis.enabled"] = false
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))
explicit := newFakeSource()
explicit.env["REDIS_ADDR"] = "redis:6379"
explicit.env["REDIS_ENABLED"] = "false"
r2 := extpoints.NewConfigRegistry(explicit)
require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r2.Resolve())
assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
}
func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
entries := map[string]extpoints.ConfigEntry{}
for _, e := range r.Entries() {
entries[e.Key] = e
}
assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
assert.Equal(t, "86400", entries["app.session_age"].Value)
}
type optionalBoolConfig struct {
RedirectTrailingSlash *bool `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH"`
}
func TestBindBoolPointerFromFileAndEnv(t *testing.T) {
t.Run("absent stays nil", func(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
assert.Nil(t, got.RedirectTrailingSlash)
assert.Equal(t, "", r.Origin("app.redirect_trailing_slash"))
})
t.Run("file false", func(t *testing.T) {
src := newFakeSource()
src.values["app.redirect_trailing_slash"] = false
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
require.NotNil(t, got.RedirectTrailingSlash)
assert.False(t, *got.RedirectTrailingSlash)
assert.Equal(t, extpoints.OriginFile, r.Origin("app.redirect_trailing_slash"))
assert.False(t, r.Bool("app.redirect_trailing_slash", true))
})
t.Run("env false", func(t *testing.T) {
src := newFakeSource()
src.env["APP_REDIRECT_TRAILING_SLASH"] = "false"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}}))
require.NoError(t, r.Resolve())
var got optionalBoolConfig
require.NoError(t, r.Bind("app", &got))
require.NotNil(t, got.RedirectTrailingSlash)
assert.False(t, *got.RedirectTrailingSlash)
assert.Equal(t, extpoints.OriginEnv, r.Origin("app.redirect_trailing_slash"))
})
}
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
r := extpoints.NewConfigRegistry(nil)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)
var cfg redisConfig
assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
}
func TestBindRejectsReadsBeforeResolution(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
var cfg redisConfig
err := r.Bind("redis", &cfg)
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
assert.Contains(t, err.Error(), "App.Prepare")
}
-266
View File
@@ -1,266 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// convertValue coerces a raw value coming from the configuration file or an
// environment variable into the declared Go type.
func convertValue(raw any, typ reflect.Type) (any, error) {
if typ == durationType {
return convertDuration(raw)
}
switch typ.Kind() {
case reflect.Bool:
return convertBool(raw)
case reflect.String:
return convertString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return convertNumeric(raw, typ, signedNumbers)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return convertNumeric(raw, typ, unsignedNumbers)
case reflect.Float32, reflect.Float64:
return convertNumeric(raw, typ, floatingNumbers)
case reflect.Slice:
return convertSlice(raw, typ)
case reflect.Struct:
return convertStruct(raw, typ)
case reflect.Pointer:
return convertPointer(raw, typ)
default:
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
}
// convertPointer decodes into the element type and returns a non-nil pointer to it.
// Nested pointers are rejected so configuration tags stay one level deep.
func convertPointer(raw any, typ reflect.Type) (any, error) {
elemType := typ.Elem()
if elemType.Kind() == reflect.Pointer {
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
elem, err := convertValue(raw, elemType)
if err != nil {
return nil, err
}
ptr := reflect.New(elemType)
ptr.Elem().Set(reflect.ValueOf(elem))
return ptr.Interface(), nil
}
func convertBool(raw any) (any, error) {
switch v := raw.(type) {
case bool:
return v, nil
case *bool:
if v == nil {
return nil, fmt.Errorf("%w: nil *bool is not a boolean", ErrConfigType)
}
return *v, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
}
return parsed, nil
default:
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
}
}
func convertString(raw any) (any, error) {
switch v := raw.(type) {
case string:
return v, nil
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), nil
default:
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
}
}
// numericString extracts the textual form of a value so environment overrides,
// which always arrive as strings, share one parsing path with file values.
func numericString(raw any) (string, bool) {
switch v := raw.(type) {
case string:
return strings.TrimSpace(v), true
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), true
default:
return "", false
}
}
// numericKind selects which strconv family converts a raw value.
type numericKind int
const (
signedNumbers numericKind = iota
unsignedNumbers
floatingNumbers
)
// convertNumeric parses a raw value into the numeric type declared by typ. The three
// numeric families share one implementation because they differ only in the strconv
// call and the reflect setter.
func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) {
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ)
}
out := reflect.New(typ).Elem()
var err error
switch family {
case signedNumbers:
parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits())
out.SetInt(parsed)
err = parseErr
case unsignedNumbers:
parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits())
out.SetUint(parsed)
err = parseErr
default:
parsed, parseErr := strconv.ParseFloat(text, typ.Bits())
out.SetFloat(parsed)
err = parseErr
}
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
}
return out.Interface(), nil
}
// convertDuration accepts both Go duration strings such as "200ms" and integer
// nanoseconds, mirroring what the previous viper based decoding supported.
func convertDuration(raw any) (any, error) {
if v, ok := raw.(time.Duration); ok {
return v, nil
}
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
}
if parsed, err := time.ParseDuration(text); err == nil {
return parsed, nil
}
nanos, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
}
return time.Duration(nanos), nil
}
// convertSlice promotes a scalar into a single-element slice so that a value such as
// REDIS_ADDR=redis:6379 can populate the redis.addrs list.
func convertSlice(raw any, typ reflect.Type) (any, error) {
items, ok := sliceItems(raw)
if !ok {
items = []any{raw}
}
out := reflect.MakeSlice(typ, 0, len(items))
for _, item := range items {
converted, err := convertValue(item, typ.Elem())
if err != nil {
return nil, err
}
out = reflect.Append(out, reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// sliceItems normalises the several slice shapes a loader may produce.
func sliceItems(raw any) ([]any, bool) {
switch v := raw.(type) {
case []any:
return v, true
case []string:
items := make([]any, len(v))
for i, s := range v {
items[i] = s
}
return items, true
}
rv := reflect.ValueOf(raw)
if rv.IsValid() && rv.Kind() == reflect.Slice {
items := make([]any, rv.Len())
for i := range items {
items[i] = rv.Index(i).Interface()
}
return items, true
}
return nil, false
}
func convertStruct(raw any, typ reflect.Type) (any, error) {
table, ok := asStringMap(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
}
fields, err := walkConfigFields(typ, "")
if err != nil {
return nil, err
}
out := reflect.New(typ).Elem()
for _, f := range fields {
item, present := table[f.path]
if !present || item == nil {
continue
}
converted, err := convertValue(item, f.typ)
if err != nil {
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
}
out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// asStringMap normalises the two map shapes produced by YAML decoders.
func asStringMap(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
return v, true
case map[any]any:
out := make(map[string]any, len(v))
for key, val := range v {
name, ok := key.(string)
if !ok {
return nil, false
}
out[name] = val
}
return out, true
default:
return nil, false
}
}
// fieldNameForPath maps a declared config path back to the Go struct field carrying it.
func fieldNameForPath(t reflect.Type, path string) string {
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Tag.Get("config") == path {
return t.Field(i).Name
}
}
return ""
}
-432
View File
@@ -1,432 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"context"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRouterExtension(t *testing.T) {
r := extpoints.NewRouterRegistry()
require.NotNil(t, r)
mGlobal := "global_middleware"
r.Use(mGlobal)
assert.Equal(t, []any{mGlobal}, r.Middlewares())
// Test root methods
hRoot := "root_handler"
r.GET("/", hRoot)
r.POST("/root_post", hRoot)
r.PUT("/root_put", hRoot)
r.DELETE("/root_del", hRoot)
r.PATCH("/root_patch", hRoot)
r.HEAD("/root_head", hRoot)
r.OPTIONS("/root_opt", hRoot)
anyRootDefs := r.Any("/root_any", hRoot)
assert.Len(t, anyRootDefs, 7)
// Group and Group.Use
mAPI := "api_middleware"
api := r.Group("/api/v1", mAPI)
api.Use("api_extra_middleware")
assert.Len(t, api.Middlewares(), 2)
hList := "list_orders_handler"
hCreate := "create_order_handler"
api.GET("/orders", hList)
api.POST("/orders", hCreate)
mAdmin := "admin_middleware"
admin := api.Group("admin", mAdmin)
hUserGet := "get_user_handler"
hUserPut := "put_user_handler"
hUserDel := "del_user_handler"
hUserPatch := "patch_user_handler"
hUserHead := "head_user_handler"
hUserOptions := "options_user_handler"
admin.GET("/users/:id", hUserGet)
admin.PUT("/users/:id", hUserPut)
admin.DELETE("/users/:id", hUserDel)
admin.PATCH("/users/:id", hUserPatch)
admin.HEAD("/users/:id", hUserHead)
admin.OPTIONS("/users/:id", hUserOptions)
hCustom := "custom_handler"
admin.Handle("CUSTOM", "/custom", hCustom)
hAny := "any_handler"
anyRoutes := admin.Any("/all", hAny)
assert.NotEmpty(t, anyRoutes)
// Group.Routes() returns root routes
assert.Equal(t, r.Routes(), admin.Routes())
routes := r.Routes()
// Verify route paths and middlewares
var foundOrderGet bool
var foundUserPut bool
for _, route := range routes {
if route.Method == "GET" && route.Path == "/api/v1/orders" {
foundOrderGet = true
assert.Equal(t, []any{mAPI, "api_extra_middleware"}, route.Middlewares)
assert.Equal(t, []any{hList}, route.Handlers)
}
if route.Method == "PUT" && route.Path == "/api/v1/admin/users/:id" {
foundUserPut = true
assert.Equal(t, []any{mAPI, "api_extra_middleware", mAdmin}, route.Middlewares)
assert.Equal(t, []any{hUserPut}, route.Handlers)
}
}
assert.True(t, foundOrderGet)
assert.True(t, foundUserPut)
}
func TestRouterGlobalMiddlewareIsNotSnapshottedOntoRoutes(t *testing.T) {
r := extpoints.NewRouterRegistry()
r.GET("/before", "handler")
r.Use("late_global")
r.GET("/after", "handler")
for _, route := range r.Routes() {
if len(route.Middlewares) != 0 {
t.Errorf("route %s %s Middlewares = %v, want none (globals live on Router.Middlewares)",
route.Method, route.Path, route.Middlewares)
}
}
assert.Equal(t, []any{"late_global"}, r.Middlewares())
}
func TestRouterWhitelist(t *testing.T) {
r := extpoints.NewRouterRegistry()
require.NotNil(t, r)
r.RegisterWhitelist(
"/healthz",
"/api/v1/user/login",
"/api/v1/oauth/*",
)
api := r.Group("/api/v1")
api.RegisterWhitelist("/cap/challenge", "/cap/redeem")
whitelist := r.Whitelist()
assert.Contains(t, whitelist, "/healthz")
assert.Contains(t, whitelist, "/api/v1/user/login")
assert.Contains(t, whitelist, "/api/v1/oauth/*")
assert.Contains(t, whitelist, "/api/v1/cap/challenge")
assert.Contains(t, whitelist, "/api/v1/cap/redeem")
// Exact match
assert.True(t, r.IsWhitelisted("/healthz"))
assert.True(t, r.IsWhitelisted("/api/v1/user/login"))
assert.True(t, api.IsWhitelisted("/api/v1/cap/challenge"))
// Wildcard match
assert.True(t, r.IsWhitelisted("/api/v1/oauth/sources"))
assert.True(t, r.IsWhitelisted("/api/v1/oauth/github/authorize"))
// Non-whitelisted
assert.False(t, r.IsWhitelisted("/api/v1/orders"))
assert.False(t, r.IsWhitelisted("/api/v1/user/profile"))
}
func TestMigrationExtension(t *testing.T) {
m := extpoints.NewMigrationRegistry()
require.NotNil(t, m)
fs1 := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")},
}
fs2 := fstest.MapFS{
"custom/001_order.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t2(id int);")},
}
m.Register("auth", fs1)
m.Register("order", fs2, "custom")
// Update existing entry
fs1Updated := fstest.MapFS{
"migrations/002_update.sql": &fstest.MapFile{Data: []byte("ALTER TABLE t1 ADD col int;")},
}
m.Register("auth", fs1Updated, "")
entries := m.Entries()
require.Len(t, entries, 2)
assert.Equal(t, "auth", entries[0].PluginID)
assert.Equal(t, "migrations", entries[0].Dir)
assert.Equal(t, "order", entries[1].PluginID)
assert.Equal(t, "custom", entries[1].Dir)
authEntry, ok := m.Get("auth")
assert.True(t, ok)
assert.Equal(t, "auth", authEntry.PluginID)
_, ok = m.Get("non_existent")
assert.False(t, ok)
}
func TestTaskExtension(t *testing.T) {
tr := extpoints.NewTaskRegistry()
require.NotNil(t, tr)
handler := func(ctx context.Context, payload []byte) error { return nil }
tr.Register("order:cancel_timeout", handler,
extpoints.WithTaskConcurrency(5),
extpoints.WithTaskRetry(3),
extpoints.WithTaskTimeout(10*time.Second),
extpoints.WithTaskMetadata("queue", "critical"),
extpoints.WithTaskType("cancel_timeout"),
extpoints.WithTaskName("取消超时订单"),
extpoints.WithTaskDescription("自动关单"),
extpoints.WithTaskCategory("order"),
extpoints.WithTaskSupportsTime(true),
extpoints.WithTaskQueue("orders"),
extpoints.WithTaskRetryable(true),
nil, // test nil option
)
// Re-register to test update
tr.Register("order:cancel_timeout", handler,
extpoints.WithTaskConcurrency(10),
extpoints.WithTaskRetry(3),
extpoints.WithTaskTimeout(10*time.Second),
extpoints.WithTaskMetadata("queue", "high"),
extpoints.WithTaskType("cancel_timeout"),
extpoints.WithTaskName("取消超时订单"),
extpoints.WithTaskDescription("自动关单"),
extpoints.WithTaskCategory("order"),
extpoints.WithTaskSupportsTime(true),
extpoints.WithTaskQueue("orders"),
extpoints.WithTaskRetryable(true),
)
tasks := tr.Tasks()
require.Len(t, tasks, 1)
assert.Equal(t, "order:cancel_timeout", tasks[0].Pattern)
assert.Equal(t, 10, tasks[0].Concurrency)
assert.Equal(t, "high", tasks[0].Metadata["queue"])
assert.Equal(t, "cancel_timeout", tasks[0].Type)
assert.Equal(t, "取消超时订单", tasks[0].Name)
dto := tasks[0].ToDTO()
assert.Equal(t, "cancel_timeout", dto.Type)
assert.Equal(t, "order:cancel_timeout", dto.AsynqTask)
assert.Equal(t, "取消超时订单", dto.Name)
assert.Equal(t, "取消超时订单", dto.DisplayName)
assert.Equal(t, "自动关单", dto.Description)
assert.Equal(t, "order", dto.Category)
assert.True(t, dto.SupportsTime)
assert.Equal(t, "orders", dto.Queue)
assert.True(t, dto.Retryable)
task, ok := tr.Get("order:cancel_timeout")
assert.True(t, ok)
assert.Equal(t, "order:cancel_timeout", task.Pattern)
byType, ok := tr.Get("cancel_timeout")
assert.True(t, ok, "Get should resolve admin type identifier")
assert.Equal(t, "order:cancel_timeout", byType.Pattern)
_, ok = tr.Get("unknown")
assert.False(t, ok)
}
func TestTaskRegisterRejectsNilHandler(t *testing.T) {
tr := extpoints.NewTaskRegistry()
assert.Panics(t, func() {
tr.Register("broken:task", nil)
})
}
func TestTaskRegisterRejectsDuplicateType(t *testing.T) {
tr := extpoints.NewTaskRegistry()
handler := func(ctx context.Context, payload []byte) error { return nil }
tr.Register("system:cleanup", handler, extpoints.WithTaskType("system_cleanup"))
assert.Panics(t, func() {
tr.Register("admin:system_cleanup", handler, extpoints.WithTaskType("system_cleanup"))
})
}
func TestScheduleExtension(t *testing.T) {
sr := extpoints.NewScheduleRegistry()
require.NotNil(t, sr)
type ReportPayload struct {
Type string `json:"type"`
}
sr.RegisterCron("0 2 * * *", "report:daily_summary", ReportPayload{Type: "daily"})
sr.Register("@every 1h", "cleanup:expired_sessions", nil,
extpoints.WithScheduleOption("retry", 2),
nil, // test nil option
)
// Re-register to test update
sr.RegisterCron("0 3 * * *", "report:daily_summary", ReportPayload{Type: "all"})
schedules := sr.Schedules()
require.Len(t, schedules, 2)
assert.Equal(t, "0 3 * * *", schedules[0].Spec)
assert.Equal(t, "report:daily_summary", schedules[0].TaskType)
assert.Equal(t, ReportPayload{Type: "all"}, schedules[0].Payload)
assert.Equal(t, "@every 1h", schedules[1].Spec)
assert.Equal(t, "cleanup:expired_sessions", schedules[1].TaskType)
assert.Equal(t, 2, schedules[1].Options["retry"])
sched, ok := sr.Get("report:daily_summary")
assert.True(t, ok)
assert.Equal(t, "0 3 * * *", sched.Spec)
_, ok = sr.Get("unknown")
assert.False(t, ok)
}
func TestSettingExtension(t *testing.T) {
sr := extpoints.NewSettingRegistry()
require.NotNil(t, sr)
assert.Panics(t, func() {
sr.Register(extpoints.SettingSchema{}) // empty key panics
})
sr.Register(extpoints.SettingSchema{
Key: "order.auto_cancel_mins",
Default: 15,
Description: "Order auto cancellation timeout in minutes",
Category: "order",
Public: true,
})
// Re-register to test update
sr.Register(extpoints.SettingSchema{
Key: "order.auto_cancel_mins",
Default: 30,
Description: "Updated timeout",
})
sr.Register(extpoints.SettingSchema{
Key: "auth.jwt_secret",
Default: "default-secret",
Description: "JWT secret key",
Category: "auth",
ReadOnly: true,
})
schemas := sr.Schemas()
require.Len(t, schemas, 2)
schema, ok := sr.Get("order.auto_cancel_mins")
assert.True(t, ok)
assert.Equal(t, 30, schema.Default)
_, ok = sr.Get("unknown")
assert.False(t, ok)
}
func TestContextExtensionPointsIntegration(t *testing.T) {
ctx := core.NewContext(context.Background())
require.NotNil(t, ctx.Events())
require.NotNil(t, ctx.Router())
require.NotNil(t, ctx.Migrations())
require.NotNil(t, ctx.Tasks())
require.NotNil(t, ctx.Task())
require.NotNil(t, ctx.Schedules())
require.NotNil(t, ctx.Schedule())
require.NotNil(t, ctx.Settings())
require.NotNil(t, ctx.Setting())
// Register from child context and verify shared application registry
child := ctx.Fork()
child.Router().GET("/ping", "pong_handler")
child.Task().Register("sample:task", "handler")
child.Schedule().RegisterCron("@hourly", "sample:cron", nil)
child.Settings().Register(extpoints.SettingSchema{
Key: "app.name",
Default: "Wavelet",
})
assert.Len(t, ctx.Router().Routes(), 1)
assert.Len(t, ctx.Tasks().Tasks(), 1)
assert.Len(t, ctx.Schedules().Schedules(), 1)
assert.Len(t, ctx.Settings().Schemas(), 1)
// Child and root events
var eventReceived bool
child.Events().On("app:ready", func() {
eventReceived = true
})
err := ctx.Events().Emit(context.Background(), "app:ready", nil)
assert.NoError(t, err)
assert.True(t, eventReceived)
}
func TestExtensionPointsUnregister(t *testing.T) {
ctx := core.NewContext(context.Background())
// 1. Router unregister (routes, middlewares, whitelist)
rd := ctx.Router().GET("/temp", "temp_handler")
assert.Greater(t, rd.ID, uint64(0))
assert.Len(t, ctx.Router().Routes(), 1)
assert.True(t, ctx.Router().Unregister("GET", "/temp"))
assert.Len(t, ctx.Router().Routes(), 0)
rd2 := ctx.Router().POST("/temp2", "temp2_handler")
assert.Len(t, ctx.Router().Routes(), 1)
assert.True(t, ctx.Router().UnregisterByID(rd2.ID))
assert.Len(t, ctx.Router().Routes(), 0)
ctx.Router().Use("mw1")
assert.Len(t, ctx.Router().Middlewares(), 1)
if reg, ok := ctx.Router().(*extpoints.RouterRegistry); ok {
ids := reg.UseWithID("mw2")
assert.Len(t, ctx.Router().Middlewares(), 2)
assert.True(t, ctx.Router().UnregisterMiddlewareByID(ids[0]))
assert.Len(t, ctx.Router().Middlewares(), 1)
}
ctx.Router().RegisterWhitelist("/api/v1/temp/*")
assert.True(t, ctx.Router().IsWhitelisted("/api/v1/temp/item"))
ctx.Router().UnregisterWhitelist("/api/v1/temp/*")
assert.False(t, ctx.Router().IsWhitelisted("/api/v1/temp/item"))
// 2. Task unregister
ctx.Task().Register("temp:task", "handler")
assert.Len(t, ctx.Task().Tasks(), 1)
assert.True(t, ctx.Task().Unregister("temp:task"))
assert.Len(t, ctx.Task().Tasks(), 0)
// 3. Schedule unregister
ctx.Schedule().RegisterCron("@hourly", "temp:cron", nil)
assert.Len(t, ctx.Schedule().Schedules(), 1)
assert.True(t, ctx.Schedule().Unregister("temp:cron"))
assert.Len(t, ctx.Schedule().Schedules(), 0)
// 4. Setting unregister
ctx.Settings().Register(extpoints.SettingSchema{Key: "temp.key", Default: 1})
assert.Len(t, ctx.Settings().Schemas(), 1)
assert.True(t, ctx.Settings().Unregister("temp.key"))
assert.Len(t, ctx.Settings().Schemas(), 0)
// 5. Migration unregister
fsys := fstest.MapFS{"001.sql": &fstest.MapFile{Data: []byte("-- migration")}}
ctx.Migrations().Register("temp_plugin", fsys)
assert.Len(t, ctx.Migrations().Entries(), 1)
assert.True(t, ctx.Migrations().Unregister("temp_plugin"))
assert.Len(t, ctx.Migrations().Entries(), 0)
}
-94
View File
@@ -1,94 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package extpoints defines extension points for router, migrations, tasks, schedules, and settings.
package extpoints
import (
"io/fs"
"sync"
)
// MigrationEntry contains the migration filesystem and configuration for a plugin.
type MigrationEntry struct {
PluginID string
FS fs.FS
Dir string
}
// MigrationExtension defines the interface for registering and querying plugin migrations.
type MigrationExtension interface {
Register(pluginID string, fsys fs.FS, dir ...string)
Unregister(pluginID string) bool
Entries() []MigrationEntry
Get(pluginID string) (MigrationEntry, bool)
}
// MigrationRegistry implements MigrationExtension.
type MigrationRegistry struct {
mu sync.RWMutex
entries []MigrationEntry
lookup map[string]MigrationEntry
}
// NewMigrationRegistry creates a new MigrationRegistry.
func NewMigrationRegistry() *MigrationRegistry {
return &MigrationRegistry{
lookup: make(map[string]MigrationEntry),
}
}
// Register registers an embedded migration filesystem for a plugin.
func (m *MigrationRegistry) Register(pluginID string, fsys fs.FS, dir ...string) {
m.mu.Lock()
defer m.mu.Unlock()
migrationDir := "migrations"
if len(dir) > 0 && dir[0] != "" {
migrationDir = dir[0]
}
entry := MigrationEntry{
PluginID: pluginID,
FS: fsys,
Dir: migrationDir,
}
// If entry already exists, update in-place; otherwise append
if _, exists := m.lookup[pluginID]; exists {
for i, e := range m.entries {
if e.PluginID == pluginID {
m.entries[i] = entry
break
}
}
} else {
m.entries = append(m.entries, entry)
}
m.lookup[pluginID] = entry
}
// Unregister removes a registered migration entry by plugin ID.
func (m *MigrationRegistry) Unregister(pluginID string) bool {
return unregisterEntry(&m.mu, m.lookup, &m.entries, pluginID, func(e MigrationEntry) bool {
return e.PluginID == pluginID
})
}
// Entries returns a copy of all registered migration entries in registration order.
func (m *MigrationRegistry) Entries() []MigrationEntry {
m.mu.RLock()
defer m.mu.RUnlock()
res := make([]MigrationEntry, len(m.entries))
copy(res, m.entries)
return res
}
// Get retrieves the migration entry for a specific plugin ID.
func (m *MigrationRegistry) Get(pluginID string) (MigrationEntry, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.lookup[pluginID]
return e, ok
}
-22
View File
@@ -1,22 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"slices"
"sync"
)
func unregisterEntry[T any](mu *sync.RWMutex, lookup map[string]T, list *[]T, key string, matches func(T) bool) bool {
mu.Lock()
defer mu.Unlock()
if _, exists := lookup[key]; !exists {
return false
}
delete(lookup, key)
*list = slices.DeleteFunc(*list, matches)
return true
}
-635
View File
@@ -1,635 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"strings"
"sync"
)
// RouteDefinition holds the metadata and handler list for a single HTTP route.
type RouteDefinition struct {
ID uint64
Method string
Path string
Handlers []any
Middlewares []any
}
// RouterExtension defines the interface for registering routes and middlewares.
type RouterExtension interface {
Use(middlewares ...any)
Group(prefix string, middlewares ...any) RouterExtension
Handle(method, path string, handlers ...any) RouteDefinition
// HandleRaw joins path with the group prefix but preserves a trailing slash,
// so `/resource` and `/resource/` can coexist as distinct routes.
HandleRaw(method, path string, handlers ...any) RouteDefinition
// BasePath reports this group's absolute prefix ("" for the root registry).
BasePath() string
GET(path string, handlers ...any) RouteDefinition
POST(path string, handlers ...any) RouteDefinition
PUT(path string, handlers ...any) RouteDefinition
DELETE(path string, handlers ...any) RouteDefinition
PATCH(path string, handlers ...any) RouteDefinition
HEAD(path string, handlers ...any) RouteDefinition
OPTIONS(path string, handlers ...any) RouteDefinition
Any(path string, handlers ...any) []RouteDefinition
Routes() []RouteDefinition
Middlewares() []any
Unregister(method, path string) bool
UnregisterByID(id uint64) bool
UnregisterMiddlewareByID(id uint64) bool
RegisterWhitelist(patterns ...string)
UnregisterWhitelist(patterns ...string)
Whitelist() []string
IsWhitelisted(path string) bool
}
// middlewareDefinition holds an assigned ID and handler for registered middleware.
type middlewareDefinition struct {
ID uint64
Handler any
}
// RouterRegistry implements RouterExtension as the root route and middleware collector.
type RouterRegistry struct {
mu sync.RWMutex
nextID uint64
nextMWID uint64
routes []RouteDefinition
middlewares []middlewareDefinition
whitelist PathWhitelist
}
// NewRouterRegistry creates a new root router collector.
func NewRouterRegistry() *RouterRegistry {
return &RouterRegistry{}
}
// Use registers global middlewares to the router.
func (r *RouterRegistry) Use(middlewares ...any) {
r.UseWithID(middlewares...)
}
// UseWithID registers global middlewares to the router and returns their assigned IDs.
func (r *RouterRegistry) UseWithID(middlewares ...any) []uint64 {
r.mu.Lock()
defer r.mu.Unlock()
ids := make([]uint64, 0, len(middlewares))
for _, mw := range middlewares {
r.nextMWID++
r.middlewares = append(r.middlewares, middlewareDefinition{
ID: r.nextMWID,
Handler: mw,
})
ids = append(ids, r.nextMWID)
}
return ids
}
// UnregisterMiddlewareByID removes a registered global middleware by its unique ID.
func (r *RouterRegistry) UnregisterMiddlewareByID(id uint64) bool {
r.mu.Lock()
defer r.mu.Unlock()
for i, mw := range r.middlewares {
if mw.ID == id {
r.middlewares = append(r.middlewares[:i], r.middlewares[i+1:]...)
return true
}
}
return false
}
// Middlewares returns a copy of registered root middleware handlers.
func (r *RouterRegistry) Middlewares() []any {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]any, len(r.middlewares))
for i, mw := range r.middlewares {
res[i] = mw.Handler
}
return res
}
// Group creates a new RouteGroup under the router.
func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtension {
return &RouterGroup{
registry: r,
prefix: cleanPath(prefix),
middlewares: middlewares,
}
}
// Handle registers a route with a custom HTTP method and handlers.
func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition {
return r.addRoute(method, cleanPath(path), handlers...)
}
// addRoute appends a route whose path is already normalised.
func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
r.mu.Lock()
defer r.mu.Unlock()
r.nextID++
rd := RouteDefinition{
ID: r.nextID,
Method: strings.ToUpper(method),
Path: fullPath,
Handlers: handlers,
// Global Router.Use middlewares are applied at HTTP Start from
// Router.Middlewares(), so late-registered plugins still wrap earlier routes.
}
r.routes = append(r.routes, rd)
return rd
}
// Unregister removes a route matching method and path from the registry.
func (r *RouterRegistry) Unregister(method, path string) bool {
r.mu.Lock()
defer r.mu.Unlock()
targetMethod := strings.ToUpper(method)
targetPath := cleanPath(path)
for i, rd := range r.routes {
if rd.Method == targetMethod && rd.Path == targetPath {
r.routes = append(r.routes[:i], r.routes[i+1:]...)
return true
}
}
return false
}
// UnregisterByID removes a route by its unique ID.
func (r *RouterRegistry) UnregisterByID(id uint64) bool {
r.mu.Lock()
defer r.mu.Unlock()
for i, rd := range r.routes {
if rd.ID == id {
r.routes = append(r.routes[:i], r.routes[i+1:]...)
return true
}
}
return false
}
// GET registers a GET route.
func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition {
return r.Handle("GET", path, handlers...)
}
// POST registers a POST route.
func (r *RouterRegistry) POST(path string, handlers ...any) RouteDefinition {
return r.Handle("POST", path, handlers...)
}
// PUT registers a PUT route.
func (r *RouterRegistry) PUT(path string, handlers ...any) RouteDefinition {
return r.Handle("PUT", path, handlers...)
}
// DELETE registers a DELETE route.
func (r *RouterRegistry) DELETE(path string, handlers ...any) RouteDefinition {
return r.Handle("DELETE", path, handlers...)
}
// PATCH registers a PATCH route.
func (r *RouterRegistry) PATCH(path string, handlers ...any) RouteDefinition {
return r.Handle("PATCH", path, handlers...)
}
// HEAD registers a HEAD route.
func (r *RouterRegistry) HEAD(path string, handlers ...any) RouteDefinition {
return r.Handle("HEAD", path, handlers...)
}
// OPTIONS registers an OPTIONS route.
func (r *RouterRegistry) OPTIONS(path string, handlers ...any) RouteDefinition {
return r.Handle("OPTIONS", path, handlers...)
}
// Any registers a route for standard HTTP methods.
func (r *RouterRegistry) Any(path string, handlers ...any) []RouteDefinition {
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
defs := make([]RouteDefinition, 0, len(methods))
for _, m := range methods {
defs = append(defs, r.Handle(m, path, handlers...))
}
return defs
}
// Routes returns a copy of all collected RouteDefinitions.
func (r *RouterRegistry) Routes() []RouteDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]RouteDefinition, len(r.routes))
copy(res, r.routes)
return res
}
// RegisterWhitelist adds path patterns to the whitelist.
func (r *RouterRegistry) RegisterWhitelist(patterns ...string) {
r.whitelist.Add(patterns...)
}
// UnregisterWhitelist removes path patterns from the whitelist.
func (r *RouterRegistry) UnregisterWhitelist(patterns ...string) {
r.whitelist.Remove(patterns...)
}
// Whitelist returns a copy of all registered whitelist path patterns.
func (r *RouterRegistry) Whitelist() []string {
return r.whitelist.Patterns()
}
// IsWhitelisted checks if the given path matches any registered whitelist pattern.
func (r *RouterRegistry) IsWhitelisted(path string) bool {
return r.whitelist.Match(path)
}
// RouterGroup represents a scoped route group with a path prefix and group-level middlewares.
type RouterGroup struct {
registry *RouterRegistry
prefix string
middlewares []any
}
// Use adds middlewares to this group.
func (g *RouterGroup) Use(middlewares ...any) {
g.middlewares = append(g.middlewares, middlewares...)
}
// Group creates a nested RouteGroup.
func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension {
combinedPrefix := joinPaths(g.prefix, prefix)
combinedMiddlewares := make([]any, 0, len(g.middlewares)+len(middlewares))
combinedMiddlewares = append(combinedMiddlewares, g.middlewares...)
combinedMiddlewares = append(combinedMiddlewares, middlewares...)
return &RouterGroup{
registry: g.registry,
prefix: combinedPrefix,
middlewares: combinedMiddlewares,
}
}
// Handle registers a route under this group.
func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition {
return g.addRoute(method, joinPaths(g.prefix, path), handlers...)
}
// addRoute appends a route under this group whose path is already joined.
func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
g.registry.mu.Lock()
defer g.registry.mu.Unlock()
allMiddlewares := append([]any(nil), g.middlewares...)
g.registry.nextID++
rd := RouteDefinition{
ID: g.registry.nextID,
Method: strings.ToUpper(method),
Path: fullPath,
Handlers: handlers,
Middlewares: allMiddlewares,
}
g.registry.routes = append(g.registry.routes, rd)
return rd
}
// Unregister removes a route under this group prefix matching method and path.
func (g *RouterGroup) Unregister(method, path string) bool {
fullPath := joinPaths(g.prefix, path)
return g.registry.Unregister(method, fullPath)
}
// UnregisterByID removes a route by its unique ID.
func (g *RouterGroup) UnregisterByID(id uint64) bool {
return g.registry.UnregisterByID(id)
}
// UnregisterMiddlewareByID removes a middleware by ID via the root registry.
func (g *RouterGroup) UnregisterMiddlewareByID(id uint64) bool {
return g.registry.UnregisterMiddlewareByID(id)
}
// GET registers a GET route in this group.
func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition {
return g.Handle("GET", path, handlers...)
}
// POST registers a POST route in this group.
func (g *RouterGroup) POST(path string, handlers ...any) RouteDefinition {
return g.Handle("POST", path, handlers...)
}
// PUT registers a PUT route in this group.
func (g *RouterGroup) PUT(path string, handlers ...any) RouteDefinition {
return g.Handle("PUT", path, handlers...)
}
// DELETE registers a DELETE route in this group.
func (g *RouterGroup) DELETE(path string, handlers ...any) RouteDefinition {
return g.Handle("DELETE", path, handlers...)
}
// PATCH registers a PATCH route in this group.
func (g *RouterGroup) PATCH(path string, handlers ...any) RouteDefinition {
return g.Handle("PATCH", path, handlers...)
}
// HEAD registers a HEAD route in this group.
func (g *RouterGroup) HEAD(path string, handlers ...any) RouteDefinition {
return g.Handle("HEAD", path, handlers...)
}
// OPTIONS registers an OPTIONS route in this group.
func (g *RouterGroup) OPTIONS(path string, handlers ...any) RouteDefinition {
return g.Handle("OPTIONS", path, handlers...)
}
// Any registers a route in this group for standard HTTP methods.
func (g *RouterGroup) Any(path string, handlers ...any) []RouteDefinition {
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
defs := make([]RouteDefinition, 0, len(methods))
for _, m := range methods {
defs = append(defs, g.Handle(m, path, handlers...))
}
return defs
}
// Routes returns all routes from the parent registry.
func (g *RouterGroup) Routes() []RouteDefinition {
return g.registry.Routes()
}
// Middlewares returns a copy of the group's middlewares.
func (g *RouterGroup) Middlewares() []any {
res := make([]any, len(g.middlewares))
copy(res, g.middlewares)
return res
}
// RegisterWhitelist adds path patterns under this group prefix to the whitelist.
func (g *RouterGroup) RegisterWhitelist(patterns ...string) {
for _, p := range patterns {
g.registry.RegisterWhitelist(joinPaths(g.prefix, p))
}
}
// UnregisterWhitelist removes path patterns under this group prefix from the whitelist.
func (g *RouterGroup) UnregisterWhitelist(patterns ...string) {
for _, p := range patterns {
g.registry.UnregisterWhitelist(joinPaths(g.prefix, p))
}
}
// Whitelist returns a copy of all registered whitelist path patterns.
func (g *RouterGroup) Whitelist() []string {
return g.registry.Whitelist()
}
// IsWhitelisted checks if the given path matches any registered whitelist pattern.
func (g *RouterGroup) IsWhitelisted(path string) bool {
return g.registry.IsWhitelisted(path)
}
func cleanPath(p string) string {
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if len(p) > 1 && strings.HasSuffix(p, "/") {
p = strings.TrimSuffix(p, "/")
}
return p
}
func joinPaths(base, relative string) string {
if base == "" || base == "/" {
return cleanPath(relative)
}
if relative == "" || relative == "/" {
return cleanPath(base)
}
base = strings.TrimSuffix(base, "/")
relative = strings.TrimPrefix(relative, "/")
return cleanPath(base + "/" + relative)
}
// MatchPathPattern checks if a URL path matches a pattern (supports exact match and wildcards).
func MatchPathPattern(pattern, path string) bool {
pattern = cleanPath(pattern)
path = cleanPath(path)
if pattern == path {
return true
}
// Suffix wildcard: /api/v1/oauth/* matches /api/v1/oauth and /api/v1/oauth/...
if strings.HasSuffix(pattern, "/*") {
prefix := strings.TrimSuffix(pattern, "/*")
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
// Parameter wildcard: /api/v1/oauth/*/authorize or /api/v1/oauth/:source/authorize
patternParts := strings.Split(pattern, "/")
pathParts := strings.Split(path, "/")
if len(patternParts) == len(pathParts) {
matched := true
for i, part := range patternParts {
if part == "*" || strings.HasPrefix(part, ":") {
continue
}
if part != pathParts[i] {
matched = false
break
}
}
if matched {
return true
}
}
return false
}
// compiledPattern holds a whitelist pattern with its per-request work already done.
type compiledPattern struct {
raw string // normalised pattern, reported back by Patterns
prefix string // non-empty when the pattern ends in "/*"
parts []string // normalised pattern split on "/"
}
// PathWhitelist matches request paths against a fixed set of patterns.
//
// Patterns are registered once during plugin Apply and never change afterwards, so
// normalising and splitting them on every request is wasted work. PathWhitelist
// does that once at registration instead. The zero value is ready to use.
type PathWhitelist struct {
mu sync.RWMutex
patterns []compiledPattern
}
// NewPathWhitelist returns a whitelist pre-populated with the given patterns.
func NewPathWhitelist(patterns ...string) *PathWhitelist {
w := &PathWhitelist{}
w.Add(patterns...)
return w
}
// compilePatterns normalises and splits each pattern once, ahead of any request.
func compilePatterns(patterns []string) []compiledPattern {
compiled := make([]compiledPattern, 0, len(patterns))
for _, p := range patterns {
clean := cleanPath(p)
cp := compiledPattern{raw: clean, parts: strings.Split(clean, "/")}
if strings.HasSuffix(clean, "/*") {
cp.prefix = strings.TrimSuffix(clean, "/*")
}
compiled = append(compiled, cp)
}
return compiled
}
// Add appends patterns, normalising and splitting each now rather than per request.
func (w *PathWhitelist) Add(patterns ...string) {
if len(patterns) == 0 {
return
}
compiled := compilePatterns(patterns)
w.mu.Lock()
defer w.mu.Unlock()
w.patterns = append(w.patterns, compiled...)
}
// Replace discards any existing patterns and installs the given ones, for callers
// whose configuration is a full swap rather than an incremental registration.
func (w *PathWhitelist) Replace(patterns ...string) {
compiled := compilePatterns(patterns)
w.mu.Lock()
defer w.mu.Unlock()
w.patterns = compiled
}
// Remove removes matching patterns from the whitelist.
func (w *PathWhitelist) Remove(patterns ...string) {
if len(patterns) == 0 {
return
}
targets := make(map[string]struct{}, len(patterns))
for _, p := range patterns {
targets[cleanPath(p)] = struct{}{}
}
w.mu.Lock()
defer w.mu.Unlock()
filtered := w.patterns[:0]
for _, p := range w.patterns {
if _, remove := targets[p.raw]; !remove {
filtered = append(filtered, p)
}
}
w.patterns = filtered
}
// Match reports whether path matches any registered pattern. Equivalent to calling
// MatchPathPattern for every pattern, except the path is normalised and split once.
func (w *PathWhitelist) Match(path string) bool {
clean := cleanPath(path)
pathParts := strings.Split(clean, "/")
w.mu.RLock()
defer w.mu.RUnlock()
for i := range w.patterns {
p := &w.patterns[i]
if p.raw == clean {
return true
}
// A suffix wildcard matches both the bare prefix and anything below it.
if p.prefix != "" && (clean == p.prefix || strings.HasPrefix(clean, p.prefix+"/")) {
return true
}
if len(p.parts) != len(pathParts) {
continue
}
if matchSegments(p.parts, pathParts) {
return true
}
}
return false
}
// matchSegments compares an already-split pattern against an already-split path.
func matchSegments(patternParts, pathParts []string) bool {
for i, part := range patternParts {
if part == "*" || strings.HasPrefix(part, ":") {
continue
}
if part != pathParts[i] {
return false
}
}
return true
}
// Patterns returns a copy of the registered patterns in registration order.
func (w *PathWhitelist) Patterns() []string {
w.mu.RLock()
defer w.mu.RUnlock()
res := make([]string, len(w.patterns))
for i := range w.patterns {
res[i] = w.patterns[i].raw
}
return res
}
// ─── Raw path registration ────────────────────────────────────────────────────
// ensureLeadingSlash normalises a path to start with exactly one "/" while
// preserving any trailing slash (unlike cleanPath).
func ensureLeadingSlash(p string) string {
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}
// joinPathPreservingTrailing joins a group prefix and a relative path without
// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/".
func joinPathPreservingTrailing(base, relative string) string {
rel := ensureLeadingSlash(relative)
if base == "" || base == "/" {
return rel
}
return strings.TrimSuffix(cleanPath(base), "/") + rel
}
// HandleRaw registers a route on the root registry, preserving a trailing slash.
func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition {
return r.addRoute(method, ensureLeadingSlash(path), handlers...)
}
// BasePath returns "" because the root registry has no prefix.
func (r *RouterRegistry) BasePath() string { return "" }
// HandleRaw registers a route under this group, preserving a trailing slash.
func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition {
return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...)
}
// BasePath returns this group's absolute prefix.
func (g *RouterGroup) BasePath() string { return g.prefix }
-52
View File
@@ -1,52 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "testing"
// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由,
// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
r := &RouterRegistry{}
g := r.Group("/api/v1/nodes")
if got := g.BasePath(); got != "/api/v1/nodes" {
t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes")
}
slashless := g.Handle("GET", "")
slashed := g.HandleRaw("GET", "/")
if slashless.Path != "/api/v1/nodes" {
t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes")
}
if slashed.Path != "/api/v1/nodes/" {
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
}
if slashed.ID == slashless.ID {
t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both")
}
if got := len(r.Routes()); got != 2 {
t.Errorf("registry routes = %d, want 2", got)
}
if !r.UnregisterByID(slashed.ID) {
t.Error("UnregisterByID(HandleRaw route) = false, want true")
}
if got := len(r.Routes()); got != 1 {
t.Errorf("routes after unregister = %d, want 1", got)
}
}
// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。
func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) {
r := &RouterRegistry{}
if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" {
t.Errorf("path = %q, want %q", got, "/health/")
}
if got := r.HandleRaw("POST", "submit").Path; got != "/submit" {
t.Errorf("path = %q, want %q", got, "/submit")
}
if got := r.BasePath(); got != "" {
t.Errorf("registry BasePath() = %q, want empty", got)
}
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
// ScheduleDefinition holds the configuration for a scheduled/cron task.
type ScheduleDefinition struct {
Spec string
TaskType string
Payload any
Options map[string]any
}
// ScheduleOption configures a ScheduleDefinition.
type ScheduleOption func(*ScheduleDefinition)
// WithScheduleOption adds a custom option to the schedule definition.
func WithScheduleOption(key string, val any) ScheduleOption {
return func(sd *ScheduleDefinition) {
if sd.Options == nil {
sd.Options = make(map[string]any)
}
sd.Options[key] = val
}
}
// ScheduleExtension defines the interface for registering and querying cron/scheduled tasks.
type ScheduleExtension interface {
Register(spec, taskType string, payload any, opts ...ScheduleOption)
RegisterCron(spec, taskType string, payload any, opts ...ScheduleOption)
Schedules() []ScheduleDefinition
Get(taskType string) (ScheduleDefinition, bool)
Unregister(taskType string) bool
}
// ScheduleRegistry collects and manages schedule registrations.
type ScheduleRegistry struct {
mu sync.RWMutex
schedules []ScheduleDefinition
lookup map[string]ScheduleDefinition
}
// NewScheduleRegistry creates a new schedule registry.
func NewScheduleRegistry() *ScheduleRegistry {
return &ScheduleRegistry{
lookup: make(map[string]ScheduleDefinition),
}
}
// Register adds a schedule definition.
func (s *ScheduleRegistry) Register(spec, taskType string, payload any, opts ...ScheduleOption) {
s.mu.Lock()
defer s.mu.Unlock()
sd := ScheduleDefinition{
Spec: spec,
TaskType: taskType,
Payload: payload,
Options: make(map[string]any),
}
for _, opt := range opts {
if opt != nil {
opt(&sd)
}
}
if _, exists := s.lookup[taskType]; exists {
for i, item := range s.schedules {
if item.TaskType == taskType {
s.schedules[i] = sd
break
}
}
} else {
s.schedules = append(s.schedules, sd)
}
s.lookup[taskType] = sd
}
// RegisterCron is an alias for Register.
func (s *ScheduleRegistry) RegisterCron(spec, taskType string, payload any, opts ...ScheduleOption) {
s.Register(spec, taskType, payload, opts...)
}
// Unregister removes a registered schedule definition by its task type.
func (s *ScheduleRegistry) Unregister(taskType string) bool {
return unregisterEntry(&s.mu, s.lookup, &s.schedules, taskType, func(item ScheduleDefinition) bool {
return item.TaskType == taskType
})
}
// Schedules returns a copy of all registered ScheduleDefinitions.
func (s *ScheduleRegistry) Schedules() []ScheduleDefinition {
s.mu.RLock()
defer s.mu.RUnlock()
res := make([]ScheduleDefinition, len(s.schedules))
copy(res, s.schedules)
return res
}
// Get retrieves a schedule definition by its task type.
func (s *ScheduleRegistry) Get(taskType string) (ScheduleDefinition, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sd, ok := s.lookup[taskType]
return sd, ok
}
-88
View File
@@ -1,88 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
// SettingSchema defines the configuration schema and metadata for a system or plugin setting.
type SettingSchema struct {
Key string `json:"key"`
Default any `json:"default"`
Description string `json:"description"`
Type string `json:"type,omitempty"`
ReadOnly bool `json:"read_only,omitempty"`
Public bool `json:"public,omitempty"`
Category string `json:"category,omitempty"`
Validation string `json:"validation,omitempty"`
}
// SettingExtension defines the interface for registering and querying setting configuration schemas.
type SettingExtension interface {
Register(schema SettingSchema)
Schemas() []SettingSchema
Get(key string) (SettingSchema, bool)
Unregister(key string) bool
}
// SettingRegistry collects and manages setting configuration schemas.
type SettingRegistry struct {
mu sync.RWMutex
schemas []SettingSchema
lookup map[string]SettingSchema
}
// NewSettingRegistry creates a new setting schema registry.
func NewSettingRegistry() *SettingRegistry {
return &SettingRegistry{
lookup: make(map[string]SettingSchema),
}
}
// Register registers a SettingSchema into the registry.
// Panics if the schema Key is empty.
func (s *SettingRegistry) Register(schema SettingSchema) {
if schema.Key == "" {
panic("core/extpoints: setting schema key cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.lookup[schema.Key]; exists {
for i, item := range s.schemas {
if item.Key == schema.Key {
s.schemas[i] = schema
break
}
}
} else {
s.schemas = append(s.schemas, schema)
}
s.lookup[schema.Key] = schema
}
// Unregister removes a registered SettingSchema by its key.
func (s *SettingRegistry) Unregister(key string) bool {
return unregisterEntry(&s.mu, s.lookup, &s.schemas, key, func(item SettingSchema) bool {
return item.Key == key
})
}
// Schemas returns a copy of all registered SettingSchemas.
func (s *SettingRegistry) Schemas() []SettingSchema {
s.mu.RLock()
defer s.mu.RUnlock()
res := make([]SettingSchema, len(s.schemas))
copy(res, s.schemas)
return res
}
// Get retrieves a SettingSchema by its key.
func (s *SettingRegistry) Get(key string) (SettingSchema, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
schema, ok := s.lookup[key]
return schema, ok
}
-338
View File
@@ -1,338 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"Wavelet/core/contracts"
"fmt"
"reflect"
"sync"
"time"
)
// TaskDefinition holds the definition and runtime options for an asynchronous background task.
type TaskDefinition struct {
Pattern string
Type string
Name string
DisplayName string
Description string
Category string
SupportsTime bool
Retryable bool
Queue string
Params []contracts.TaskParamDTO
Handler any
Concurrency int
Retry int
Timeout time.Duration
Metadata map[string]any
}
// TaskOption configures a TaskDefinition.
type TaskOption func(*TaskDefinition)
// WithTaskType sets the admin task type identifier.
func WithTaskType(taskType string) TaskOption {
return func(td *TaskDefinition) {
td.Type = taskType
}
}
// WithTaskName sets the task human-readable display name.
func WithTaskName(name string) TaskOption {
return func(td *TaskDefinition) {
td.Name = name
if td.DisplayName == "" {
td.DisplayName = name
}
}
}
// WithTaskDisplayName sets the task display name.
func WithTaskDisplayName(displayName string) TaskOption {
return func(td *TaskDefinition) {
td.DisplayName = displayName
if td.Name == "" {
td.Name = displayName
}
}
}
// WithTaskDescription sets the task description.
func WithTaskDescription(desc string) TaskOption {
return func(td *TaskDefinition) {
td.Description = desc
}
}
// WithTaskCategory sets the task category grouping.
func WithTaskCategory(category string) TaskOption {
return func(td *TaskDefinition) {
td.Category = category
}
}
// WithTaskSupportsTime sets whether the task supports time range filtering.
func WithTaskSupportsTime(supports bool) TaskOption {
return func(td *TaskDefinition) {
td.SupportsTime = supports
}
}
// WithTaskQueue sets the task queue.
func WithTaskQueue(queue string) TaskOption {
return func(td *TaskDefinition) {
td.Queue = queue
}
}
// WithTaskRetryable sets whether the task is retryable.
func WithTaskRetryable(retryable bool) TaskOption {
return func(td *TaskDefinition) {
td.Retryable = retryable
}
}
// WithTaskParams sets the task parameter definitions.
func WithTaskParams(params ...contracts.TaskParamDTO) TaskOption {
return func(td *TaskDefinition) {
td.Params = append(td.Params, params...)
}
}
// WithTaskMeta sets all task metadata from a TaskMetaDTO.
func WithTaskMeta(meta contracts.TaskMetaDTO) TaskOption {
return func(td *TaskDefinition) {
if meta.Type != "" {
td.Type = meta.Type
}
if meta.Name != "" {
td.Name = meta.Name
}
if meta.DisplayName != "" {
td.DisplayName = meta.DisplayName
}
if meta.Description != "" {
td.Description = meta.Description
}
if meta.Category != "" {
td.Category = meta.Category
}
td.SupportsTime = meta.SupportsTime
if meta.Queue != "" {
td.Queue = meta.Queue
}
td.Retryable = meta.Retryable
if meta.MaxRetry > 0 {
td.Retry = meta.MaxRetry
}
if meta.Timeout > 0 {
td.Timeout = meta.Timeout
}
if len(meta.Params) > 0 {
td.Params = append([]contracts.TaskParamDTO(nil), meta.Params...)
}
}
}
// WithTaskConcurrency sets the concurrency limit for the task.
func WithTaskConcurrency(concurrency int) TaskOption {
return func(td *TaskDefinition) {
td.Concurrency = concurrency
}
}
// WithTaskRetry sets the maximum retry count for the task.
func WithTaskRetry(retry int) TaskOption {
return func(td *TaskDefinition) {
td.Retry = retry
}
}
// WithTaskTimeout sets the execution timeout for the task.
func WithTaskTimeout(timeout time.Duration) TaskOption {
return func(td *TaskDefinition) {
td.Timeout = timeout
}
}
// WithTaskMetadata adds a key-value pair to the task metadata.
func WithTaskMetadata(key string, val any) TaskOption {
return func(td *TaskDefinition) {
if td.Metadata == nil {
td.Metadata = make(map[string]any)
}
td.Metadata[key] = val
}
}
// ToDTO converts TaskDefinition to contracts.TaskMetaDTO.
func (td TaskDefinition) ToDTO() contracts.TaskMetaDTO {
taskType := td.Type
if taskType == "" {
taskType = td.Pattern
}
name := td.Name
if name == "" {
name = td.DisplayName
}
if name == "" {
name = td.Pattern
}
displayName := td.DisplayName
if displayName == "" {
displayName = name
}
queue := td.Queue
if queue == "" {
queue = "default"
}
retryable := td.Retryable
if !retryable && td.Retry > 0 {
retryable = true
}
return contracts.TaskMetaDTO{
Type: taskType,
AsynqTask: td.Pattern,
Name: name,
DisplayName: displayName,
Description: td.Description,
Category: td.Category,
SupportsTime: td.SupportsTime,
Params: td.Params,
MaxRetry: td.Retry,
Timeout: td.Timeout,
Queue: queue,
Retryable: retryable,
}
}
// TaskExtension defines the interface for registering and querying background task handlers.
type TaskExtension interface {
Register(pattern string, handler any, opts ...TaskOption)
Tasks() []TaskDefinition
Get(pattern string) (TaskDefinition, bool)
Unregister(pattern string) bool
}
// TaskRegistry collects and manages task registrations.
type TaskRegistry struct {
mu sync.RWMutex
tasks []TaskDefinition
lookup map[string]TaskDefinition
}
// NewTaskRegistry creates a new task registry.
func NewTaskRegistry() *TaskRegistry {
return &TaskRegistry{
lookup: make(map[string]TaskDefinition),
}
}
// Register registers a task pattern and its handler with optional configuration.
// A nil handler panics. A non-empty Type that is already used by another pattern panics.
func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) {
t.mu.Lock()
defer t.mu.Unlock()
if isNilTaskHandler(handler) {
panic(fmt.Sprintf("extpoints: nil handler for task pattern %q", pattern))
}
td := TaskDefinition{
Pattern: pattern,
Handler: handler,
Metadata: make(map[string]any),
}
for _, opt := range opts {
if opt != nil {
opt(&td)
}
}
if td.Type == "" {
td.Type = pattern
}
for _, item := range t.tasks {
if item.Pattern == pattern {
continue
}
if item.Type == td.Type {
panic(fmt.Sprintf("extpoints: duplicate task type %q (patterns %q and %q)", td.Type, item.Pattern, pattern))
}
}
if existing, exists := t.lookup[pattern]; exists {
if existing.Type != "" && existing.Type != pattern {
delete(t.lookup, existing.Type)
}
for i, item := range t.tasks {
if item.Pattern == pattern {
t.tasks[i] = td
break
}
}
} else {
t.tasks = append(t.tasks, td)
}
t.lookup[pattern] = td
if td.Type != pattern {
t.lookup[td.Type] = td
}
}
func isNilTaskHandler(handler any) bool {
if handler == nil {
return true
}
v := reflect.ValueOf(handler)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Unregister removes a registered task definition by its pattern.
func (t *TaskRegistry) Unregister(pattern string) bool {
t.mu.Lock()
defer t.mu.Unlock()
td, ok := t.lookup[pattern]
if !ok {
return false
}
delete(t.lookup, td.Pattern)
if td.Type != "" && td.Type != td.Pattern {
delete(t.lookup, td.Type)
}
filtered := t.tasks[:0]
for _, item := range t.tasks {
if item.Pattern != td.Pattern {
filtered = append(filtered, item)
}
}
t.tasks = filtered
return true
}
// Tasks returns a copy of all registered TaskDefinitions.
func (t *TaskRegistry) Tasks() []TaskDefinition {
t.mu.RLock()
defer t.mu.RUnlock()
res := make([]TaskDefinition, len(t.tasks))
copy(res, t.tasks)
return res
}
// Get retrieves a task definition by its pattern or admin type identifier.
func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
td, ok := t.lookup[pattern]
return td, ok
}
-157
View File
@@ -1,157 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"Wavelet/core/extpoints"
"testing"
)
// whitelistEquivalencePatterns and paths cover every matching rule MatchPathPattern
// implements, so PathWhitelist.Match can be pinned against the behaviour it replaces.
var (
whitelistEquivalencePatterns = []string{
"/api/v1/user/login",
"/api/v1/oauth/*",
"/api/v1/cap/:source/authorize",
"/api/v1/files/*/download",
"/",
"login",
"/api/v1/x/",
"",
}
whitelistEquivalencePaths = []string{
"/api/v1/user/login",
"/api/v1/user/login/",
"/api/v1/oauth/callback",
"/api/v1/oauth",
"/api/v1/oauth/a/b",
"/api/v1/cap/github/authorize",
"/api/v1/cap/:source/authorize",
"/api/v1/files/abc/download",
"/api/v1/files/a/b/download",
"/",
"login",
"/login",
"",
"/api/v1/x",
"/api/v1/x/y",
}
)
// legacyMatch reproduces the per-request loop every whitelist caller used before
// PathWhitelist existed.
func legacyMatch(patterns []string, path string) bool {
for _, pattern := range patterns {
if extpoints.MatchPathPattern(pattern, path) {
return true
}
}
return false
}
func TestPathWhitelistMatchesLegacyLoop(t *testing.T) {
for _, pattern := range whitelistEquivalencePatterns {
wl := extpoints.NewPathWhitelist(pattern)
for _, path := range whitelistEquivalencePaths {
got := wl.Match(path)
want := legacyMatch([]string{pattern}, path)
if got != want {
t.Errorf("pattern %q path %q: Match=%v, legacy=%v", pattern, path, got, want)
}
}
}
}
func TestPathWhitelistAccumulatesAcrossRegistration(t *testing.T) {
wl := extpoints.NewPathWhitelist("/api/v1/a")
wl.Add("/api/v1/b/*")
if !wl.Match("/api/v1/a") {
t.Error("first registration lost")
}
if !wl.Match("/api/v1/b/deep") {
t.Error("second registration lost")
}
if wl.Match("/api/v1/c") {
t.Error("path outside both registrations matched")
}
got := wl.Patterns()
want := []string{"/api/v1/a", "/api/v1/b/*"}
if len(got) != len(want) {
t.Fatalf("Patterns() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("Patterns()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestPathWhitelistReplaceDropsPreviousPatterns(t *testing.T) {
wl := extpoints.NewPathWhitelist("/api/v1/a")
wl.Replace("/api/v1/b")
if wl.Match("/api/v1/a") {
t.Error("Replace kept a pattern it should have discarded")
}
if !wl.Match("/api/v1/b") {
t.Error("Replace did not install the new pattern")
}
}
// whitelistBenchPatterns mirrors a realistically sized auth whitelist.
var whitelistBenchPatterns = []string{
"/api/v1/user/login",
"/api/v1/auth/refresh",
"/api/v1/oauth/*",
"/api/v1/cap/*",
"/api/v1/public/config",
"/api/v1/health",
"/api/v1/uploads/:id/file",
"/api/v1/notify/webhook/:channel",
"/login",
"/api/v1/access-tokens/:id/revoke",
}
// TestPathWhitelistAllocationReduction asserts the point of pre-compiling: a single
// Match must allocate less than the legacy per-pattern loop it replaces.
func TestPathWhitelistAllocationReduction(t *testing.T) {
wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...)
legacy := testing.Benchmark(func(b *testing.B) {
for b.Loop() {
_ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file")
}
})
compiled := testing.Benchmark(func(b *testing.B) {
for b.Loop() {
_ = wl.Match("/api/v1/uploads/9/file")
}
})
legacyAlloc := legacy.AllocsPerOp()
compiledAlloc := compiled.AllocsPerOp()
t.Logf("legacy %d allocs/op, PathWhitelist %d allocs/op", legacyAlloc, compiledAlloc)
if compiledAlloc >= legacyAlloc {
t.Errorf("PathWhitelist allocated %d/op, want fewer than legacy %d/op", compiledAlloc, legacyAlloc)
}
}
func BenchmarkLegacyWhitelistMatch(b *testing.B) {
for b.Loop() {
_ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file")
}
}
func BenchmarkPathWhitelistMatch(b *testing.B) {
wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...)
b.ResetTimer()
for b.Loop() {
_ = wl.Match("/api/v1/uploads/9/file")
}
}
-181
View File
@@ -1,181 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"reflect"
"sync"
)
// FiberState represents the lifecycle status of a plugin instance in the Cordis micro-kernel.
type FiberState string
const (
// FiberPending indicates the plugin is waiting for its required dependencies to be provided.
FiberPending FiberState = "PENDING"
// FiberLoading indicates the plugin is currently running its Apply mounting phase.
FiberLoading FiberState = "LOADING"
// FiberActive indicates the plugin is fully mounted, active, and operating without error.
FiberActive FiberState = "ACTIVE"
// FiberUnloading indicates the plugin is tearing down its scoped effects in LIFO order.
FiberUnloading FiberState = "UNLOADING"
// FiberDisposed indicates the plugin has been completely unmounted and its context disposed.
FiberDisposed FiberState = "DISPOSED"
// FiberSkipped indicates the plugin never activated because its configuration gate
// evaluated to false, so an alternative provider took over.
FiberSkipped FiberState = "SKIPPED"
)
// Fiber wraps a Plugin instance with a dedicated scoped Context and manages its
// reactive lifecycle state machine according to Cordis spatiotemporal composability principles.
type Fiber struct {
mu sync.RWMutex
plugin Plugin
state FiberState
ctx *Context
deps []reflect.Type
err error
}
// NewFiber creates a new Fiber for the specified plugin with a child scoped context.
func NewFiber(rootCtx *Context, plugin Plugin) *Fiber {
var deps []reflect.Type
if depPlugin, ok := plugin.(DependentPlugin); ok {
deps = depPlugin.Inject()
}
return &Fiber{
plugin: plugin,
state: FiberPending,
ctx: rootCtx.Fork(),
deps: deps,
}
}
// Plugin returns the underlying Plugin instance.
func (f *Fiber) Plugin() Plugin {
return f.plugin
}
// Name returns the unique identifier of the plugin.
func (f *Fiber) Name() string {
if f.plugin == nil {
return ""
}
return f.plugin.Name()
}
// State returns the current lifecycle state of this Fiber.
func (f *Fiber) State() FiberState {
f.mu.RLock()
defer f.mu.RUnlock()
return f.state
}
// Context returns the dedicated scoped Context for this Fiber.
func (f *Fiber) Context() *Context {
return f.ctx
}
// Dependencies returns the list of required service reflect.Types.
func (f *Fiber) Dependencies() []reflect.Type {
res := make([]reflect.Type, len(f.deps))
copy(res, f.deps)
return res
}
// Error returns the latest mounting or unmounting error, if any.
func (f *Fiber) Error() error {
f.mu.RLock()
defer f.mu.RUnlock()
return f.err
}
// DependenciesSatisfied checks if all declared dependencies are present in the target Context container.
func (f *Fiber) DependenciesSatisfied(ctx *Context) bool {
if len(f.deps) == 0 {
return true
}
container := ctx.Container()
for _, dep := range f.deps {
if _, err := container.resolve(dep); err != nil {
return false
}
}
return true
}
// Load executes the plugin mounting lifecycle: PENDING -> LOADING -> ACTIVE.
func (f *Fiber) Load() error {
f.mu.Lock()
if f.state != FiberPending {
f.mu.Unlock()
return nil
}
f.state = FiberLoading
f.err = nil
f.mu.Unlock()
if err := f.plugin.Apply(f.ctx); err != nil {
f.mu.Lock()
f.err = fmt.Errorf("fiber %q: apply failed: %w", f.Name(), err)
f.state = FiberPending
_ = f.ctx.Dispose()
f.mu.Unlock()
return err
}
f.mu.Lock()
f.state = FiberActive
f.mu.Unlock()
return nil
}
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
// Active plugins are left untouched, which makes the call safe to replay on every
// reconciliation pass, including for plugins mounted after the first gate evaluation.
func (f *Fiber) Skip() error {
f.mu.Lock()
if f.state != FiberPending {
f.mu.Unlock()
return nil
}
f.state = FiberSkipped
f.mu.Unlock()
return f.ctx.Dispose()
}
// Skipped reports whether the plugin was excluded by its configuration gate.
func (f *Fiber) Skipped() bool {
return f.State() == FiberSkipped
}
// Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED.
func (f *Fiber) Unload() error {
f.mu.Lock()
if f.state != FiberActive && f.state != FiberLoading {
f.mu.Unlock()
return nil
}
f.state = FiberUnloading
f.mu.Unlock()
err := f.ctx.Dispose()
f.mu.Lock()
f.state = FiberDisposed
if err != nil {
f.err = err
}
f.mu.Unlock()
return err
}

Some files were not shown because too many files have changed in this diff Show More