Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d0a0272
feat(web): add SavedFilter model, uninstall purge, and OSearch favorites
buke Aug 8, 2026
4d0e41e
refactor(web): move SavedFilter field validation onto @Constraint
buke Aug 8, 2026
a457d7d
feat(modules): wire web shell planning, depends, and SavedFilter auth…
buke Aug 9, 2026
80c8058
fix(web): address SavedFilter PR review security and UX gaps
buke Aug 9, 2026
7cd60a4
fix(lifecycle): finish EnsureOrder modules and preserve constraint st…
buke Aug 9, 2026
d83f1a7
fix(web): seed SavedFilter method/field ACL and harden favorites UX
buke Aug 9, 2026
a87f1c4
fix(test): rebuild choysumtest request context before each case
buke Aug 9, 2026
96dbaf0
fix(core): prefer status-bearing validation issues and stop after Cho…
buke Aug 9, 2026
a6d4917
test(coverage): cover SavedFilter patch gaps across Go and web
buke Aug 9, 2026
f76aec1
fix(web): address cubic review on SavedFilter authz and shell races
buke Aug 9, 2026
1204aee
fix(test): harden review-flagged flaky and vacuous coverage
buke Aug 9, 2026
581a38a
fix(test): align SF11 expectCode hint with record-rule violation message
buke Aug 9, 2026
7657f53
fix(test): address DeepSource unused params and builtin shadowing
buke Aug 9, 2026
195e805
fix(test): blank unused planner peek name parameters
buke Aug 9, 2026
e08dd5a
test(coverage): close remaining SavedFilter patch gaps to 100%
buke Aug 9, 2026
2803869
test(coverage): close SavedFilter web patch gaps to 100%
buke Aug 9, 2026
3288179
test(coverage): close remaining SavedFilter patch partials
buke Aug 9, 2026
b450741
test(coverage): hit final SavedFilter patch partials
buke Aug 9, 2026
1fd8272
fix(test): address CodeRabbit and DeepSource review nits
buke Aug 9, 2026
77ee102
fix(test): blank unused skipWebShellPeekOrigin receivers
buke Aug 9, 2026
8a90d94
refactor(web): dial meta.MetaModel with typed createServiceByModel
buke Aug 9, 2026
e7e692a
refactor(web): inline SavedFilter actor id from BaseModel.userId
buke Aug 9, 2026
9f75ce5
refactor(meta): drop LookupEffective multi-row pick on read path
buke Aug 9, 2026
c67e491
fix(auth): keep company-switch drafts across open-panel token refresh
buke Aug 9, 2026
17b7377
feat(web): scope SavedFilter uniqueness by route ScopeKey
buke Aug 10, 2026
0873a35
test(coverage): close SavedFilter ScopeKey and auth patch gaps
buke Aug 10, 2026
6b2e3fb
test(coverage): hit remaining ScopeKey and favorite-name branches
buke Aug 10, 2026
386a402
test(coverage): hit SavedFilter clearDefaults empty actor branch
buke Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .cursor/rules/module-data-seeds.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
description: Ownership rules for module bootstrap/demo data seeds (xml_id, cross-app RR)
globs: modules/**/data/**/*.json,modules/**/demo/**/*.json,modules/**/package.json
alwaysApply: false
---

# Module Data Seed Ownership

When adding or moving records in `modules/*/data/*.json` or `modules/*/demo/*.json`:

1. **Master / business rows** belong in the module that owns the model (e.g. `base.Company` → `modules/base/data`).
2. **Domain-targeted authz** (`RoleRecordRule` / `RoleFieldRule` / `RoleMethodAccess` scoped to a **domain model** via `MetaModelId` / `modelRef`, or a **domain-owned** logical name) belongs in that **domain module**. Use `application: "auth"`; `module` / xml_id stay under the applying module (example: `web/data/bootstrap.json` → `web.rrr_…` for `web.SavedFilter`).
3. **Platform roles, global break-glass, and platform LogicalModel default packs** stay in `modules/auth/data`: `base.user` / `sys.admin` / terminology role, global RR/FR/RMA/RUI, auth User/Token/Session packs, and cross-app logical defaults for `FieldDefault` / `AppSetting` / `TranslationTerm` (same registry as core’s `registerLogicalModelName`).
4. Rule (2) requires the seeding module to install **after** auth: it must `depends` on `auth`, and `auth` must **not** depend on it. Modules that auth already depends on (`base`, `meta`) cannot own authz seeds without a cycle — leave those app-level gift packs in auth (or use a future late-apply mechanism).
5. **Do not** pile new **domain-model** RR/RFR/RMA into `auth/data`. Prefer the SavedFilter pattern under the domain module’s `data/bootstrap.json`. Platform logical defaults (rule 3) are the exception and belong in auth.

