Skip to content
Open
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
27 changes: 25 additions & 2 deletions src/BuildEngineFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {Config} from './Config'
import {CrudOperations} from './CrudOperations'
import {DateTimeHelper} from './DateTimeHelper'
import {DependencyGraph} from './DependencyGraph'
import {SheetSizeLimitExceededError} from './errors'
import {LicenseCapabilityMissingError, SheetSizeLimitExceededError} from './errors'
import {Evaluator} from './Evaluator'
import {Exporter} from './Exporter'
import {GraphBuilder} from './GraphBuilder'
Expand All @@ -19,6 +19,8 @@ import {ArithmeticHelper} from './interpreter/ArithmeticHelper'
import {FunctionRegistry} from './interpreter/FunctionRegistry'
import {Interpreter} from './interpreter/Interpreter'
import {LazilyTransformingAstService} from './LazilyTransformingAstService'
import {allowsFeature} from './license/CapabilityRegistry'
import {FeatureId} from './license/LicenseEntitlement'
import {buildColumnSearchStrategy, ColumnSearchStrategy} from './Lookup/SearchStrategy'
import {NamedExpressions} from './NamedExpressions'
import {NumberLiteralHelper} from './NumberLiteralHelper'
Expand Down Expand Up @@ -50,23 +52,44 @@ export type EngineState = {
export class BuildEngineFactory {
public static buildFromSheets(sheets: Sheets, configInput: Partial<ConfigParams> = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState {
const config = new Config(configInput)
this.ensureNamedExpressionsCapability(config, namedExpressions)
return this.buildEngine(config, sheets, namedExpressions)
}

public static buildFromSheet(sheet: Sheet, configInput: Partial<ConfigParams> = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState {
const config = new Config(configInput)
this.ensureNamedExpressionsCapability(config, namedExpressions)
const newsheetprefix = config.translationPackage.getUITranslation(UIElement.NEW_SHEET_PREFIX) + '1'
return this.buildEngine(config, {[newsheetprefix]: sheet}, namedExpressions)
}

public static buildEmpty(configInput: Partial<ConfigParams> = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState {
return this.buildEngine(new Config(configInput), {}, namedExpressions)
const config = new Config(configInput)
this.ensureNamedExpressionsCapability(config, namedExpressions)
return this.buildEngine(config, {}, namedExpressions)
}

public static rebuildWithConfig(config: Config, sheets: Sheets, namedExpressions: SerializedNamedExpression[], stats: Statistics): EngineState {
return this.buildEngine(config, sheets, namedExpressions, stats)
}

/**
* Throws if `namedExpressions` is non-empty and `config`'s entitlement does not grant
* {@link FeatureId.NamedExpressions} (HF-307 PR 2, task 2.3 - the build-time counterpart of
* {@link HyperFormula.ensureCapability}). An empty list is never checked: building an engine
* with no named expressions never touches the feature. Deliberately not called from
* {@link rebuildWithConfig}, which re-serializes named expressions an already-built instance
* created (and was allowed to create) rather than accepting them fresh from a caller.
*/
private static ensureNamedExpressionsCapability(config: Config, namedExpressions: SerializedNamedExpression[]): void {
if (namedExpressions.length === 0) {
return
}
if (config.isLicenseGateActive && !allowsFeature(config.licenseCapabilities, FeatureId.NamedExpressions)) {
throw new LicenseCapabilityMissingError(FeatureId.NamedExpressions)
}
}

private static buildEngine(config: Config, sheets: Sheets = {}, inputNamedExpressions: SerializedNamedExpression[] = [], stats: Statistics = config.useStats ? new Statistics() : new EmptyStatistics()): EngineState {
stats.start(StatType.BUILD_ENGINE_TOTAL)

Expand Down
78 changes: 78 additions & 0 deletions src/HyperFormula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ import {
ExpectedValueOfTypeError,
LanguageAlreadyRegisteredError,
LanguageNotRegisteredError,
LicenseCapabilityMissingError,
NotAFormulaError,
} from './errors'
import {Evaluator} from './Evaluator'
import {ExportedChange, Exporter} from './Exporter'
import {LicenseKeyValidityState} from './helpers/licenseKeyValidator'
import {allowsFeature} from './license/CapabilityRegistry'
import {FeatureId} from './license/LicenseEntitlement'
import {buildTranslationPackage, RawTranslationPackage, TranslationPackage} from './i18n'
import {FunctionPluginDefinition} from './interpreter'
import {FUNCTION_DOCS} from './interpreter/functionMetadata'
Expand Down Expand Up @@ -1237,6 +1240,7 @@ export class HyperFormula implements TypedEmitter {
* @category Undo and Redo
*/
public undo(): ExportedChange[] {
this.ensureCapability(FeatureId.UndoRedo)
this._crudOperations.undo()
return this.recomputeIfDependencyGraphNeedsIt()
}
Expand Down Expand Up @@ -1275,6 +1279,7 @@ export class HyperFormula implements TypedEmitter {
* @category Undo and Redo
*/
public redo(): ExportedChange[] {
this.ensureCapability(FeatureId.UndoRedo)
this._crudOperations.redo()
return this.recomputeIfDependencyGraphNeedsIt()
}
Expand Down Expand Up @@ -1407,6 +1412,7 @@ export class HyperFormula implements TypedEmitter {
* @category Cells
*/
public setCellContents(topLeftCornerAddress: SimpleCellAddress, cellContents: RawCellContent[][] | RawCellContent): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
this._crudOperations.setCellContents(topLeftCornerAddress, cellContents)
return this.recomputeIfDependencyGraphNeedsIt()
}
Expand Down Expand Up @@ -1459,6 +1465,7 @@ export class HyperFormula implements TypedEmitter {
* @category Rows
*/
public swapRowIndexes(sheetId: number, rowMapping: [number, number][]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.setRowOrder(sheetId, rowMapping)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -1542,6 +1549,7 @@ export class HyperFormula implements TypedEmitter {
* @category Rows
*/
public setRowOrder(sheetId: number, newRowOrder: number[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
const mapping = this._crudOperations.mappingFromOrder(sheetId, newRowOrder, 'row')
return this.swapRowIndexes(sheetId, mapping)
Expand Down Expand Up @@ -1635,6 +1643,7 @@ export class HyperFormula implements TypedEmitter {
* @category Columns
*/
public swapColumnIndexes(sheetId: number, columnMapping: [number, number][]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.setColumnOrder(sheetId, columnMapping)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -1713,6 +1722,7 @@ export class HyperFormula implements TypedEmitter {
* @category Columns
*/
public setColumnOrder(sheetId: number, newColumnOrder: number[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
const mapping = this._crudOperations.mappingFromOrder(sheetId, newColumnOrder, 'column')
return this.swapColumnIndexes(sheetId, mapping)
Expand Down Expand Up @@ -1824,6 +1834,7 @@ export class HyperFormula implements TypedEmitter {
* @category Rows
*/
public addRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.addRows(sheetId, ...indexes)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -1896,6 +1907,7 @@ export class HyperFormula implements TypedEmitter {
* @category Rows
*/
public removeRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.removeRows(sheetId, ...indexes)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -1972,6 +1984,7 @@ export class HyperFormula implements TypedEmitter {
* @category Columns
*/
public addColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.addColumns(sheetId, ...indexes)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -2047,6 +2060,7 @@ export class HyperFormula implements TypedEmitter {
* @category Columns
*/
public removeColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.removeColumns(sheetId, ...indexes)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -2140,6 +2154,7 @@ export class HyperFormula implements TypedEmitter {
* @category Cells
*/
public moveCells(source: SimpleCellRange, destinationLeftCorner: SimpleCellAddress): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
if (!isSimpleCellAddress(destinationLeftCorner)) {
throw new ExpectedValueOfTypeError('SimpleCellAddress', 'destinationLeftCorner')
}
Expand Down Expand Up @@ -2226,6 +2241,7 @@ export class HyperFormula implements TypedEmitter {
* @category Rows
*/
public moveRows(sheetId: number, startRow: number, numberOfRows: number, targetRow: number): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
validateArgToType(startRow, 'number', 'startRow')
validateArgToType(numberOfRows, 'number', 'numberOfRows')
Expand Down Expand Up @@ -2314,6 +2330,7 @@ export class HyperFormula implements TypedEmitter {
* @category Columns
*/
public moveColumns(sheetId: number, startColumn: number, numberOfColumns: number, targetColumn: number): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
validateArgToType(startColumn, 'number', 'startColumn')
validateArgToType(numberOfColumns, 'number', 'numberOfColumns')
Expand Down Expand Up @@ -2352,6 +2369,7 @@ export class HyperFormula implements TypedEmitter {
* @category Clipboard
*/
public copy(source: SimpleCellRange): CellValue[][] {
this.ensureCapability(FeatureId.Clipboard)
if (!isSimpleCellRange(source)) {
throw new ExpectedValueOfTypeError('SimpleCellRange', 'source')
}
Expand Down Expand Up @@ -2392,6 +2410,7 @@ export class HyperFormula implements TypedEmitter {
* @category Clipboard
*/
public cut(source: SimpleCellRange): CellValue[][] {
this.ensureCapability(FeatureId.Clipboard)
if (!isSimpleCellRange(source)) {
throw new ExpectedValueOfTypeError('SimpleCellRange', 'source')
}
Expand Down Expand Up @@ -2443,6 +2462,7 @@ export class HyperFormula implements TypedEmitter {
* @category Clipboard
*/
public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] {
this.ensureCapability(FeatureId.Clipboard)
if (!isSimpleCellAddress(targetLeftCorner)) {
throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner')
}
Expand Down Expand Up @@ -2769,6 +2789,7 @@ export class HyperFormula implements TypedEmitter {
* @category Sheets
*/
public addSheet(sheetName?: string): string {
this.ensureCapability(FeatureId.Crud)
if (sheetName !== undefined) {
validateArgToType(sheetName, 'string', 'sheetName')
}
Expand Down Expand Up @@ -2844,6 +2865,7 @@ export class HyperFormula implements TypedEmitter {
* @category Sheets
*/
public removeSheet(sheetId: number): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
const displayName = this.sheetMapping.getSheetName(sheetId) as string
this._crudOperations.removeSheet(sheetId)
Expand Down Expand Up @@ -2917,6 +2939,7 @@ export class HyperFormula implements TypedEmitter {
* @category Sheets
*/
public clearSheet(sheetId: number): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.clearSheet(sheetId)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -2984,6 +3007,7 @@ export class HyperFormula implements TypedEmitter {
* @category Sheets
*/
public setSheetContent(sheetId: number, values: RawCellContent[][]): ExportedChange[] {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
this._crudOperations.setSheetContent(sheetId, values)
return this.recomputeIfDependencyGraphNeedsIt()
Expand Down Expand Up @@ -3671,6 +3695,7 @@ export class HyperFormula implements TypedEmitter {
* @category Sheets
*/
public renameSheet(sheetId: number, newName: string): void {
this.ensureCapability(FeatureId.Crud)
validateArgToType(sheetId, 'number', 'sheetId')
validateArgToType(newName, 'string', 'newName')
const oldName = this._crudOperations.renameSheet(sheetId, newName)
Expand Down Expand Up @@ -3712,6 +3737,7 @@ export class HyperFormula implements TypedEmitter {
* @category Batch
*/
public batch(batchOperations: () => void): ExportedChange[] {
this.ensureCapability(FeatureId.Batching)
this.suspendEvaluation()
this._crudOperations.beginUndoRedoBatchMode()
try {
Expand Down Expand Up @@ -3759,6 +3785,7 @@ export class HyperFormula implements TypedEmitter {
* @category Batch
*/
public suspendEvaluation(): void {
this.ensureCapability(FeatureId.Batching)
this._evaluationSuspended = true
this._emitter.emit(Events.EvaluationSuspended)
}
Expand Down Expand Up @@ -3795,6 +3822,15 @@ export class HyperFormula implements TypedEmitter {
* @category Batch
*/
public resumeEvaluation(): ExportedChange[] {
// Deliberately NOT gated, unlike suspendEvaluation and batch. This is the only exit from
// a suspended engine, and _evaluationSuspended survives rebuildWithConfig: an instance
// suspended while Batching was granted, whose entitlement then loses Batching via
// updateConfig, would be stuck suspended forever - every read throws
// EvaluationSuspendedError and the sole recovery path would throw
// LicenseCapabilityMissingError. Gating the two entry points is what makes the feature
// licensable; gating the release valve only strands the caller, which is the same reason
// teardown (clearClipboard, clearUndoStack, clearRedoStack) is ungated. See the note on
// ensureCapability.
this._evaluationSuspended = false
const changes = this.recomputeIfDependencyGraphNeedsIt()
this._emitter.emit(Events.EvaluationResumed, changes)
Expand Down Expand Up @@ -3902,6 +3938,7 @@ export class HyperFormula implements TypedEmitter {
* @category Named Expressions
*/
public addNamedExpression(expressionName: string, expression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] {
this.ensureCapability(FeatureId.NamedExpressions)
validateArgToType(expressionName, 'string', 'expressionName')
if (scope !== undefined) {
validateArgToType(scope, 'number', 'scope')
Expand Down Expand Up @@ -4124,6 +4161,7 @@ export class HyperFormula implements TypedEmitter {
* @category Named Expressions
*/
public changeNamedExpression(expressionName: string, newExpression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] {
this.ensureCapability(FeatureId.NamedExpressions)
validateArgToType(expressionName, 'string', 'expressionName')
if (scope !== undefined) {
validateArgToType(scope, 'number', 'scope')
Expand Down Expand Up @@ -4205,6 +4243,7 @@ export class HyperFormula implements TypedEmitter {
* @category Named Expressions
*/
public removeNamedExpression(expressionName: string, scope?: number): ExportedChange[] {
this.ensureCapability(FeatureId.NamedExpressions)
validateArgToType(expressionName, 'string', 'expressionName')
if (scope !== undefined) {
validateArgToType(scope, 'number', 'scope')
Expand Down Expand Up @@ -4767,6 +4806,45 @@ export class HyperFormula implements TypedEmitter {
}
}

/**
* Throws an error if the current license entitlement does not grant the given feature.
* A no-op read (`isLicenseGateActive === false`) whenever this instance's entitlement is
* unrestricted, i.e. for every key this library fully understands today (HF-307 PR 1); the
* check only does work once a real license-key payload adapter (a later HF-307 PR) can
* produce a restricted entitlement.
*
* Where the line is drawn, so a later change does not move it by accident:
* - **Gated:** methods that create value by mutating the sheet, the clipboard, the undo
* history, or the named-expression set.
* - **Not gated:** reads (`getCellValue`, `listNamedExpressions`,
* `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates) and teardown or
* cleanup that only ever removes state (`clearClipboard`, `clearUndoStack`,
* `clearRedoStack`, `destroy`). Gating cleanup would let a restricted entitlement strand
* an integration mid-teardown while giving a licensee nothing, and mirrors gate B, which
* blocks *calling* a function rather than *reading* an already-computed value.
* - **Not gated, for the same reason:** `resumeEvaluation`, the sole exit from a suspended
* engine. Gate the entry points (`suspendEvaluation`, `batch`) and the feature is
* licensable; gate the release valve too and an entitlement change mid-suspension leaves
* the instance permanently unusable. A capability check must never be reachable only on
* the way out of a state it let the caller into.
*
* Note this checks gate B (entitlement) only, never gate A (key validity). That asymmetry
* with the interpreter's gate B - which checks key validity first - is deliberate: it keeps
* today's behaviour for a missing or invalid key, where formulas yield `#LIC!` but the CRUD
* API keeps working. A later PR that resolves an invalid key to a *restricted* entitlement
* rather than an unrestricted one would silently turn that into a breaking API change.
*
* @internal
*/
private ensureCapability(feature: FeatureId): void {
if (!this._config.isLicenseGateActive) {
return
}
if (!allowsFeature(this._config.licenseCapabilities, feature)) {
throw new LicenseCapabilityMissingError(feature)
}
}

/**
* Parses a formula string and extracts its AST and dependencies.
*
Expand Down
25 changes: 25 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import {SimpleCellAddress} from './Cell'
import {FeatureId} from './license/LicenseEntitlement'

/**
* Error thrown when the sheet of a given ID does not exist.
Expand Down Expand Up @@ -392,3 +393,27 @@ export class AliasAlreadyExisting extends Error {
super(`Alias id ${name} in plugin ${pluginName} already defined as a function or alias.`)
}
}

/**
* Error thrown when a public API method is called for a {@link FeatureId} that the current
* license entitlement does not grant. Mirrors gate B's `ErrorMessage.LicenseCapability`, but
* this one guards the API surface itself (HF-307 PR 2) rather than a formula evaluation, so it
* is thrown synchronously instead of surfacing as a cell error.
*
* @see [[addNamedExpression]]
* @see [[changeNamedExpression]]
* @see [[removeNamedExpression]]
* @see [[copy]]
* @see [[cut]]
* @see [[paste]]
* @see [[undo]]
* @see [[redo]]
* @see [[batch]]
* @see [[suspendEvaluation]]
* @see [[resumeEvaluation]]
*/
export class LicenseCapabilityMissingError extends Error {
constructor(feature: FeatureId) {
super(`Feature ${feature} is not included in your license.`)
}
}
Loading