Compare commits
9 Commits
7710-bundl
...
ai/sync-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c86964df | ||
|
|
0c12852e5a | ||
|
|
9b19cd2616 | ||
|
|
1f101077d6 | ||
|
|
62d7c0e479 | ||
|
|
740392941d | ||
|
|
d4528e18ea | ||
|
|
3d47eae87b | ||
|
|
90a1e9bdd3 |
4
.github/actions/setup/action.yml
vendored
@@ -39,8 +39,10 @@ runs:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
|
||||
- name: Ensure Lage cache directory exists
|
||||
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
run: mkdir -p "$WORKING_DIRECTORY/.lage"
|
||||
shell: bash
|
||||
env:
|
||||
WORKING_DIRECTORY: ${{ inputs.working-directory }}
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: ${{ inputs.cache == 'true' }}
|
||||
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
# Only run on PR comments from CodeRabbit bot
|
||||
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
environment: ai-release-notes
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
1
.github/workflows/docs-spelling.yml
vendored
@@ -146,6 +146,7 @@ jobs:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
environment: docs-spelling
|
||||
if: ${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
|
||||
29
.github/workflows/electron-master.yml
vendored
@@ -100,10 +100,11 @@ jobs:
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
RELEASE_NOTES: |
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.process_version.outputs.version }})
|
||||
|
||||
## Desktop releases
|
||||
@@ -114,13 +115,27 @@ jobs:
|
||||
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="12" height="1" alt="" />
|
||||
<a href="https://flathub.org/apps/com.actualbudget.actual"><img width="165" style="margin-left:12px;" alt="Get it on Flathub" src="https://flathub.org/api/badge?locale=en" /></a>
|
||||
</p>
|
||||
files: |
|
||||
run: |
|
||||
# The matrix runs three OS jobs in parallel against one release;
|
||||
# only ignore the "already exists" error that the race losers hit.
|
||||
if ! create_output=$(gh release create "$TAG" --draft --title "$TAG" --notes "$RELEASE_NOTES" 2>&1); then
|
||||
if [[ "$create_output" != *already_exists* ]]; then
|
||||
echo "$create_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
shopt -s extglob nullglob
|
||||
files=(
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/!(Actual-windows).exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
packages/desktop-electron/dist/*.appx
|
||||
)
|
||||
if [ ${#files[@]} -gt 0 ]; then
|
||||
gh release upload "$TAG" --clobber "${files[@]}"
|
||||
fi
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.process_version.outputs.version }}
|
||||
|
||||
@@ -12,6 +12,7 @@ permissions:
|
||||
jobs:
|
||||
extract-and-upload-i18n-strings:
|
||||
runs-on: ubuntu-latest
|
||||
environment: i18n
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
steps:
|
||||
- name: Check out main repository
|
||||
|
||||
@@ -11,21 +11,21 @@ jobs:
|
||||
needs-votes:
|
||||
if: ${{ github.event.label.name == 'feature' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
|
||||
with:
|
||||
labels: needs votes
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Add needs votes label
|
||||
run: gh issue edit "$ISSUE_NUMBER" --add-label "needs votes"
|
||||
- name: Add reactions
|
||||
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
reactions: '+1'
|
||||
- name: Create comment
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
env:
|
||||
COMMENT_BODY: |
|
||||
:sparkles: Thanks for sharing your idea! :sparkles:
|
||||
|
||||
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
Don't forget to upvote the top comment with 👍!
|
||||
|
||||
<!-- feature-auto-close-comment -->
|
||||
run: gh issue comment "$ISSUE_NUMBER" --body "$COMMENT_BODY"
|
||||
- name: Close Issue
|
||||
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh issue close "$ISSUE_NUMBER"
|
||||
|
||||
1
.github/workflows/release-notes.yml
vendored
@@ -14,6 +14,7 @@ concurrency:
|
||||
jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pr-automation
|
||||
steps:
|
||||
- name: Check if triggered by bot
|
||||
id: bot-check
|
||||
|
||||
1
.github/workflows/vrt-update-apply.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
||||
apply-vrt-updates:
|
||||
name: Apply VRT Updates
|
||||
runs-on: ubuntu-latest
|
||||
environment: pr-automation
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
@@ -193,7 +193,7 @@ export function BalanceWithCarryover({
|
||||
<div>
|
||||
{
|
||||
{
|
||||
type: longGoalValue === 1 ? t('Long') : t('Template'),
|
||||
type: longGoalValue === 1 ? t('Goal') : t('Automation'),
|
||||
} as TransObjectLiteral
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ export function AutomationErrorTitle({
|
||||
return <Trans>Schedule not found</Trans>;
|
||||
case 'refill-no-cap':
|
||||
return <Trans>Refill needs a balance cap</Trans>;
|
||||
case 'limit-no-contributor':
|
||||
return <Trans>Balance cap needs a contributing automation</Trans>;
|
||||
case 'percentage-out-of-range':
|
||||
return <Trans>Percentage out of range</Trans>;
|
||||
case 'percentage-no-source':
|
||||
@@ -53,7 +55,9 @@ export function AutomationErrorShort({
|
||||
<Trans>Pick a schedule</Trans>
|
||||
);
|
||||
case 'refill-no-cap':
|
||||
return <Trans>Add a balance cap above</Trans>;
|
||||
return <Trans>Add a balance cap</Trans>;
|
||||
case 'limit-no-contributor':
|
||||
return <Trans>Add an automation that contributes funds</Trans>;
|
||||
case 'percentage-out-of-range':
|
||||
return (
|
||||
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
|
||||
@@ -100,6 +104,14 @@ export function AutomationErrorDetail({
|
||||
added to use as the target.
|
||||
</Trans>
|
||||
);
|
||||
case 'limit-no-contributor':
|
||||
return (
|
||||
<Trans>
|
||||
A balance cap on its own does nothing. Add a contributing automation
|
||||
(such as a fixed amount, save by date, or whatever is left) so the cap
|
||||
has something to clamp.
|
||||
</Trans>
|
||||
);
|
||||
case 'percentage-out-of-range':
|
||||
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
|
||||
case 'percentage-no-source':
|
||||
|
||||
@@ -38,7 +38,7 @@ export function getDisplayTemplateMeta(
|
||||
case 'schedule':
|
||||
return {
|
||||
label: t('Cover schedule'),
|
||||
description: t('Save up for a recurring scheduled transaction.'),
|
||||
description: t('Save up for a scheduled transaction.'),
|
||||
icon: SvgCalendar3,
|
||||
};
|
||||
case 'by':
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DisplayTemplateType } from './constants';
|
||||
export type AutomationErrorKind =
|
||||
| { kind: 'schedule-not-found'; name: string }
|
||||
| { kind: 'refill-no-cap' }
|
||||
| { kind: 'limit-no-contributor' }
|
||||
| { kind: 'percentage-out-of-range'; percent: number }
|
||||
| { kind: 'percentage-no-source' }
|
||||
| { kind: 'percentage-source-not-found'; source: string }
|
||||
@@ -48,6 +49,15 @@ export function validateAutomation(
|
||||
return { kind: 'refill-no-cap' };
|
||||
}
|
||||
return null;
|
||||
case 'limit':
|
||||
if (
|
||||
!allTemplates.some(
|
||||
t => t.type !== 'limit' && t.type !== 'goal' && t.type !== 'error',
|
||||
)
|
||||
) {
|
||||
return { kind: 'limit-no-contributor' };
|
||||
}
|
||||
return null;
|
||||
case 'percentage':
|
||||
if (template.type !== 'percentage') return null;
|
||||
if (!template.category) return { kind: 'percentage-no-source' };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
@@ -126,18 +127,24 @@ export function AutomationListRow({
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: subtitleColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
<Tooltip
|
||||
content={
|
||||
<Text style={{ display: 'block', maxWidth: 320 }}>{subtitle}</Text>
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: subtitleColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</View>
|
||||
{!NON_CONTRIBUTION_TYPES.has(entry.displayType) && (
|
||||
<View
|
||||
|
||||
@@ -100,6 +100,31 @@ describe('migrateTemplatesToAutomations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('expands `#template 0 up to N` into limit + fixed-zero (not refill)', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 4,
|
||||
monthly: 0,
|
||||
limit: {
|
||||
amount: 1000,
|
||||
hold: false,
|
||||
period: 'monthly',
|
||||
},
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
|
||||
expect(result[1].template).toMatchObject({
|
||||
type: 'periodic',
|
||||
amount: 0,
|
||||
directive: 'template',
|
||||
priority: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
|
||||
// `#template 20 up to 200 per week` budgets 20/month and caps at the
|
||||
// limit — the engine's runSimple returns just the monthly value, so
|
||||
|
||||
@@ -47,7 +47,8 @@ export function migrateTemplatesToAutomations(
|
||||
templates.forEach(template => {
|
||||
if (template.type === 'simple') {
|
||||
const monthly = template.monthly;
|
||||
const hasMonthly = monthly != null && monthly !== 0;
|
||||
const hasMonthly =
|
||||
monthly != null && (monthly !== 0 || template.limit != null);
|
||||
|
||||
if (template.limit) {
|
||||
entries.push(
|
||||
@@ -64,10 +65,7 @@ export function migrateTemplatesToAutomations(
|
||||
'limit',
|
||||
),
|
||||
);
|
||||
// The implicit refill only applies to a limit-only simple template
|
||||
// (e.g. `#template up to 200`). When a monthly amount is also set
|
||||
// (`#template 50 up to 200`), the engine just budgets the monthly
|
||||
// amount and clamps to the cap — no top-up to the limit.
|
||||
|
||||
if (!hasMonthly) {
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
@@ -81,6 +79,7 @@ export function migrateTemplatesToAutomations(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMonthly) {
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useDispatch } from '#redux';
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
getZeroCrossingGradientOffset,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
export function BalanceForecast() {
|
||||
@@ -280,6 +281,7 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
|
||||
|
||||
const lowestPoint = forecastData?.lowestBalance;
|
||||
const hasNegativeBalance = chartData.some(d => d.balance < 0);
|
||||
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
|
||||
const todayReferenceDate =
|
||||
granularity === 'Daily'
|
||||
? monthUtils.currentDay()
|
||||
@@ -384,6 +386,37 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: 5, bottom: 10 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="balance-forecast-line-gradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
{zeroCrossingGradientOffset == null ? (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={
|
||||
hasNegativeBalance
|
||||
? theme.errorText
|
||||
: theme.noticeText
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<stop
|
||||
offset={`${zeroCrossingGradientOffset}%`}
|
||||
stopColor={theme.noticeText}
|
||||
/>
|
||||
<stop
|
||||
offset={`${zeroCrossingGradientOffset}%`}
|
||||
stopColor={theme.errorText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -461,14 +494,13 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasNegativeBalance && (
|
||||
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
stroke={
|
||||
hasNegativeBalance
|
||||
? theme.errorText
|
||||
: theme.noticeText
|
||||
}
|
||||
stroke="url(#balance-forecast-line-gradient)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useFormat } from '#hooks/useFormat';
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
getZeroCrossingGradientOffset,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
type BalanceForecastCardProps = {
|
||||
@@ -123,6 +124,9 @@ export function BalanceForecastCard({
|
||||
end: chartRange.end,
|
||||
granularity: 'Monthly',
|
||||
});
|
||||
const hasNegativeBalance = chartData.some(d => d.balance < 0);
|
||||
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
|
||||
const gradientId = `balance-forecast-card-line-gradient-${widgetId}`;
|
||||
const isUpdatingForecast = isFetching && isPlaceholderData;
|
||||
const todayReferenceDate = monthUtils.currentMonth();
|
||||
const showsTodayReferenceLine = chartData.some(
|
||||
@@ -234,6 +238,37 @@ export function BalanceForecastCard({
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
{zeroCrossingGradientOffset == null ? (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={
|
||||
hasNegativeBalance
|
||||
? theme.errorText
|
||||
: theme.noticeText
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<stop
|
||||
offset={`${zeroCrossingGradientOffset}%`}
|
||||
stopColor={theme.noticeText}
|
||||
/>
|
||||
<stop
|
||||
offset={`${zeroCrossingGradientOffset}%`}
|
||||
stopColor={theme.errorText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Tooltip
|
||||
isAnimationActive={false}
|
||||
content={({ active, payload }) => {
|
||||
@@ -272,10 +307,13 @@ export function BalanceForecastCard({
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
)}
|
||||
{hasNegativeBalance && (
|
||||
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
stroke={hasNegative ? theme.errorText : theme.noticeText}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
getZeroCrossingGradientOffset,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
describe('buildBalanceForecastChartData', () => {
|
||||
@@ -257,3 +258,23 @@ describe('countForecastScheduledOccurrences', () => {
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZeroCrossingGradientOffset', () => {
|
||||
it('returns the zero threshold offset when balances cross zero', () => {
|
||||
expect(
|
||||
getZeroCrossingGradientOffset([
|
||||
{ date: '2024-03', balance: 100 },
|
||||
{ date: '2024-04', balance: -100 },
|
||||
]),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('returns null when balances do not cross zero', () => {
|
||||
expect(
|
||||
getZeroCrossingGradientOffset([
|
||||
{ date: '2024-03', balance: 100 },
|
||||
{ date: '2024-04', balance: 50 },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,3 +128,19 @@ export function countForecastScheduledOccurrences(
|
||||
|
||||
return occurrenceKeys.size;
|
||||
}
|
||||
|
||||
export function getZeroCrossingGradientOffset(chartData: ChartDataPoint[]) {
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balances = chartData.map(point => point.balance);
|
||||
const minBalance = Math.min(...balances);
|
||||
const maxBalance = Math.max(...balances);
|
||||
|
||||
if (minBalance >= 0 || maxBalance <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (maxBalance / (maxBalance - minBalance)) * 100;
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ export function AmountInput({
|
||||
onFocus={e => {
|
||||
setIsFocused(true);
|
||||
setValue(format.forEdit(Math.abs(initialValue ?? 0)));
|
||||
setTimeout(() => innerRef.current?.select(), 0);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
|
||||
@@ -152,14 +152,13 @@ async function stagePluginsService(): Promise<void> {
|
||||
}
|
||||
|
||||
async function stagePublicData(): Promise<void> {
|
||||
// The current loot-core worker inlines everything it reads at init, so
|
||||
// new clients never touch `data/`. `default-db.sqlite` is still staged
|
||||
// here for one release so older clients pinned by a stale service-worker
|
||||
// cache can finish populating their in-memory FS after upgrade.
|
||||
const migrationsDest = path.resolve(publicDataDir, 'migrations');
|
||||
await mkdir(publicDataDir, { recursive: true });
|
||||
await rm(migrationsDest, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
cp(path.resolve(lootCoreRoot, 'migrations'), migrationsDest, {
|
||||
recursive: true,
|
||||
}),
|
||||
cp(
|
||||
path.resolve(lootCoreRoot, 'default-db.sqlite'),
|
||||
path.resolve(publicDataDir, 'default-db.sqlite'),
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"#types/*": "./src/types/*.ts",
|
||||
"#mocks/*": "./src/mocks/*.ts",
|
||||
"#migrations/*": "./migrations/*.js",
|
||||
"#default-db.sqlite": "./default-db.sqlite",
|
||||
"#*": "./src/*.ts"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
// `import bytes from './file?bytes'` inlines the file as a Uint8Array. Used
|
||||
// to embed assets that backend init must read without hitting the network —
|
||||
// SharedWorker fetches bypass the service-worker cache in some browser/PWA
|
||||
// contexts, so anything fetched at init breaks offline app load.
|
||||
const BYTES_QUERY = '?bytes';
|
||||
|
||||
export function bytesLoader(): Plugin {
|
||||
return {
|
||||
name: 'loot-core-bytes-loader',
|
||||
enforce: 'pre',
|
||||
async resolveId(id, importer) {
|
||||
if (!id.endsWith(BYTES_QUERY)) return null;
|
||||
const base = id.slice(0, -BYTES_QUERY.length);
|
||||
// Delegate so package imports (`#path/...`) resolve via Vite/Rolldown.
|
||||
const resolved = await this.resolve(base, importer, { skipSelf: true });
|
||||
if (!resolved) return null;
|
||||
return resolved.id + BYTES_QUERY;
|
||||
},
|
||||
load(id) {
|
||||
if (!id.endsWith(BYTES_QUERY)) return null;
|
||||
const filePath = id.slice(0, -BYTES_QUERY.length);
|
||||
const base64 = readFileSync(filePath).toString('base64');
|
||||
// Block-scope the base64 + intermediate binary string so V8 can GC
|
||||
// them after the IIFE returns; only the Uint8Array stays resident.
|
||||
return `const bytes = (() => {
|
||||
const bin = atob(${JSON.stringify(base64)});
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
})();
|
||||
export default bytes;
|
||||
`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -71,11 +71,8 @@ vi.mock('#server/migrate/migrations', async () => {
|
||||
...realMigrations,
|
||||
migrate: async db => {
|
||||
_id = 100_000_000;
|
||||
try {
|
||||
return await realMigrations.migrate(db);
|
||||
} finally {
|
||||
_id = 1;
|
||||
}
|
||||
await realMigrations.migrate(db);
|
||||
_id = 1;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
|
||||
import defaultDbBytes from '#default-db.sqlite?bytes';
|
||||
import { patchFetchForSqlJS } from '#mocks/util';
|
||||
import * as idb from '#platform/server/indexeddb';
|
||||
import * as sqlite from '#platform/server/sqlite';
|
||||
@@ -119,18 +115,3 @@ describe('join', () => {
|
||||
expect(join('/foo', '../bar')).toBe('/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled default-db.sqlite', () => {
|
||||
const onDiskPath = path.resolve(__dirname, '../../../../default-db.sqlite');
|
||||
|
||||
test('matches the on-disk file byte-for-byte', () => {
|
||||
expect(defaultDbBytes).toBeInstanceOf(Uint8Array);
|
||||
// SQLite file header `SQLite format 3\0` — readable failure if a future
|
||||
// loader change accidentally decodes the asset as text.
|
||||
expect(Array.from(defaultDbBytes.slice(0, 16))).toEqual([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61,
|
||||
0x74, 0x20, 0x33, 0x00,
|
||||
]);
|
||||
expect(defaultDbBytes).toEqual(new Uint8Array(readFileSync(onDiskPath)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
// Inlined so backend init does not require network: SharedWorker fetches
|
||||
// bypass the service-worker cache in some browser/PWA contexts, so any
|
||||
// init-path fetch breaks offline app load.
|
||||
import defaultDbBytes from '#default-db.sqlite?bytes';
|
||||
import * as connection from '#platform/server/connection';
|
||||
import { join } from '#platform/server/fs/path-join';
|
||||
import * as idb from '#platform/server/indexeddb';
|
||||
@@ -239,8 +235,29 @@ async function _removeFile(filepath: string) {
|
||||
FS.unlink(filepath);
|
||||
}
|
||||
|
||||
// Load files from the server that should exist by default
|
||||
async function populateDefaultFilesystem() {
|
||||
await _writeFile(bundledDatabasePath, defaultDbBytes);
|
||||
const index = await (
|
||||
await fetch(process.env.PUBLIC_URL + 'data-file-index.txt')
|
||||
).text();
|
||||
const files = index
|
||||
.split('\n')
|
||||
.map(name => name.trim())
|
||||
.filter(name => name !== '');
|
||||
const fetchFile = url => fetch(url).then(res => res.arrayBuffer());
|
||||
|
||||
// This is hardcoded. We know we must create the migrations
|
||||
// directory, it's not worth complicating the index to support
|
||||
// creating arbitrary folders.
|
||||
await mkdir('/migrations');
|
||||
await mkdir('/demo-budget');
|
||||
|
||||
await Promise.all(
|
||||
files.map(async file => {
|
||||
const contents = await fetchFile(process.env.PUBLIC_URL + 'data/' + file);
|
||||
await _writeFile('/' + file, contents);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const populateFileHierarchy = async function () {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
} from './backups';
|
||||
import { classifyUpdateVersionError } from './classify-error';
|
||||
|
||||
const DEMO_BUDGET_ID = '_demo-budget';
|
||||
const TEST_BUDGET_ID = '_test-budget';
|
||||
@@ -552,17 +551,20 @@ async function _loadBudget(id: Budget['id']): Promise<{
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
logger.warn('Error updating', e);
|
||||
const { error, report } = classifyUpdateVersionError(e.message);
|
||||
if (report) {
|
||||
let result;
|
||||
if (e.message.includes('out-of-sync-migrations')) {
|
||||
result = { error: 'out-of-sync-migrations' };
|
||||
} else if (e.message.includes('out-of-sync-data')) {
|
||||
result = { error: 'out-of-sync-data' };
|
||||
} else {
|
||||
captureException(e);
|
||||
}
|
||||
if (error === 'loading-budget') {
|
||||
logger.info('Error updating budget ' + id, e);
|
||||
logger.log('Error updating budget', e);
|
||||
result = { error: 'loading-budget' };
|
||||
}
|
||||
|
||||
await closeBudget();
|
||||
return { error };
|
||||
return result;
|
||||
}
|
||||
|
||||
await db.loadClock();
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { classifyUpdateVersionError } from './classify-error';
|
||||
|
||||
describe('classifyUpdateVersionError', () => {
|
||||
test('out-of-sync-migrations from migrate() maps to the same code, no report', () => {
|
||||
expect(classifyUpdateVersionError('out-of-sync-migrations')).toEqual({
|
||||
error: 'out-of-sync-migrations',
|
||||
report: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('out-of-sync-data maps to the same code, no report', () => {
|
||||
expect(classifyUpdateVersionError('out-of-sync-data')).toEqual({
|
||||
error: 'out-of-sync-data',
|
||||
report: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('schema-out-of-sync (from probeViews) funnels to out-of-sync-migrations and reports', () => {
|
||||
expect(
|
||||
classifyUpdateVersionError(
|
||||
'schema-out-of-sync: v_schedules: no such column: custom_upcoming_length',
|
||||
),
|
||||
).toEqual({
|
||||
error: 'out-of-sync-migrations',
|
||||
report: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown messages fall back to loading-budget and report', () => {
|
||||
expect(classifyUpdateVersionError('something broke')).toEqual({
|
||||
error: 'loading-budget',
|
||||
report: true,
|
||||
});
|
||||
expect(classifyUpdateVersionError('')).toEqual({
|
||||
error: 'loading-budget',
|
||||
report: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('substring matching: longer prefixed messages still classify correctly', () => {
|
||||
expect(
|
||||
classifyUpdateVersionError(
|
||||
'Error: out-of-sync-migrations (id mismatch at index 3)',
|
||||
).error,
|
||||
).toBe('out-of-sync-migrations');
|
||||
|
||||
expect(
|
||||
classifyUpdateVersionError(
|
||||
'Failed: out-of-sync-data — local clock diverged',
|
||||
).error,
|
||||
).toBe('out-of-sync-data');
|
||||
});
|
||||
|
||||
test('schema-out-of-sync is routed correctly even though it shares the "out-of-sync" prefix', () => {
|
||||
// The `out-of-sync-migrations` check looks for the `-migrations` suffix,
|
||||
// so the schema error doesn't match it and falls through to the schema
|
||||
// branch as intended.
|
||||
const result = classifyUpdateVersionError('schema-out-of-sync: v_x: ...');
|
||||
expect(result.error).toBe('out-of-sync-migrations');
|
||||
expect(result.report).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
export type UpdateVersionErrorCode =
|
||||
| 'out-of-sync-migrations'
|
||||
| 'out-of-sync-data'
|
||||
| 'loading-budget';
|
||||
|
||||
// Maps an `updateVersion` failure to the user-facing error and a flag for
|
||||
// whether the error is an unexpected bug worth reporting. `out-of-sync-*`
|
||||
// errors are expected recovery states (the user is guided to resync from the
|
||||
// server); `schema-out-of-sync` and any unrecognized message indicate a real
|
||||
// bug.
|
||||
export function classifyUpdateVersionError(message: string): {
|
||||
error: UpdateVersionErrorCode;
|
||||
report: boolean;
|
||||
} {
|
||||
if (message.includes('out-of-sync-migrations')) {
|
||||
return { error: 'out-of-sync-migrations', report: false };
|
||||
}
|
||||
if (message.includes('out-of-sync-data')) {
|
||||
return { error: 'out-of-sync-data', report: false };
|
||||
}
|
||||
if (message.includes('schema-out-of-sync')) {
|
||||
return { error: 'out-of-sync-migrations', report: true };
|
||||
}
|
||||
return { error: 'loading-budget', report: true };
|
||||
}
|
||||
@@ -1,29 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join as pathJoin } from 'node:path';
|
||||
|
||||
import * as fs from '#platform/server/fs';
|
||||
import * as db from '#server/db';
|
||||
|
||||
import {
|
||||
applyMigration,
|
||||
getAppliedMigrations,
|
||||
getMigrationId,
|
||||
getMigrationList,
|
||||
getMigrationsDir,
|
||||
getPending,
|
||||
getUpMigration,
|
||||
migrate,
|
||||
withMigrationsDir,
|
||||
} from './migrations';
|
||||
|
||||
beforeEach(global.emptyDatabase(true));
|
||||
|
||||
function makeTempMigrationsDir(prefix: string): string {
|
||||
return mkdtempSync(pathJoin(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe('Migrations', () => {
|
||||
test('gets the latest migrations', async () => {
|
||||
const applied = await getAppliedMigrations(db.getDatabase());
|
||||
@@ -36,14 +23,6 @@ describe('Migrations', () => {
|
||||
expect(getPending(applied, available)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('bundled list is sorted by id and includes the latest sql migration', async () => {
|
||||
const available = await getMigrationList(fs.migrationsPath);
|
||||
|
||||
expect(available).toContain('1769000000000_add_custom_upcoming_length.sql');
|
||||
const ids = available.map(getMigrationId);
|
||||
expect(ids).toEqual([...ids].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('applied migrations are returned in order', async () => {
|
||||
return withMigrationsDir(
|
||||
__dirname + '/../../mocks/migrations',
|
||||
@@ -100,282 +79,3 @@ describe('Migrations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMigrationId', () => {
|
||||
test('parses the leading numeric run', () => {
|
||||
expect(getMigrationId('1769000000000_add_custom_upcoming_length.sql')).toBe(
|
||||
1769000000000,
|
||||
);
|
||||
expect(getMigrationId('1632571489012_remove_cache.js')).toBe(1632571489012);
|
||||
expect(getMigrationId('1.sql')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpMigration', () => {
|
||||
test('returns the matching name', () => {
|
||||
const names = ['1000_a.sql', '2000_b.sql', '3000_c.js'];
|
||||
expect(getUpMigration(2000, names)).toBe('2000_b.sql');
|
||||
expect(getUpMigration(3000, names)).toBe('3000_c.js');
|
||||
});
|
||||
|
||||
test('returns undefined when no name matches', () => {
|
||||
expect(getUpMigration(9999, ['1000_a.sql'])).toBeUndefined();
|
||||
expect(getUpMigration(1, [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPending', () => {
|
||||
const all = ['1000_a.sql', '2000_b.sql', '3000_c.sql'];
|
||||
|
||||
test('returns names whose ids are not in applied', () => {
|
||||
expect(getPending([1000], all)).toEqual(['2000_b.sql', '3000_c.sql']);
|
||||
});
|
||||
|
||||
test('returns empty when all are applied', () => {
|
||||
expect(getPending([1000, 2000, 3000], all)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns full list when none are applied', () => {
|
||||
expect(getPending([], all)).toEqual(all);
|
||||
});
|
||||
|
||||
test('ignores applied ids that are not in the available list', () => {
|
||||
expect(getPending([1000, 9999], all)).toEqual(['2000_b.sql', '3000_c.sql']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMigrationList', () => {
|
||||
test('sorts numerically, not lexically, for mixed-length ids', async () => {
|
||||
const dir = makeTempMigrationsDir('mig-sort-');
|
||||
// Lexical sort would put "100" before "9"; numeric sort puts "9" first.
|
||||
writeFileSync(pathJoin(dir, '9_short.sql'), '');
|
||||
writeFileSync(pathJoin(dir, '100_long.sql'), '');
|
||||
writeFileSync(pathJoin(dir, '1000_longer.sql'), '');
|
||||
|
||||
const list = await getMigrationList(dir);
|
||||
|
||||
expect(list).toEqual(['9_short.sql', '100_long.sql', '1000_longer.sql']);
|
||||
});
|
||||
|
||||
test('filters out files that are not .sql or .js', async () => {
|
||||
const dir = makeTempMigrationsDir('mig-filter-');
|
||||
writeFileSync(pathJoin(dir, '1_keep.sql'), '');
|
||||
writeFileSync(pathJoin(dir, '2_keep.js'), 'export default function() {}');
|
||||
writeFileSync(pathJoin(dir, '3_ignore.txt'), '');
|
||||
writeFileSync(pathJoin(dir, '4_ignore.md'), '');
|
||||
writeFileSync(pathJoin(dir, '.force-copy-windows'), '');
|
||||
|
||||
const list = await getMigrationList(dir);
|
||||
|
||||
expect(list).toEqual(['1_keep.sql', '2_keep.js']);
|
||||
});
|
||||
|
||||
test('returns the bundled list for the default migrations dir', async () => {
|
||||
const list = await getMigrationList(fs.migrationsPath);
|
||||
// JS migrations are present as synthesized `${id}.js` entries.
|
||||
expect(list).toContain('1632571489012.js');
|
||||
// Real SQL migrations keep their on-disk filename.
|
||||
expect(list).toContain('1548957970627_remove-db-version.sql');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withMigrationsDir', () => {
|
||||
test('restores the previous dir after the callback resolves', async () => {
|
||||
const before = getMigrationsDir();
|
||||
|
||||
await withMigrationsDir('/tmp/whatever', async () => {
|
||||
expect(getMigrationsDir()).toBe('/tmp/whatever');
|
||||
});
|
||||
|
||||
expect(getMigrationsDir()).toBe(before);
|
||||
});
|
||||
|
||||
test('restores the previous dir when the callback throws', async () => {
|
||||
const before = getMigrationsDir();
|
||||
|
||||
await expect(
|
||||
withMigrationsDir('/tmp/whatever', async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(getMigrationsDir()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMigration', () => {
|
||||
test('SQL path against bundled dir applies the SQL and records the id', async () => {
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
|
||||
|
||||
// 1548957970627_remove-db-version.sql drops the db_version table.
|
||||
await applyMigration(
|
||||
db.getDatabase(),
|
||||
'1548957970627_remove-db-version.sql',
|
||||
fs.migrationsPath,
|
||||
);
|
||||
|
||||
const tbl = await db.first<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'db_version'",
|
||||
);
|
||||
expect(tbl).toBe(null);
|
||||
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
|
||||
1548957970627,
|
||||
]);
|
||||
});
|
||||
|
||||
test('JS path against bundled dir runs the registered handler', async () => {
|
||||
// 1632571489012_remove_cache.js drops spreadsheet_cells and creates
|
||||
// zero_budget_months (plus other budget tables). Bundled names use the
|
||||
// synthesized `${id}.js` form.
|
||||
await applyMigration(
|
||||
db.getDatabase(),
|
||||
'1632571489012.js',
|
||||
fs.migrationsPath,
|
||||
);
|
||||
|
||||
const newTable = await db.first<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
|
||||
);
|
||||
expect(newTable).toBeDefined();
|
||||
expect(newTable.name).toBe('zero_budget_months');
|
||||
|
||||
const droppedTable = await db.first<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'spreadsheet_cells'",
|
||||
);
|
||||
expect(droppedTable).toBe(null);
|
||||
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
|
||||
1632571489012,
|
||||
]);
|
||||
});
|
||||
|
||||
test('JS path throws when the migration id is not registered', async () => {
|
||||
await expect(
|
||||
applyMigration(db.getDatabase(), '9999999999999_unknown.js', '/ignored'),
|
||||
).rejects.toThrow(
|
||||
'Could not find JS migration code to run for 9999999999999',
|
||||
);
|
||||
|
||||
// Nothing recorded in __migrations__.
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
|
||||
});
|
||||
|
||||
test('SQL path propagates SQLite errors and skips recording the id', async () => {
|
||||
const dir = makeTempMigrationsDir('mig-bad-');
|
||||
writeFileSync(pathJoin(dir, '1_broken.sql'), 'THIS IS NOT VALID SQL;');
|
||||
|
||||
await expect(
|
||||
applyMigration(db.getDatabase(), '1_broken.sql', dir),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate (end-to-end against bundled dir)', () => {
|
||||
test('applies every bundled migration on a fresh init.sql DB', async () => {
|
||||
const pending = await migrate(db.getDatabase());
|
||||
|
||||
expect(pending.length).toBeGreaterThan(0);
|
||||
|
||||
const applied = await getAppliedMigrations(db.getDatabase());
|
||||
expect(applied.length).toBe(pending.length);
|
||||
|
||||
// Both bundled JS and SQL migrations executed.
|
||||
expect(applied).toContain(1632571489012); // JS
|
||||
expect(applied).toContain(1769000000000); // SQL: custom_upcoming_length
|
||||
|
||||
// The column the JS migration creates exists in the database.
|
||||
const zb = await db.first<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
|
||||
);
|
||||
expect(zb.name).toBe('zero_budget_months');
|
||||
|
||||
// The column the latest SQL migration adds exists.
|
||||
const cols = await db.all<{ name: string }>(
|
||||
"PRAGMA table_info('schedules')",
|
||||
);
|
||||
expect(cols.map(c => c.name)).toContain('custom_upcoming_length');
|
||||
});
|
||||
|
||||
test('returns an empty pending list on the second invocation (idempotent)', async () => {
|
||||
await migrate(db.getDatabase());
|
||||
const second = await migrate(db.getDatabase());
|
||||
expect(second).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchBadMigrations', () => {
|
||||
test('replaces the bad-filters id with the new-filters id before validity check', async () => {
|
||||
return withMigrationsDir(
|
||||
__dirname + '/../../mocks/migrations',
|
||||
async () => {
|
||||
// Inject the bad id. The mock dir doesn't contain either id, so the
|
||||
// only way patching can avoid an "out-of-sync" throw is by removing
|
||||
// the bad id (and inserting the new one, which we also assert isn't
|
||||
// tripping validity below because it's not in available either).
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1685375406832)');
|
||||
|
||||
// The new id (1688749527273) gets inserted by patchBadMigrations.
|
||||
// Neither id is in the mock available list, so validity will still
|
||||
// complain — assert via the patched table state directly.
|
||||
await migrate(db.getDatabase()).catch(() => {
|
||||
// expected: validity throws because 1688749527273 isn't in mocks
|
||||
});
|
||||
|
||||
const ids = await getAppliedMigrations(db.getDatabase());
|
||||
expect(ids).not.toContain(1685375406832);
|
||||
expect(ids).toContain(1688749527273);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('is a no-op when the bad id is not in __migrations__', async () => {
|
||||
return withMigrationsDir(
|
||||
__dirname + '/../../mocks/migrations',
|
||||
async () => {
|
||||
await migrate(db.getDatabase());
|
||||
const ids = await getAppliedMigrations(db.getDatabase());
|
||||
expect(ids).not.toContain(1685375406832);
|
||||
expect(ids).not.toContain(1688749527273);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDatabaseValidity (via migrate)', () => {
|
||||
test('throws when more migrations are recorded than are available', async () => {
|
||||
return withMigrationsDir(
|
||||
__dirname + '/../../mocks/migrations',
|
||||
async () => {
|
||||
// Mock dir has 3 migrations. Insert 4 unrelated ids → applied.length
|
||||
// (4) > available.length (3) → length-branch throw.
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (4)');
|
||||
|
||||
await expect(migrate(db.getDatabase())).rejects.toThrow(
|
||||
'out-of-sync-migrations',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on id mismatch even when counts match', async () => {
|
||||
return withMigrationsDir(
|
||||
__dirname + '/../../mocks/migrations',
|
||||
async () => {
|
||||
// Same count as available (3) but the ids don't line up.
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
|
||||
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
|
||||
|
||||
await expect(migrate(db.getDatabase())).rejects.toThrow(
|
||||
'out-of-sync-migrations',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,19 +14,6 @@ import { logger } from '#platform/server/log';
|
||||
import * as sqlite from '#platform/server/sqlite';
|
||||
import * as prefs from '#server/prefs';
|
||||
|
||||
// Inline SQL migrations into the worker chunk so they cannot desync with the
|
||||
// AQL schema across a service-worker cache boundary. `import.meta.glob` is
|
||||
// statically analyzed, so the path must be a literal relative to this file.
|
||||
const bundledSqlByName: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(
|
||||
import.meta.glob<string>('../../../migrations/*.sql', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
}),
|
||||
).map(([p, sql]) => [p.slice(p.lastIndexOf('/') + 1), sql]),
|
||||
);
|
||||
|
||||
let MIGRATIONS_DIR = fs.migrationsPath;
|
||||
|
||||
const javascriptMigrations = {
|
||||
@@ -37,36 +24,21 @@ const javascriptMigrations = {
|
||||
1765518577215: m1765518577215,
|
||||
};
|
||||
|
||||
// JS migrations are looked up by id, so the on-disk filename is irrelevant
|
||||
// once bundled — `${id}.js` is enough to round-trip through the sort/filter
|
||||
// path in `getMigrationList`.
|
||||
const bundledMigrationNames: string[] = [
|
||||
...Object.keys(bundledSqlByName),
|
||||
...Object.keys(javascriptMigrations).map(id => `${id}.js`),
|
||||
];
|
||||
|
||||
function isDefaultMigrationsDir(dir: string): boolean {
|
||||
return dir === fs.migrationsPath;
|
||||
}
|
||||
|
||||
export async function withMigrationsDir(
|
||||
dir: string,
|
||||
func: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const oldDir = MIGRATIONS_DIR;
|
||||
MIGRATIONS_DIR = dir;
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
MIGRATIONS_DIR = oldDir;
|
||||
}
|
||||
await func();
|
||||
MIGRATIONS_DIR = oldDir;
|
||||
}
|
||||
|
||||
export function getMigrationsDir(): string {
|
||||
return MIGRATIONS_DIR;
|
||||
}
|
||||
|
||||
export function getMigrationId(name: string): number {
|
||||
function getMigrationId(name: string): number {
|
||||
return parseInt(name.match(/^(\d)+/)[0]);
|
||||
}
|
||||
|
||||
@@ -105,9 +77,7 @@ export async function getAppliedMigrations(db: Database): Promise<number[]> {
|
||||
export async function getMigrationList(
|
||||
migrationsDir: string,
|
||||
): Promise<string[]> {
|
||||
const files = isDefaultMigrationsDir(migrationsDir)
|
||||
? bundledMigrationNames
|
||||
: await fs.listDir(migrationsDir);
|
||||
const files = await fs.listDir(migrationsDir);
|
||||
return files
|
||||
.filter(name => name.match(/(\.sql|\.js)$/))
|
||||
.sort((m1, m2) => {
|
||||
@@ -162,13 +132,11 @@ export async function applyMigration(
|
||||
name: string,
|
||||
migrationsDir: string,
|
||||
): Promise<void> {
|
||||
const code = await fs.readFile(fs.join(migrationsDir, name));
|
||||
if (name.match(/\.js$/)) {
|
||||
await applyJavaScript(db, getMigrationId(name));
|
||||
} else {
|
||||
const sql = isDefaultMigrationsDir(migrationsDir)
|
||||
? bundledSqlByName[name]
|
||||
: await fs.readFile(fs.join(migrationsDir, name));
|
||||
await applySql(db, sql);
|
||||
await applySql(db, code);
|
||||
}
|
||||
sqlite.runQuery(db, 'INSERT INTO __migrations__ (id) VALUES (?)', [
|
||||
getMigrationId(name),
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import * as db from '#server/db';
|
||||
|
||||
import { updateVersion } from './update';
|
||||
|
||||
beforeEach(global.emptyDatabase());
|
||||
|
||||
const VIEWS = [
|
||||
'v_payees',
|
||||
'v_categories',
|
||||
'v_schedules',
|
||||
'v_transactions_internal',
|
||||
'v_transactions_internal_alive',
|
||||
'v_transactions',
|
||||
];
|
||||
|
||||
describe('updateVersion (happy path via emptyDatabase)', () => {
|
||||
test('all configured views are created and queryable after init', async () => {
|
||||
for (const view of VIEWS) {
|
||||
// Throws if the view doesn't exist or its definition is invalid.
|
||||
const row = await db.first<Record<string, unknown>>(
|
||||
`SELECT * FROM ${view} LIMIT 1`,
|
||||
);
|
||||
// Either an empty result or a row — we just need the SELECT to succeed.
|
||||
expect(row === null || typeof row === 'object').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('view hash is stored in __meta__ after init', async () => {
|
||||
const row = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
expect(row?.value).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateViews (re-run behavior)', () => {
|
||||
test('is a no-op when the stored hash matches (does not change __meta__ row)', async () => {
|
||||
const before = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
|
||||
await updateVersion();
|
||||
|
||||
const after = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
expect(after?.value).toBe(before?.value);
|
||||
});
|
||||
|
||||
test('recreates views when the stored hash differs', async () => {
|
||||
// Force a hash mismatch.
|
||||
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
|
||||
|
||||
await updateVersion();
|
||||
|
||||
const after = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
expect(after?.value).not.toBe('stale');
|
||||
expect(after?.value).toMatch(/^[0-9a-f]{32}$/);
|
||||
|
||||
// Views are still queryable after recreation.
|
||||
const row = await db.first<{ id: string }>(
|
||||
'SELECT * FROM v_payees LIMIT 1',
|
||||
);
|
||||
expect(row === null || typeof row === 'object').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeViews (failure surfaces schema-out-of-sync)', () => {
|
||||
test('throws schema-out-of-sync with the failing view name when an underlying table is missing', async () => {
|
||||
// Simulate the #7710 desync: a migration was recorded as applied but the
|
||||
// table/column it created is gone. Dropping `schedules` makes `v_schedules`
|
||||
// unresolvable when probed, even though CREATE VIEW itself succeeds.
|
||||
db.execQuery('DROP TABLE schedules');
|
||||
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
|
||||
|
||||
await expect(updateVersion()).rejects.toThrow(/schema-out-of-sync/);
|
||||
});
|
||||
|
||||
test('error message includes the view name and the underlying cause', async () => {
|
||||
db.execQuery('DROP TABLE schedules');
|
||||
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
if (!(thrown instanceof Error)) {
|
||||
throw new Error(
|
||||
`expected updateVersion to throw an Error, got: ${String(thrown)}`,
|
||||
);
|
||||
}
|
||||
expect(thrown.message).toContain('schema-out-of-sync');
|
||||
expect(thrown.message).toContain('v_schedules');
|
||||
expect(thrown.message.toLowerCase()).toContain('schedules');
|
||||
});
|
||||
|
||||
test('does not throw when every view resolves cleanly (fresh DB)', async () => {
|
||||
// Force re-run of view creation + probe on a healthy DB.
|
||||
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
|
||||
|
||||
await expect(updateVersion()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { logger } from '#platform/server/log';
|
||||
|
||||
import { makeViews, schema, schemaConfig } from './aql';
|
||||
import * as db from './db';
|
||||
import * as migrations from './migrate/migrations';
|
||||
@@ -13,29 +11,6 @@ async function runMigrations() {
|
||||
await migrations.migrate(db.getDatabase());
|
||||
}
|
||||
|
||||
// `'fields'` is a non-view entry inside each table's view map, shared with
|
||||
// `makeViews` — skip it.
|
||||
function getConfiguredViewNames(): string[] {
|
||||
return Object.values(schemaConfig.views).flatMap(tableViews =>
|
||||
Object.keys(tableViews).filter(name => name !== 'fields'),
|
||||
);
|
||||
}
|
||||
|
||||
// Fail fast when the newly-created views reference columns the migrations
|
||||
// didn't add, so the user hits the recovery dialog once at startup instead of
|
||||
// a cryptic error on every UI query.
|
||||
function probeViews(): void {
|
||||
for (const viewName of getConfiguredViewNames()) {
|
||||
try {
|
||||
db.execQuery(`SELECT * FROM ${viewName} LIMIT 0`);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`View ${viewName} failed schema probe`, e);
|
||||
throw new Error(`schema-out-of-sync: ${viewName}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateViews() {
|
||||
const hashKey = 'view-hash';
|
||||
const row = await db.first<{ value: string }>(
|
||||
@@ -53,7 +28,6 @@ async function updateViews() {
|
||||
hashKey,
|
||||
currentHash,
|
||||
]);
|
||||
probeViews();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module '*?bytes' {
|
||||
const bytes: Uint8Array;
|
||||
export default bytes;
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { defineConfig } from 'vite';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
|
||||
import { bytesLoader } from './scripts/bytes-loader.mts';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDev = mode === 'development';
|
||||
@@ -63,7 +61,6 @@ export default defineConfig(({ mode }) => {
|
||||
'process.env.ACTUAL_DOCUMENT_DIR': JSON.stringify('/documents'),
|
||||
},
|
||||
plugins: [
|
||||
bytesLoader(),
|
||||
peggyLoader(),
|
||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
|
||||
nodePolyfills({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import { bytesLoader } from './scripts/bytes-loader.mts';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
@@ -13,5 +11,5 @@ export default defineConfig({
|
||||
],
|
||||
maxWorkers: 2,
|
||||
},
|
||||
plugins: [bytesLoader(), peggyLoader()],
|
||||
plugins: [peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app
|
||||
|
||||
# Copy only the files needed for installing dependencies
|
||||
COPY .yarn ./.yarn
|
||||
COPY yarn.lock package.json .yarnrc.yml tsconfig.json ./
|
||||
COPY yarn.lock package.json .yarnrc.yml tsconfig.json lage.config.js ./
|
||||
COPY packages/api/package.json packages/api/package.json
|
||||
COPY packages/component-library/package.json packages/component-library/package.json
|
||||
COPY packages/crdt/package.json packages/crdt/package.json
|
||||
@@ -31,6 +31,13 @@ COPY packages/ ./packages/
|
||||
# Increase memory limit for the build process to 8GB
|
||||
ENV NODE_OPTIONS=--max_old_space_size=8192
|
||||
|
||||
# lage's task hasher invokes `git ls-tree HEAD` during initialization, so it
|
||||
# needs a git repo even when individual targets disable caching. .dockerignore
|
||||
# omits the real .git, so seed a throwaway repo with a single commit here.
|
||||
RUN git -c init.defaultBranch=master init -q \
|
||||
&& git -c user.email=build@docker -c user.name=docker-build add -A \
|
||||
&& git -c user.email=build@docker -c user.name=docker-build commit -qm build
|
||||
|
||||
RUN yarn build:server
|
||||
|
||||
# Focus the workspaces in production mode (including @actual-app/web you just built)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Bundle `default-db.sqlite` into the loot-core worker chunk so backend init no longer fetches it from the server. Fixes the FatalError users hit when opening the app while the sync server was unreachable (e.g. local-network host on a different Wi-Fi, PWA on iOS).
|
||||
6
upcoming-release-notes/7832.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Automation UI: various tweaks and fixes
|
||||
6
upcoming-release-notes/7850.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [samaluk]
|
||||
---
|
||||
|
||||
Color the Balance Forecast line by zero-balance crossing.
|
||||
6
upcoming-release-notes/7852.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Refactor workflows to utilize native `gh` CLI commands instead of third-party GitHub Actions.
|
||||
6
upcoming-release-notes/7856.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Reference dedicated environments for workflows that consume secrets, satisfying zizmor's `secrets-without-environment` audit.
|
||||
6
upcoming-release-notes/7858.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Fix template injection in setup action's Lage cache step.
|
||||
6
upcoming-release-notes/7861.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Update Dockerfile to ensure `yarn build:server` works after lage migration.
|
||||
12
yarn.lock
@@ -19650,9 +19650,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"lodash-es@npm:^4.17.21":
|
||||
version: 4.17.21
|
||||
resolution: "lodash-es@npm:4.17.21"
|
||||
checksum: 10/03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43
|
||||
version: 4.18.1
|
||||
resolution: "lodash-es@npm:4.18.1"
|
||||
checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -23873,11 +23873,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1":
|
||||
version: 6.14.1
|
||||
resolution: "qs@npm:6.14.1"
|
||||
version: 6.15.1
|
||||
resolution: "qs@npm:6.15.1"
|
||||
dependencies:
|
||||
side-channel: "npm:^1.1.0"
|
||||
checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5
|
||||
checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||