Do **not** add `web` to domain `depends`. The install/upgrade planner auto-includes the web SPA shell when a module declares `entryPoints.web` (opt out with CLI `--no-web`).

## Shape reminders

- `record.module` must equal the applying module (or be omitted).
- `record.application` may target another app’s model (cross-app seeding); omit to default to the owner app.
- `record.model` is the **short** name only (not `app.Model`).
- Prefer `modelRef` / `ref` / `refBy` for stable links; do not hardcode row ids.
9 changes: 4 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Modules live in the local `./modules` dir (auto-detected because cwd contains
`modules/`). Install them before running; install is idempotent:

```bash
./choysum install core web base auth meta task partner
./choysum install core base task meta auth document web partner
./choysum run --config config.yaml # serves http://localhost:9527 ( / redirects to /web/ )
```

Expand All @@ -39,10 +39,9 @@ Gotchas:
registered. Using `development`/`production` fails with
`scope factory not registered`. The built-in default is already `default`, so
running with no config works too; `config.yaml` here just enables `hotReload`.
- Do **not** use `--with-demo` for `base`: its demo record hits
`NOT NULL constraint failed: base_company.timezone` and aborts the install.
Install without `--with-demo`; the non-demo bootstrap seed (roles + `admin`
user + `base.company_main`) is applied regardless.
- Bootstrap seeds (roles + `admin` + `base.company_main`) apply without
`--with-demo`. Avoid `--with-demo` for `base`: `base.company_demo` currently
omits required `CurrencyId` and aborts the install.
- Default DB is embedded SQLite at `.choysum/choysum.sqlite`; no external DB
needed. Postgres/MySQL and S3 document storage are optional.

