Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@tinacms/cli": minor
"@tinacms/graphql": minor
"@tinacms/metrics": minor
---

Stop writing generated files (`_schema.json`, `_graphql.json`, `_lookup.json`, `tina-lock.json`) to the content repo when `localContentPath` is set. Generated files now live only in the generator repo's `tina/__generated__/`. The content repo is no longer required to contain a `tina/` folder. `FilesystemBridge.get` / `put` / `delete` now route `tina/__generated__/` and `.tina/__generated__/` paths to `rootPath` (the generator) instead of `outputPath` (the content root). Closes [tinacms/tinacloud#3295](https://github.com/tinacms/tinacloud/issues/3295).

### ⚠️ Rollout gate

**This release must not be promoted to the `@latest` dist-tag until TinaCloud prod has deployed [tinacms/tinacloud#3403](https://github.com/tinacms/tinacloud/issues/3403).** Pre-#3403 TinaCloud reads `tina-lock.json` from the content repo on generator pushes; shipping this change before the server-side fix breaks every existing multi-repo user's indexing.

### Migration notes for existing multi-repo projects

After upgrading (and once TinaCloud prod is on #3403):

- **Stale `tina/` folder in your content repo.** Pre-upgrade builds committed `tina/__generated__/*` and `tina/tina-lock.json` to the content repo. Nothing updates or reads those files any more. They are safe — and recommended — to delete from the content repo in a single cleanup commit.
- **`ConfigManager.generatedFolderPathContentRepo` is removed.** If any custom CLI code, plugins, or scripts referenced this field, they will fail at type-check or runtime. Use `generatedFolderPath` — it has always been the generator-relative path.
- **`ConfigManager.getTinaFolderPath` no longer accepts an `isContentRoot` option.** The content root never needs a `tina/` folder now, so the option was removed. If any custom code called `getTinaFolderPath(path, { isContentRoot: true })`, drop the second argument.
- **`FilesystemBridge` behavior change for `tina/__generated__/` paths.** In multi-repo setups, bridge reads/writes of paths under `tina/__generated__/` or `.tina/__generated__/` now resolve against the generator (`rootPath`) rather than the content repo (`outputPath`). If you have custom bridge subclasses or code that relied on these paths resolving to the content repo, update it.
- **Generated `client.ts` / `database-client.ts` now import `./types` extensionless** (was `./types.ts`) for TypeScript projects. Avoids requiring `allowImportingTsExtensions: true` in consumer tsconfigs, which broke the build under Next.js 15.5+ defaults. JS projects still import `./types.js` (Node ESM requires the extension).
67 changes: 59 additions & 8 deletions packages/@tinacms/cli/src/next/codegen/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jest.mock('esbuild', () => ({
transform: jest.fn().mockResolvedValue({ code: '' }),
}));

import path from 'path';
import * as stripModule from './stripSearchTokenFromConfig';
import { Codegen } from './index';

Expand All @@ -33,13 +34,19 @@ describe('Codegen.genClient', () => {
return instance;
}

it('emits ./types.ts import for TypeScript projects', async () => {
it('emits extensionless ./types import for TypeScript projects', async () => {
// Avoids requiring `allowImportingTsExtensions: true` in consumer
// tsconfigs. Modern TS module resolution (Bundler / NodeNext) resolves
// `./types` to `./types.ts` cleanly. The previous `./types.ts` import
// broke under stricter Next.js / TS defaults (see #6062 follow-up).
const { clientString } = await makeInstance(true).genClient();
expect(clientString).toContain('from "./types.ts"');
expect(clientString).not.toMatch(/from ["']\.\/types["']/);
expect(clientString).toContain('from "./types"');
expect(clientString).not.toMatch(/from ["']\.\/types\.ts["']/);
expect(clientString).not.toMatch(/from ["']\.\/types\.js["']/);
});

it('emits ./types.js import for non-TypeScript projects', async () => {
// Node ESM strictly requires the extension on relative imports.
const { clientString } = await makeInstance(false).genClient();
expect(clientString).toContain('from "./types.js"');
expect(clientString).not.toMatch(/from ["']\.\/types["']/);
Expand All @@ -58,10 +65,11 @@ describe('Codegen.genDatabaseClient', () => {
return instance;
}

it('emits ./types.ts import for TypeScript projects', async () => {
it('emits extensionless ./types import for TypeScript projects', async () => {
const result = await makeInstance(true).genDatabaseClient();
expect(result).toContain('from "./types.ts"');
expect(result).not.toMatch(/from ["']\.\/types["']/);
expect(result).toContain('from "./types"');
expect(result).not.toMatch(/from ["']\.\/types\.ts["']/);
expect(result).not.toMatch(/from ["']\.\/types\.js["']/);
});

it('emits ./types.js import for non-TypeScript projects', async () => {
Expand Down Expand Up @@ -106,7 +114,6 @@ describe('Codegen.execute integration', () => {
} as any;
instance.configManager = {
generatedFolderPath: '/fake/tina/__generated__',
generatedFolderPathContentRepo: '/fake/tina/__generated__',
generatedSchemaJSONPath: '/fake/tina/__generated__/_schema.json',
generatedQueriesFilePath: '/fake/tina/__generated__/queries.gql',
generatedFragmentsFilePath: '/fake/tina/__generated__/frags.gql',
Expand All @@ -115,7 +122,6 @@ describe('Codegen.execute integration', () => {
token: 'tok',
clientId: 'cid',
},
hasSeparateContentRoot: () => false,
shouldSkipSDK: () => true,
getTinaGraphQLVersion: () => ({
fullVersion: '1.0.0',
Expand Down Expand Up @@ -172,4 +178,49 @@ describe('Codegen.execute integration', () => {
expect(writtenData.config).toEqual(SAFE_RESULT);
expect(JSON.stringify(writtenData)).not.toContain('secret-token');
});

it('writes each generated config file exactly once (never duplicates to a content repo path)', async () => {
const codegen = stubCodegen();
const fs = jest.requireMock('fs-extra');
(fs.outputFile as jest.Mock).mockClear();

await codegen.execute();

for (const fileName of ['_schema.json', '_graphql.json', '_lookup.json']) {
const calls = (fs.outputFile as jest.Mock).mock.calls.filter(
([filePath]: [string]) => filePath.endsWith(fileName)
);
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe(
path.join(codegen.configManager.generatedFolderPath, fileName)
);
}
});

it('still writes exactly once when the project has a separate content root', async () => {
// Strengthens the previous test: even when hasSeparateContentRoot returns
// true (the multi-repo flag the duplicate-write block used to gate on),
// we should still see exactly one write per generated file. Pins that the
// duplicate-write code is gone, not just unreachable in the default mock.
const codegen = stubCodegen();
(codegen.configManager as any).hasSeparateContentRoot = () => true;
(codegen.configManager as any).contentRootPath = '/fake-content-root';

const fs = jest.requireMock('fs-extra');
(fs.outputFile as jest.Mock).mockClear();

await codegen.execute();

for (const fileName of ['_schema.json', '_graphql.json', '_lookup.json']) {
const calls = (fs.outputFile as jest.Mock).mock.calls.filter(
([filePath]: [string]) => filePath.endsWith(fileName)
);
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe(
path.join(codegen.configManager.generatedFolderPath, fileName)
);
// Specifically: no write under the content-root path.
expect(calls[0][0]).not.toContain('fake-content-root');
}
});
});
12 changes: 2 additions & 10 deletions packages/@tinacms/cli/src/next/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,6 @@ export class Codegen {
);
await fs.ensureFile(filePath);
await fs.outputFile(filePath, data);
if (this.configManager.hasSeparateContentRoot()) {
const filePath = path.join(
this.configManager.generatedFolderPathContentRepo,
fileName
);
await fs.ensureFile(filePath);
await fs.outputFile(filePath, data);
}
}

async removeGeneratedFilesIfExists() {
Expand Down Expand Up @@ -297,7 +289,7 @@ export class Codegen {
import { resolve } from "@tinacms/datalayer";
import type { TinaClient } from "tinacms/dist/client";

import { queries } from "${this.configManager.isUsingTs() ? './types.ts' : './types.js'}";
import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}";
import database from "../database";

export async function databaseRequest({ query, variables, user }) {
Expand Down Expand Up @@ -365,7 +357,7 @@ export default databaseClient;
const apiURL = this.getApiURL();

const clientString = `import { createClient } from "tinacms/dist/client";
import { queries } from "${this.configManager.isUsingTs() ? './types.ts' : './types.js'}";
import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}";
export const client = createClient({ ${
this.noClientBuildCache === false
? `cacheDir: '${normalizePath(
Expand Down
11 changes: 11 additions & 0 deletions packages/@tinacms/cli/src/next/commands/build-command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import crypto from 'crypto';
import path from 'path';
import { ChangeType, diff } from '@graphql-inspector/core';
import { type Database, FilesystemBridge, buildSchema } from '@tinacms/graphql';
import { Telemetry } from '@tinacms/metrics';
import { parseURL } from '@tinacms/schema-tools';
import {
type SearchClient,
Expand Down Expand Up @@ -132,6 +133,16 @@ export class BuildCommand extends BaseCommand {
);
process.exit(1);
}

// Track localContentPath usage so we can measure adoption of the
// multi-repo separation.
const telemetry = new Telemetry({ disabled: this.noTelemetry });
await telemetry.submitRecord({
event: {
name: 'tinacms:cli:build:invoke',
hasLocalContentPath: Boolean(configManager.config.localContentPath),
},
});
if (localContentOnly && !this.localOption) {
const config = configManager.config;
const missing = [];
Expand Down
24 changes: 14 additions & 10 deletions packages/@tinacms/cli/src/next/commands/dev-command/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'path';
import { Database, FilesystemBridge, buildSchema } from '@tinacms/graphql';
import { Telemetry } from '@tinacms/metrics';
import { LocalSearchIndexClient, SearchIndexer } from '@tinacms/search';
import AsyncLock from 'async-lock';
import chokidar from 'chokidar';
Expand Down Expand Up @@ -78,6 +79,19 @@ export class DevCommand extends BaseCommand {
try {
await configManager.processConfig();
if (firstTime) {
// Track localContentPath usage so we can measure adoption of the
// multi-repo separation. Fire once per `tinacms dev` invocation, not
// per reload.
const telemetry = new Telemetry({ disabled: this.noTelemetry });
await telemetry.submitRecord({
event: {
name: 'tinacms:cli:dev:invoke',
hasLocalContentPath: Boolean(
configManager.config.localContentPath
),
},
});

database = await createAndInitializeDatabase(
configManager,
Number(this.datalayerPort)
Expand Down Expand Up @@ -123,16 +137,6 @@ export class DevCommand extends BaseCommand {
path.join(configManager.tinaFolderPath, tinaLockFilename),
tinaLockContent
);

if (configManager.hasSeparateContentRoot()) {
const rootPath = await configManager.getTinaFolderPath(
configManager.contentRootPath,
{ isContentRoot: true }
);
const filePath = path.join(rootPath, tinaLockFilename);
await fs.ensureFile(filePath);
await fs.outputFile(filePath, tinaLockContent);
}
}

await this.indexContentWithSpinner({
Expand Down
21 changes: 2 additions & 19 deletions packages/@tinacms/cli/src/next/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import type { Loader } from 'esbuild';
import { Config } from '@tinacms/schema-tools';
import * as dotenv from 'dotenv';
import normalizePath from 'normalize-path';
import chalk from 'chalk';
import { logger } from '../logger';
import { createRequire } from 'module';
import { stripNativeTrailingSlash } from '../utils/path';
import { logger } from '../logger';
import { warnText } from '../utils/theme';
import { resolveContentRootPath } from './resolve-content-root';

Expand All @@ -34,7 +32,6 @@ export class ConfigManager {
envFilePath: string;
generatedCachePath: string;
generatedFolderPath: string;
generatedFolderPathContentRepo: string;
generatedGraphQLGQLPath: string;
generatedGraphQLJSONPath: string;
generatedSchemaJSONPath: string;
Expand Down Expand Up @@ -260,22 +257,13 @@ export class ConfigManager {
localContentPath: this.config.localContentPath,
});

this.generatedFolderPathContentRepo = path.join(
await this.getTinaFolderPath(this.contentRootPath, {
isContentRoot: this.hasSeparateContentRoot(),
}),
GENERATED_FOLDER
);
this.spaMainPath = require.resolve('@tinacms/app');
this.spaRootPath = path.join(this.spaMainPath, '..', '..');
// =================
// End of paths that depend on the config file
}

async getTinaFolderPath(
rootPath: string,
{ isContentRoot }: { isContentRoot?: boolean } = {}
) {
async getTinaFolderPath(rootPath: string) {
const tinaFolderPath = path.join(rootPath, TINA_FOLDER);
const tinaFolderExists = await fs.pathExists(tinaFolderPath);
if (tinaFolderExists) {
Expand All @@ -288,11 +276,6 @@ export class ConfigManager {
this.isUsingLegacyFolder = true;
return legacyFolderPath;
}
if (isContentRoot) {
throw new Error(
`Unable to find a ${chalk.cyan('tina/')} folder in your content root at ${chalk.cyan(rootPath)}. When using localContentPath, the content directory must contain a ${chalk.cyan('tina/')} folder for generated files. Create one with: mkdir ${path.join(rootPath, TINA_FOLDER)}`
);
}
throw new Error(
`Unable to find Tina folder, if you're working in folder outside of the Tina config be sure to specify --rootPath`
);
Expand Down
Loading
Loading