Expand Down
7 changes: 5 additions & 2 deletions cmd/cmd_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (

func newInstallCmd(envGetter func() scope.Scope) *cobra.Command {
var withDemo bool
var noWeb bool
var cliCompatVersion string
cmd := &cobra.Command{
Use: "install <module|module@version> [<module|module@version>...]",
Expand Down Expand Up @@ -119,8 +120,9 @@ func newInstallCmd(envGetter func() scope.Scope) *cobra.Command {

installScope.Logger().Debug("module install started", "input", rootInput)
if installErr := lifecycle.InstallModule(ctx, installScope, compilerExecutor, lifecycle.InstallModuleRequest{
Input: rootInput,
WithDemo: withDemo,
Input: rootInput,
WithDemo: withDemo,
SkipWebShell: noWeb,
}); installErr != nil {
return xfmt.Errorf("error installing module %s: %w", rootInput, installErr)
}
Expand All @@ -141,6 +143,7 @@ func newInstallCmd(envGetter func() scope.Scope) *cobra.Command {
},
}
cmd.Flags().BoolVar(&withDemo, "with-demo", false, "Load demo data declared by package.json")
cmd.Flags().BoolVar(&noWeb, "no-web", false, "Skip auto-installing the web SPA shell when a module declares entryPoints.web")
cmd.Flags().StringVar(&cliCompatVersion, "cli-compat-version", "", "override CLI compatibility version for module compatibility checks")
return cmd
}
Expand Down
4 changes: 3 additions & 1 deletion cmd/cmd_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command {
var withDemo bool
var noWeb bool
var cliCompatVersion string
cmd := &cobra.Command{
Use: "upgrade <module|module@version> [<module|module@version>...]",
Expand Down Expand Up @@ -163,7 +164,7 @@ func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command {
for _, plan := range plans {
currentInput = plan.requestedInput
upgradeScope.Logger().Debug("module upgrade started", "input", plan.resolvedInput)
if err := moduleLifecycle.Upgrade(ctx, lifecycle.UpgradeRequest{Input: plan.resolvedInput, WithDemo: withDemo}); err != nil {
if err := moduleLifecycle.Upgrade(ctx, lifecycle.UpgradeRequest{Input: plan.resolvedInput, WithDemo: withDemo, SkipWebShell: noWeb}); err != nil {
_ = compilerExecutor.Stop()
exitUpgradeError(currentInput, xfmt.Errorf("error upgrading module %s: %w", plan.requestedInput, err))
}
Expand All @@ -172,6 +173,7 @@ func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command {
},
}
cmd.Flags().BoolVar(&withDemo, "with-demo", false, "Load demo data declared by package.json")
cmd.Flags().BoolVar(&noWeb, "no-web", false, "Skip auto-installing a missing web SPA shell when upgrading a module with entryPoints.web")
cmd.Flags().StringVar(&cliCompatVersion, "cli-compat-version", "", "override CLI compatibility version for module compatibility checks")
return cmd
}
121 changes: 87 additions & 34 deletions internal/bootstrap/service/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,49 @@ func (c *coordinator) withInstallTransaction(
return err
}

// applyMinimalInstallFetchProgress updates bootstrap stage detail/spinner for registry fetch.
// Empty moduleName becomes "module" so messages stay readable when origin omits the name.
func applyMinimalInstallFetchProgress(
markDetail func(detail string),
setMessage func(message string),
stage origincontract.FetchProgressStage,
moduleName string,
) {
moduleName = strings.TrimSpace(moduleName)
if moduleName == "" {
moduleName = "module"
}
switch stage {
case origincontract.FetchProgressStageDownload:
markDetail("downloading module package: " + moduleName + "...")
setMessage(fmt.Sprintf("%s: downloading from registry...", moduleName))
case origincontract.FetchProgressStageVerify:
markDetail("verifying module package integrity: " + moduleName + "...")
setMessage(fmt.Sprintf("%s: verifying package...", moduleName))
case origincontract.FetchProgressStageExtract:
markDetail("extracting module package: " + moduleName + "...")
setMessage(fmt.Sprintf("%s: extracting package...", moduleName))
default:
// Keep existing stage detail if unknown progress stage is received.
}
}

// bindMinimalInstallFetchProgressReporter adapts stage detail/spinner setters to an origin fetch reporter.
func bindMinimalInstallFetchProgressReporter(
markDetail func(detail string),
setMessage func(message string),
) func(stage origincontract.FetchProgressStage, moduleName string) {
return func(stage origincontract.FetchProgressStage, moduleName string) {
applyMinimalInstallFetchProgress(markDetail, setMessage, stage, moduleName)
}
}

// installMinimalModulesFn is the InstallModule entry used by bootstrap minimal install (overridable in tests).
var installMinimalModulesFn = lifecycle.InstallModule
Comment thread
buke marked this conversation as resolved.

// newMinimalInstallExecutor builds the JS executor for bootstrap minimal install (overridable in tests).
var newMinimalInstallExecutor = jsexecutor.NewCompilerExecutor

func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operationID string) error {
if c.runtimeScope == nil {
return newBootstrapError(bootstrapErrCodeRuntimePrepare, "scope is not available", nil)
Expand All @@ -523,35 +566,21 @@ func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operatio
updateFetchProgressMessage := func(message string) {
spinnerTicker.SetMessage(message)
}
markFetchDetail := c.minimalInstallFetchDetailMarker(operationID)

installCtx = origincontract.WithFetchProgressReporter(installCtx, func(stage origincontract.FetchProgressStage, moduleName string) {
moduleName = strings.TrimSpace(moduleName)
if moduleName == "" {
moduleName = "core module"
}
switch stage {
case origincontract.FetchProgressStageDownload:
c.store.markStageDetail(operationID, "downloading module package: "+moduleName+"...")
updateFetchProgressMessage(fmt.Sprintf("%s: downloading from registry...", moduleName))
case origincontract.FetchProgressStageVerify:
c.store.markStageDetail(operationID, "verifying module package integrity: "+moduleName+"...")
updateFetchProgressMessage(fmt.Sprintf("%s: verifying package...", moduleName))
case origincontract.FetchProgressStageExtract:
c.store.markStageDetail(operationID, "extracting module package: "+moduleName+"...")
updateFetchProgressMessage(fmt.Sprintf("%s: extracting package...", moduleName))
default:
// Keep existing stage detail if unknown progress stage is received.
}
})
installCtx = origincontract.WithFetchProgressReporter(
installCtx,
bindMinimalInstallFetchProgressReporter(markFetchDetail, updateFetchProgressMessage),
)

c.store.markStageDetail(operationID, "resolving core module installation plan...")
spinnerTicker.SetMessage("document: preparing metadata tables")
c.store.markStageDetail(operationID, "resolving meta module installation plan...")
spinnerTicker.SetMessage("meta: preparing module installation...")

installScope := c.runtimeScope.WithContext(installCtx)
if installScope == nil {
installScope = c.runtimeScope
}
executor, err := jsexecutor.NewCompilerExecutor(installScope)
executor, err := newMinimalInstallExecutor(installScope)
if err != nil {
return c.classifyModuleInstallError(progress, installTimeout, err)
}
Expand All @@ -560,28 +589,54 @@ func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operatio
}
defer executor.Stop()

installErr := lifecycle.InstallModule(installCtx, installScope, executor, lifecycle.InstallModuleRequest{
Input: "document",
return runMinimalMetaModuleInstall(
installCtx,
installScope,
executor,
installMinimalModulesFn,
func() { c.store.markStageDetail(operationID, "meta module installation completed") },
func(installErr error) error {
return c.classifyModuleInstallError(progress, installTimeout, installErr)
},
)
}

// minimalInstallFetchDetailMarker returns the stage-detail setter used by fetch progress reporting.
func (c *coordinator) minimalInstallFetchDetailMarker(operationID string) func(detail string) {
return func(detail string) {
c.store.markStageDetail(operationID, detail)
}
}

// runMinimalMetaModuleInstall installs meta and marks the completed stage (testable without full bootstrap).
func runMinimalMetaModuleInstall(
installCtx context.Context,
installScope scope.Scope,
executor jsexecutor.ScriptExecutor,
installFn func(context.Context, scope.Scope, jsexecutor.ScriptExecutor, lifecycle.InstallModuleRequest, ...lifecycle.Option) error,
markCompleted func(),
classify func(error) error,
) error {
installErr := installFn(installCtx, installScope, executor, lifecycle.InstallModuleRequest{
Input: "meta",
WithDemo: false,
})
if installErr != nil {
return c.classifyModuleInstallError(progress, installTimeout, installErr)
return classify(installErr)
}

c.store.markStageDetail(operationID, "core module installation completed")

markCompleted()
return nil
}

func (c *coordinator) classifyModuleInstallError(progress *logger.ProgressLine, installTimeout time.Duration, installErr error) error {
if progress != nil {
progress.Done("✗", "core module installation failed")
progress.Done("✗", "meta module installation failed")
}
if errors.Is(installErr, context.DeadlineExceeded) {
return newBootstrapError(
bootstrapErrCodeModuleInstallTimeout,
"module installation timed out after "+installTimeout.String()+". "+
"Check your network connection or place the required modules (document and its dependencies) in ModulesPath.",
"Check your network connection or place the required modules (meta, the web shell, and their dependencies) in ModulesPath.",
installErr,
)
}
Expand Down Expand Up @@ -649,14 +704,12 @@ func (c *coordinator) defaultUpdateAdminAndMarker(ctx context.Context, input ini
}

var model meta.Model
lookedUp, err := modmeta.LookupEffectiveModel(txScope.Session().DB, "auth", "User")
if err != nil {
if modmeta.IsEffectiveModelNotFound(err) || errors.Is(err, gorm.ErrRecordNotFound) {
if err := txScope.Session().DB.Where("application = ? AND name = ?", "auth", "User").First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errBootstrapAdminModelNotFound
}
return err
}
model = *lookedUp

if strings.TrimSpace(model.ModelTable) == "" {
return errBootstrapAdminModelTableMissing
Expand Down
Loading